mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Forward whole message to FE on resend so the browser updates
This commit is contained in:
@@ -347,6 +347,12 @@ class MessagesAroundResponse(BaseModel):
|
||||
has_newer: bool
|
||||
|
||||
|
||||
class ResendChannelMessageResponse(BaseModel):
|
||||
status: str
|
||||
message_id: int
|
||||
message: Message | None = None
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
"""Decryption info for a raw packet (when successfully decrypted)."""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
ResendChannelMessageResponse,
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
@@ -171,11 +172,15 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
@router.post(
|
||||
"/channel/{message_id}/resend",
|
||||
response_model=ResendChannelMessageResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
) -> dict[str, object]:
|
||||
"""Resend a channel message.
|
||||
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
|
||||
@@ -530,7 +530,7 @@ async def resend_channel_message_record(
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_message.id}
|
||||
return {"status": "ok", "message_id": new_message.id, "message": new_message}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
|
||||
@@ -34,6 +34,12 @@ import type {
|
||||
UnreadCounts,
|
||||
} from './types';
|
||||
|
||||
export interface ResendChannelMessageResponse {
|
||||
status: string;
|
||||
message_id: number;
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
@@ -238,7 +244,7 @@ export const api = {
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
|
||||
fetchJson<{ status: string; message_id: number }>(
|
||||
fetchJson<ResendChannelMessageResponse>(
|
||||
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
|
||||
@@ -69,7 +69,16 @@ export function useConversationActions({
|
||||
const handleResendChannelMessage = useCallback(
|
||||
async (messageId: number, newTimestamp?: boolean) => {
|
||||
try {
|
||||
await api.resendChannelMessage(messageId, newTimestamp);
|
||||
const resent = await api.resendChannelMessage(messageId, newTimestamp);
|
||||
const resentMessage = resent.message;
|
||||
if (
|
||||
newTimestamp &&
|
||||
resentMessage &&
|
||||
activeConversationRef.current?.type === 'channel' &&
|
||||
activeConversationRef.current.id === resentMessage.conversation_key
|
||||
) {
|
||||
addMessageIfNew(resentMessage);
|
||||
}
|
||||
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
@@ -77,7 +86,7 @@ export function useConversationActions({
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
[activeConversationRef, addMessageIfNew]
|
||||
);
|
||||
|
||||
const handleSetChannelFloodScopeOverride = useCallback(
|
||||
|
||||
@@ -125,6 +125,74 @@ describe('useConversationActions', () => {
|
||||
expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] ');
|
||||
});
|
||||
|
||||
it('appends a new-timestamp resend immediately for the active channel', async () => {
|
||||
const resentMessage: Message = {
|
||||
...sentMessage,
|
||||
id: 99,
|
||||
sender_timestamp: 1700000100,
|
||||
received_at: 1700000100,
|
||||
};
|
||||
mocks.api.resendChannelMessage.mockResolvedValue({
|
||||
status: 'ok',
|
||||
message_id: resentMessage.id,
|
||||
message: resentMessage,
|
||||
});
|
||||
const args = createArgs();
|
||||
|
||||
const { result } = renderHook(() => useConversationActions(args));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleResendChannelMessage(sentMessage.id, true);
|
||||
});
|
||||
|
||||
expect(mocks.api.resendChannelMessage).toHaveBeenCalledWith(sentMessage.id, true);
|
||||
expect(args.addMessageIfNew).toHaveBeenCalledWith(resentMessage);
|
||||
});
|
||||
|
||||
it('does not append a byte-perfect resend locally', async () => {
|
||||
mocks.api.resendChannelMessage.mockResolvedValue({
|
||||
status: 'ok',
|
||||
message_id: sentMessage.id,
|
||||
});
|
||||
const args = createArgs();
|
||||
|
||||
const { result } = renderHook(() => useConversationActions(args));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleResendChannelMessage(sentMessage.id, false);
|
||||
});
|
||||
|
||||
expect(args.addMessageIfNew).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not append a resend if the user has switched conversations', async () => {
|
||||
const resentMessage: Message = {
|
||||
...sentMessage,
|
||||
id: 100,
|
||||
sender_timestamp: 1700000200,
|
||||
received_at: 1700000200,
|
||||
};
|
||||
mocks.api.resendChannelMessage.mockResolvedValue({
|
||||
status: 'ok',
|
||||
message_id: resentMessage.id,
|
||||
message: resentMessage,
|
||||
});
|
||||
const args = createArgs();
|
||||
const { result } = renderHook(() => useConversationActions(args));
|
||||
|
||||
await act(async () => {
|
||||
const resendPromise = result.current.handleResendChannelMessage(sentMessage.id, true);
|
||||
args.activeConversationRef.current = {
|
||||
type: 'channel',
|
||||
id: 'AA'.repeat(16),
|
||||
name: 'Other',
|
||||
};
|
||||
await resendPromise;
|
||||
});
|
||||
|
||||
expect(args.addMessageIfNew).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges returned contact data after path discovery', async () => {
|
||||
const contactKey = 'aa'.repeat(32);
|
||||
const discoveredContact: Contact = {
|
||||
|
||||
@@ -491,6 +491,50 @@ class TestMessagesEndpoint:
|
||||
assert send_kwargs["msg"] == "hello world"
|
||||
assert send_kwargs["timestamp"] == sent_at.to_bytes(4, "little")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_new_timestamp_returns_message_payload(
|
||||
self, test_db, client
|
||||
):
|
||||
"""New-timestamp resend returns the created message payload for local UI append."""
|
||||
from meshcore import EventType
|
||||
|
||||
chan_key = "EF" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#resend-new")
|
||||
sent_at = int(time.time()) - 5
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="TestNode: hello again",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=sent_at,
|
||||
received_at=sent_at,
|
||||
outgoing=True,
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode", "public_key": "ab" * 32}
|
||||
mock_mc.commands = MagicMock()
|
||||
mock_mc.commands.set_channel = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={})
|
||||
)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with _patch_require_connected(mock_mc):
|
||||
response = await client.post(
|
||||
f"/api/messages/channel/{msg_id}/resend?new_timestamp=true"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["message_id"] != msg_id
|
||||
assert payload["message"]["id"] == payload["message_id"]
|
||||
assert payload["message"]["conversation_key"] == chan_key
|
||||
assert payload["message"]["outgoing"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resend_channel_message_window_expired(self, test_db, client):
|
||||
"""Resend endpoint rejects channel messages older than 30 seconds."""
|
||||
|
||||
@@ -766,6 +766,11 @@ class TestResendChannelMessage:
|
||||
assert result["message_id"] != msg_id
|
||||
resent = await MessageRepository.get_by_id(result["message_id"])
|
||||
assert resent is not None
|
||||
assert result["message"].id == resent.id
|
||||
assert result["message"].conversation_key == resent.conversation_key
|
||||
assert result["message"].text == resent.text
|
||||
assert result["message"].sender_timestamp == resent.sender_timestamp
|
||||
assert result["message"].outgoing is True
|
||||
assert resent.sender_timestamp == now + 1
|
||||
assert resent.received_at == now
|
||||
sent_timestamp = int.from_bytes(
|
||||
|
||||
Reference in New Issue
Block a user