diff --git a/app/AGENTS.md b/app/AGENTS.md index 214ac41..7888741 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -362,6 +362,16 @@ packet, `packet_processor.py` handles the complete flow: export, unknown contact), `on_contact_message` handles DMs from the MeshCore library's `CONTACT_MSG_RECV` event. DB deduplication prevents double-storage when both paths fire. +**Prefix-stored DMs (edge case)**: A rare scenario can occur when the radio can decrypt +a DM (contact is on the radio) but the server cannot (private key not exported or +contact not yet known server-side). The `CONTACT_MSG_RECV` payload may only include a +pubkey prefix. If the full key isn't known yet, the message is stored with the prefix +as `conversation_key`. When a full contact key becomes known (via advertisement or +radio sync), the server attempts to **claim** those prefix messages and upgrade them +to the full key. Claims only occur when the prefix matches exactly one contact to +avoid mis-attribution in large contact sets. Until claimed, these DMs will not show +in the UI because conversations are keyed by full public keys. + **Outgoing DMs**: Outgoing direct messages are only sent via the app's REST API (`POST /api/messages/direct`), which stores the plaintext directly in the database. No decryption is needed for outgoing DMs. The real-time packet processor may also see diff --git a/app/migrations.py b/app/migrations.py index d3cb5b5..dc99ce7 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -993,3 +993,4 @@ async def _migrate_015_fix_null_sender_timestamp(conn: aiosqlite.Connection) -> ) await conn.commit() + diff --git a/app/packet_processor.py b/app/packet_processor.py index 2fc4869..7377441 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -685,6 +685,13 @@ async def _process_advertisement( } await ContactRepository.upsert(contact_data) + claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower()) + if claimed > 0: + logger.info( + "Claimed %d prefix DM message(s) for contact %s", + claimed, + advert.public_key[:12], + ) # Broadcast contact update to connected clients broadcast_event( diff --git a/app/radio_sync.py b/app/radio_sync.py index 1e32ab7..d8969fc 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -18,7 +18,7 @@ from meshcore import EventType from app.models import Contact from app.radio import radio_manager -from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository +from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository, MessageRepository logger = logging.getLogger(__name__) @@ -95,6 +95,13 @@ async def sync_and_offload_contacts() -> dict: await ContactRepository.upsert( Contact.from_radio_dict(public_key, contact_data, on_radio=False) ) + claimed = await MessageRepository.claim_prefix_messages(public_key.lower()) + if claimed > 0: + logger.info( + "Claimed %d prefix DM message(s) for contact %s", + claimed, + public_key[:12], + ) synced += 1 # Remove from radio diff --git a/app/repository.py b/app/repository.py index fcfe504..cd4f3fc 100644 --- a/app/repository.py +++ b/app/repository.py @@ -390,7 +390,11 @@ class MessageRepository: cursor = await db.conn.execute( """UPDATE messages SET conversation_key = ? WHERE type = 'PRIV' AND length(conversation_key) < 64 - AND ? LIKE conversation_key || '%'""", + AND ? LIKE conversation_key || '%' + AND ( + SELECT COUNT(*) FROM contacts + WHERE public_key LIKE messages.conversation_key || '%' + ) = 1""", (lower_key, lower_key), ) await db.conn.commit() diff --git a/frontend/src/useWebSocket.ts b/frontend/src/useWebSocket.ts index 2c0ea14..1f04221 100644 --- a/frontend/src/useWebSocket.ts +++ b/frontend/src/useWebSocket.ts @@ -60,6 +60,10 @@ export function useWebSocket(options: UseWebSocketOptions) { ws.onopen = () => { console.log('WebSocket connected'); setConnected(true); + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } }; ws.onclose = () => { @@ -68,6 +72,9 @@ export function useWebSocket(options: UseWebSocketOptions) { wsRef.current = null; // Reconnect after 3 seconds + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } reconnectTimeoutRef.current = window.setTimeout(() => { console.log('Attempting WebSocket reconnect...'); connect(); @@ -146,6 +153,7 @@ export function useWebSocket(options: UseWebSocketOptions) { clearInterval(pingInterval); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; } if (wsRef.current) { wsRef.current.close();