mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 19:12:57 +02:00
Move resend button into modal
This commit is contained in:
@@ -306,10 +306,17 @@ RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
async def resend_channel_message(message_id: int) -> dict:
|
||||
"""Resend a channel message within 30 seconds of original send.
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
"""Resend a channel message.
|
||||
|
||||
Performs a byte-perfect resend using the same timestamp bytes as the original.
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
Only allowed within 30 seconds of the original send.
|
||||
|
||||
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
|
||||
new packet. Creates a new message row in the database. No time window restriction.
|
||||
"""
|
||||
mc = require_connected()
|
||||
|
||||
@@ -328,16 +335,22 @@ async def resend_channel_message(message_id: int) -> dict:
|
||||
if msg.sender_timestamp is None:
|
||||
raise HTTPException(status_code=400, detail="Message has no timestamp")
|
||||
|
||||
elapsed = int(time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
|
||||
# Byte-perfect resend enforces the 30s window; new-timestamp resend does not
|
||||
if not new_timestamp:
|
||||
elapsed = int(time.time()) - msg.sender_timestamp
|
||||
if elapsed > RESEND_WINDOW_SECONDS:
|
||||
raise HTTPException(status_code=400, detail="Resend window has expired (30 seconds)")
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(msg.conversation_key)
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
# Reconstruct timestamp bytes
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
|
||||
if new_timestamp:
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
@@ -374,5 +387,47 @@ async def resend_channel_message(message_id: int) -> dict:
|
||||
status_code=500, detail=f"Failed to resend message: {result.payload}"
|
||||
)
|
||||
|
||||
# For new-timestamp resend, create a new message row and broadcast it
|
||||
if new_timestamp:
|
||||
new_msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=msg.text,
|
||||
conversation_key=msg.conversation_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
if new_msg_id is None:
|
||||
# Timestamp-second collision (same text+channel within the same second).
|
||||
# The radio already transmitted, so log and return the original ID rather
|
||||
# than surfacing a 500 for a message that was successfully sent over the air.
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message_id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=new_msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=msg.conversation_key,
|
||||
text=msg.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message_id,
|
||||
new_msg_id,
|
||||
db_channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_msg_id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
@@ -371,16 +371,21 @@ export function App() {
|
||||
);
|
||||
|
||||
// Handle resend channel message
|
||||
const handleResendChannelMessage = useCallback(async (messageId: number) => {
|
||||
try {
|
||||
await api.resendChannelMessage(messageId);
|
||||
toast.success('Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const handleResendChannelMessage = useCallback(
|
||||
async (messageId: number, newTimestamp?: boolean) => {
|
||||
try {
|
||||
// New-timestamp resend creates a new message; the backend broadcast_event
|
||||
// will add it to the conversation via WebSocket.
|
||||
await api.resendChannelMessage(messageId, newTimestamp);
|
||||
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle sender click to add mention
|
||||
const handleSenderClick = useCallback((sender: string) => {
|
||||
|
||||
@@ -167,10 +167,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
resendChannelMessage: (messageId: number) =>
|
||||
fetchJson<{ status: string; message_id: number }>(`/messages/channel/${messageId}/resend`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
|
||||
fetchJson<{ status: string; message_id: number }>(
|
||||
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
// Packets
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
|
||||
@@ -23,7 +23,7 @@ interface MessageListProps {
|
||||
hasOlderMessages?: boolean;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number) => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
}
|
||||
@@ -156,12 +156,11 @@ export function MessageList({
|
||||
const [selectedPath, setSelectedPath] = useState<{
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
messageId?: number;
|
||||
isOutgoingChan?: boolean;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const activeBurstsRef = useRef<Map<number, ReturnType<typeof setTimeout>[]>>(new Map());
|
||||
const onResendRef = useRef(onResendChannelMessage);
|
||||
onResendRef.current = onResendChannelMessage;
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -262,17 +261,6 @@ export function MessageList({
|
||||
};
|
||||
}, [messages, onResendChannelMessage]);
|
||||
|
||||
// Clean up burst timers on unmount
|
||||
useEffect(() => {
|
||||
const bursts = activeBurstsRef.current;
|
||||
return () => {
|
||||
for (const timers of bursts.values()) {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
}
|
||||
bursts.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -315,6 +303,21 @@ export function MessageList({
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Sender info for outgoing messages (used by path modal on own messages)
|
||||
const selfSenderInfo = useMemo<SenderInfo>(
|
||||
() => ({
|
||||
name: config?.name || 'Unknown',
|
||||
publicKeyOrPrefix: config?.public_key || '',
|
||||
lat: config?.lat ?? null,
|
||||
lon: config?.lon ?? null,
|
||||
}),
|
||||
[config?.name, config?.public_key, config?.lat, config?.lon]
|
||||
);
|
||||
|
||||
// Derive live so the byte-perfect button disables if the 30s window expires while modal is open
|
||||
const isSelectedMessageResendable =
|
||||
selectedPath?.messageId !== undefined && resendableIds.has(selectedPath.messageId);
|
||||
|
||||
// Look up contact by public key
|
||||
const getContact = (conversationKey: string | null): Contact | null => {
|
||||
if (!conversationKey) return null;
|
||||
@@ -520,34 +523,6 @@ export function MessageList({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing && onResendChannelMessage && resendableIds.has(msg.id) && (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-primary ml-1 text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.altKey) {
|
||||
// Burst resend: 5 times, 2 seconds apart
|
||||
if (activeBurstsRef.current.has(msg.id)) return;
|
||||
onResendChannelMessage(msg.id); // first send (immediate)
|
||||
const msgId = msg.id;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const timer = setTimeout(() => {
|
||||
onResendRef.current?.(msgId);
|
||||
if (i === 4) activeBurstsRef.current.delete(msgId);
|
||||
}, i * 3000);
|
||||
timers.push(timer);
|
||||
}
|
||||
activeBurstsRef.current.set(msgId, timers);
|
||||
} else {
|
||||
onResendChannelMessage(msg.id);
|
||||
}
|
||||
}}
|
||||
title="Resend message"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
@@ -557,12 +532,9 @@ export function MessageList({
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: {
|
||||
name: config?.name || 'Unknown',
|
||||
publicKeyOrPrefix: config?.public_key || '',
|
||||
lat: config?.lat ?? null,
|
||||
lon: config?.lon ?? null,
|
||||
},
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
@@ -570,6 +542,23 @@ export function MessageList({
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="Message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
@@ -616,6 +605,10 @@ export function MessageList({
|
||||
senderInfo={selectedPath.senderInfo}
|
||||
contacts={contacts}
|
||||
config={config ?? null}
|
||||
messageId={selectedPath.messageId}
|
||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||
isResendable={isSelectedMessageResendable}
|
||||
onResend={onResendChannelMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,14 +28,34 @@ interface PathModalProps {
|
||||
senderInfo: SenderInfo;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
messageId?: number;
|
||||
isOutgoingChan?: boolean;
|
||||
isResendable?: boolean;
|
||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
}
|
||||
|
||||
export function PathModal({ open, onClose, paths, senderInfo, contacts, config }: PathModalProps) {
|
||||
export function PathModal({
|
||||
open,
|
||||
onClose,
|
||||
paths,
|
||||
senderInfo,
|
||||
contacts,
|
||||
config,
|
||||
messageId,
|
||||
isOutgoingChan,
|
||||
isResendable,
|
||||
onResend,
|
||||
}: PathModalProps) {
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
|
||||
// Resolve all paths
|
||||
const resolvedPaths = paths.map((p) => ({
|
||||
...p,
|
||||
resolved: resolvePath(p.path, senderInfo, contacts, config),
|
||||
}));
|
||||
const resolvedPaths = hasPaths
|
||||
? paths.map((p) => ({
|
||||
...p,
|
||||
resolved: resolvePath(p.path, senderInfo, contacts, config),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const hasSinglePath = paths.length === 1;
|
||||
|
||||
@@ -43,9 +63,15 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config }
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Path{!hasSinglePath && `s (${paths.length})`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{hasPaths
|
||||
? `Message Path${!hasSinglePath ? `s (${paths.length})` : ''}`
|
||||
: 'Message Status'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasSinglePath ? (
|
||||
{!hasPaths ? (
|
||||
<>No echoes heard yet. Echoes appear when repeaters re-broadcast your message.</>
|
||||
) : hasSinglePath ? (
|
||||
<>
|
||||
This shows <em>one route</em> that this message traveled through the mesh network.
|
||||
Routers may be incorrectly identified due to prefix collisions between heard and
|
||||
@@ -60,63 +86,98 @@ export function PathModal({ open, onClose, paths, senderInfo, contacts, config }
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasPaths && (
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
const hops = parsePathHops(p.path);
|
||||
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
|
||||
return (
|
||||
<div key={index}>
|
||||
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
|
||||
<span className="font-mono text-muted-foreground">{rawPath}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Straight-line distance (sender to receiver, same for all routes) */}
|
||||
{resolvedPaths.length > 0 &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon
|
||||
) &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
) && (
|
||||
<div className="text-sm pb-2 border-b border-border">
|
||||
<span className="text-muted-foreground">Straight-line distance: </span>
|
||||
<span className="font-medium">
|
||||
{formatDistance(
|
||||
calculateDistance(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
{!hasSinglePath && (
|
||||
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
{/* Straight-line distance (sender to receiver, same for all routes) */}
|
||||
{resolvedPaths.length > 0 &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon
|
||||
) &&
|
||||
isValidLocation(
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
) && (
|
||||
<div className="text-sm pb-2 border-b border-border">
|
||||
<span className="text-muted-foreground">Straight-line distance: </span>
|
||||
<span className="font-medium">
|
||||
{formatDistance(
|
||||
calculateDistance(
|
||||
resolvedPaths[0].resolved.sender.lat,
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
{!hasSinglePath && (
|
||||
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
{hasResendActions && (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{isResendable && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onResend(messageId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
↻ Resend (byte-perfect)
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onResend(messageId, true);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-tight">
|
||||
<span>↻ Resend as new</span>
|
||||
<span className="text-[10px] font-normal opacity-80">
|
||||
May cause duplicate receives
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function useConversationRouter({
|
||||
setActiveConversationState(publicConversation);
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}, [channels, activeConversation, getPublicChannelConversation]);
|
||||
}, [channels, activeConversation, getPublicChannelConversation, hasSetDefaultConversation]);
|
||||
|
||||
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
|
||||
useEffect(() => {
|
||||
@@ -186,7 +186,14 @@ export function useConversationRouter({
|
||||
hasSetDefaultConversation.current = true;
|
||||
}
|
||||
}
|
||||
}, [contacts, channels, activeConversation, contactsLoaded, getPublicChannelConversation]);
|
||||
}, [
|
||||
contacts,
|
||||
channels,
|
||||
activeConversation,
|
||||
contactsLoaded,
|
||||
getPublicChannelConversation,
|
||||
hasSetDefaultConversation,
|
||||
]);
|
||||
|
||||
// Keep ref in sync and update URL hash
|
||||
useEffect(() => {
|
||||
@@ -221,7 +228,7 @@ export function useConversationRouter({
|
||||
id: publicChannel.key,
|
||||
name: publicChannel.name,
|
||||
});
|
||||
}, [activeConversation, channels]);
|
||||
}, [activeConversation, channels, hasSetDefaultConversation, pendingDeleteFallbackRef]);
|
||||
|
||||
// Handle conversation selection (closes sidebar on mobile)
|
||||
const handleSelectConversation = useCallback(
|
||||
|
||||
@@ -283,7 +283,7 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with patch("app.routers.messages.require_connected", return_value=mc):
|
||||
result = await resend_channel_message(msg_id)
|
||||
result = await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert result["message_id"] == msg_id
|
||||
@@ -316,11 +316,42 @@ class TestResendChannelMessage:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "expired" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_new_timestamp_collision_returns_original_id(self, test_db):
|
||||
"""When new-timestamp resend collides (same second), return original ID gracefully."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#collision")
|
||||
|
||||
now = int(time.time())
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: duplicate",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
):
|
||||
# Force the same second so MessageRepository.create returns None (duplicate)
|
||||
mock_time.time.return_value = float(now)
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
# Should succeed gracefully, returning the original message ID
|
||||
assert result["status"] == "ok"
|
||||
assert result["message_id"] == msg_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_non_outgoing_returns_400(self, test_db):
|
||||
"""Resend of incoming message fails."""
|
||||
@@ -343,7 +374,7 @@ class TestResendChannelMessage:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "outgoing" in exc_info.value.detail.lower()
|
||||
@@ -369,7 +400,7 @@ class TestResendChannelMessage:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id)
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "channel" in exc_info.value.detail.lower()
|
||||
@@ -383,7 +414,7 @@ class TestResendChannelMessage:
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(999999)
|
||||
await resend_channel_message(999999, new_timestamp=False)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
@@ -406,7 +437,126 @@ class TestResendChannelMessage:
|
||||
assert msg_id is not None
|
||||
|
||||
with patch("app.routers.messages.require_connected", return_value=mc):
|
||||
await resend_channel_message(msg_id)
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
|
||||
assert call_kwargs["msg"] == "hello world"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_new_timestamp_skips_window(self, test_db):
|
||||
"""new_timestamp=True succeeds even when the 30s window has expired."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#old")
|
||||
|
||||
old_ts = int(time.time()) - 60 # 60 seconds ago — outside byte-perfect window
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: old message",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=old_ts,
|
||||
received_at=old_ts,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
# Should return a NEW message id, not the original
|
||||
assert result["message_id"] != msg_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_new_timestamp_creates_new_message(self, test_db):
|
||||
"""new_timestamp=True creates a new DB row with a different sender_timestamp."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#new")
|
||||
|
||||
old_ts = int(time.time()) - 10
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: test",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=old_ts,
|
||||
received_at=old_ts,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
new_msg_id = result["message_id"]
|
||||
new_msg = await MessageRepository.get_by_id(new_msg_id)
|
||||
original_msg = await MessageRepository.get_by_id(msg_id)
|
||||
|
||||
assert new_msg is not None
|
||||
assert original_msg is not None
|
||||
assert new_msg.sender_timestamp != original_msg.sender_timestamp
|
||||
assert new_msg.text == original_msg.text
|
||||
assert new_msg.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_new_timestamp_broadcasts_message(self, test_db):
|
||||
"""new_timestamp=True broadcasts the new message via WebSocket."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#broadcast")
|
||||
|
||||
old_ts = int(time.time()) - 5
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: broadcast test",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=old_ts,
|
||||
received_at=old_ts,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, event_data = mock_broadcast.call_args.args
|
||||
assert event_type == "message"
|
||||
assert event_data["id"] == result["message_id"]
|
||||
assert event_data["outgoing"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_byte_perfect_still_enforces_window(self, test_db):
|
||||
"""Default (byte-perfect) resend still enforces the 30s window."""
|
||||
mc = _make_mc(name="MyNode")
|
||||
chan_key = "dd" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#window")
|
||||
|
||||
old_ts = int(time.time()) - 60
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="MyNode: expired",
|
||||
conversation_key=chan_key.upper(),
|
||||
sender_timestamp=old_ts,
|
||||
received_at=old_ts,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "expired" in exc_info.value.detail.lower()
|
||||
|
||||
Reference in New Issue
Block a user