From de30dfe87b39074856770e816c14615d06860c94 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 6 Mar 2026 14:36:33 -0800 Subject: [PATCH] Backfill channel sender identity when it's available --- app/event_handlers.py | 7 + app/packet_processor.py | 10 ++ app/radio_sync.py | 11 ++ app/repository/messages.py | 16 +++ app/routers/contacts.py | 17 +++ frontend/src/App.tsx | 5 +- frontend/src/components/ContactInfoPane.tsx | 18 +++ frontend/src/components/MessageList.tsx | 8 +- tests/test_channel_sender_backfill.py | 145 ++++++++++++++++++++ 9 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 tests/test_channel_sender_backfill.py diff --git a/app/event_handlers.py b/app/event_handlers.py index acee3b5..84dceb3 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -267,6 +267,13 @@ async def on_new_contact(event: "Event") -> None: await ContactNameHistoryRepository.record_name( public_key.lower(), adv_name, int(time.time()) ) + backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name) + if backfilled > 0: + logger.info( + "Backfilled sender_key on %d channel message(s) for %s", + backfilled, + adv_name, + ) # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, etc.) matching the REST Contact shape exactly. diff --git a/app/packet_processor.py b/app/packet_processor.py index de1da24..3e40a79 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -763,6 +763,16 @@ async def _process_advertisement( claimed, advert.public_key[:12], ) + if advert.name: + backfilled = await MessageRepository.backfill_channel_sender_key( + advert.public_key, advert.name + ) + if backfilled > 0: + logger.info( + "Backfilled sender_key on %d channel message(s) for %s", + backfilled, + advert.name, + ) # Read back from DB so the broadcast includes all fields (last_contacted, # last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly. diff --git a/app/radio_sync.py b/app/radio_sync.py index f6148e9..e4976aa 100644 --- a/app/radio_sync.py +++ b/app/radio_sync.py @@ -136,6 +136,17 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict: claimed, public_key[:12], ) + adv_name = contact_data.get("adv_name") + if adv_name: + backfilled = await MessageRepository.backfill_channel_sender_key( + public_key, adv_name + ) + if backfilled > 0: + logger.info( + "Backfilled sender_key on %d channel message(s) for %s", + backfilled, + adv_name, + ) synced += 1 # Remove from radio diff --git a/app/repository/messages.py b/app/repository/messages.py index 59deb47..d3cb977 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -128,6 +128,22 @@ class MessageRepository: await db.conn.commit() return cursor.rowcount + @staticmethod + async def backfill_channel_sender_key(public_key: str, name: str) -> int: + """Backfill sender_key on channel messages that match a contact's name. + + When a contact becomes known (via advert, sync, or manual creation), + any channel messages with a matching sender_name but no sender_key + are updated to associate them with this contact's public key. + """ + cursor = await db.conn.execute( + """UPDATE messages SET sender_key = ? + WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""", + (public_key.lower(), name), + ) + await db.conn.commit() + return cursor.rowcount + @staticmethod def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]: """Normalize a conversation key and return (sql_clause, normalized_key). diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 0d4ee03..4744377 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -154,6 +154,14 @@ async def create_contact( if claimed > 0: logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12]) + # Backfill sender_key on channel messages that match this contact's name + if request.name: + backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name) + if backfilled > 0: + logger.info( + "Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name + ) + # Trigger historical decryption if requested if request.try_historical: await start_historical_dm_decryption(background_tasks, lower_key, request.name) @@ -281,6 +289,15 @@ async def sync_contacts_from_radio() -> dict: claimed = await MessageRepository.claim_prefix_messages(lower_key) if claimed > 0: logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12]) + adv_name = contact_data.get("adv_name") + if adv_name: + backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name) + if backfilled > 0: + logger.info( + "Backfilled sender_key on %d channel message(s) for %s", + backfilled, + adv_name, + ) count += 1 # Clear on_radio for contacts not found on the radio diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1103aa3..5337841 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -84,6 +84,7 @@ export function App() { const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); + const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); const [targetMessageId, setTargetMessageId] = useState(null); @@ -523,8 +524,9 @@ export function App() { setShowCracker((prev) => !prev); }, []); - const handleOpenContactInfo = useCallback((publicKey: string) => { + const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { setInfoPaneContactKey(publicKey); + setInfoPaneFromChannel(fromChannel ?? false); }, []); const handleCloseContactInfo = useCallback(() => { @@ -934,6 +936,7 @@ export function App() { = { interface ContactInfoPaneProps { contactKey: string | null; + fromChannel?: boolean; onClose: () => void; contacts: Contact[]; config: RadioConfig | null; @@ -34,6 +35,7 @@ interface ContactInfoPaneProps { export function ContactInfoPane({ contactKey, + fromChannel = false, onClose, contacts, config, @@ -117,6 +119,8 @@ export function ContactInfoPane({ + {fromChannel && } + {/* Block by name toggle */} {onToggleBlockedName && (
@@ -186,6 +190,8 @@ export function ContactInfoPane({
+ {fromChannel && } + {/* Info grid */}
@@ -436,6 +442,18 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } +function ChannelAttributionWarning() { + return ( +
+

+ Channel sender identity is based on best-effort name matching. Different nodes using the + same name will be attributed to the same contact. Message counts and key-based statistics + may be inaccurate. +

+
+ ); +} + function InfoItem({ label, value }: { label: string; value: string }) { return (
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 477f630..7602b01 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -27,7 +27,7 @@ interface MessageListProps { onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void; radioName?: string; config?: RadioConfig | null; - onOpenContactInfo?: (publicKey: string) => void; + onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void; targetMessageId?: number | null; onTargetReached?: () => void; hasNewerMessages?: boolean; @@ -532,7 +532,11 @@ export function MessageList({ role={onOpenContactInfo ? 'button' : undefined} tabIndex={onOpenContactInfo ? 0 : undefined} onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined} - onClick={onOpenContactInfo ? () => onOpenContactInfo(avatarKey) : undefined} + onClick={ + onOpenContactInfo + ? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN') + : undefined + } >