From 8ab19582cdfd13f7cb2bef3b83dc08429f28a05f Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 25 Feb 2026 10:34:39 +0100 Subject: [PATCH] fix(dm): Fix unread markers by deduping retries in all endpoints Extracted dedup_retry_messages() helper (300s window, was 120s which was too tight) and applied it in three places: - GET /api/dm/messages - already had inline dedup, now uses helper - get_dm_conversations() - fixes last_message_timestamp inflation - GET /api/dm/updates - was missing dedup entirely, counted retries as unread messages (root cause of persistent unread markers) Co-Authored-By: Claude Opus 4.6 --- app/meshcore/parser.py | 34 +++++++++++++++++++++------------- app/routes/api.py | 19 ++++--------------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/app/meshcore/parser.py b/app/meshcore/parser.py index 336e215..1ba1d6f 100644 --- a/app/meshcore/parser.py +++ b/app/meshcore/parser.py @@ -559,6 +559,26 @@ def read_dm_messages( return messages, pubkey_to_name +def dedup_retry_messages(messages: List[Dict], window_seconds: int = 300) -> List[Dict]: + """Collapse outgoing messages with same text+recipient within a time window. + + Auto-retry sends multiple SENT_MSG entries for the same message. + This keeps only the first occurrence and drops duplicates within the window. + """ + deduped = [] + seen_outgoing = {} # (recipient, text) -> earliest timestamp + for msg in messages: + if msg.get('direction') == 'outgoing': + key = (msg.get('recipient', ''), msg.get('content', '')) + ts = msg.get('timestamp', 0) + prev_ts = seen_outgoing.get(key) + if prev_ts is not None and abs(ts - prev_ts) <= window_seconds: + continue + seen_outgoing[key] = ts + deduped.append(msg) + return deduped + + def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]: """ Get list of DM conversations with metadata. @@ -582,19 +602,7 @@ def get_dm_conversations(days: Optional[int] = 7) -> List[Dict]: """ messages, pubkey_to_name = read_dm_messages(days=days) - # Deduplicate outgoing retry messages: collapse same text+recipient within 120s - deduped = [] - seen_outgoing = {} # (recipient, text) -> earliest timestamp - for msg in messages: - if msg.get('direction') == 'outgoing': - key = (msg.get('recipient', ''), msg.get('content', '')) - ts = msg.get('timestamp', 0) - prev_ts = seen_outgoing.get(key) - if prev_ts is not None and abs(ts - prev_ts) < 120: - continue - seen_outgoing[key] = ts - deduped.append(msg) - messages = deduped + messages = dedup_retry_messages(messages) # Build reverse mapping: name -> pubkey_prefix name_to_pubkey = {name: pk for pk, name in pubkey_to_name.items()} diff --git a/app/routes/api.py b/app/routes/api.py index 2743e4c..86ecef8 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1680,20 +1680,8 @@ def get_dm_messages(): except Exception as e: logger.debug(f"Retry dedup failed (non-critical): {e}") - # Secondary dedup: collapse outgoing messages with same text+recipient - # within 120s window (catches retries whose ack wasn't tracked, e.g. timeouts) - deduped = [] - seen_outgoing = {} # (recipient, text) -> earliest timestamp - for msg in messages: - if msg.get('direction') == 'outgoing': - key = (msg.get('recipient', ''), msg.get('content', '')) - ts = msg.get('timestamp', 0) - prev_ts = seen_outgoing.get(key) - if prev_ts is not None and abs(ts - prev_ts) < 120: - continue # Skip duplicate within time window - seen_outgoing[key] = ts - deduped.append(msg) - messages = deduped + # Secondary dedup: collapse retries with same text+recipient within 5min window + messages = parser.dedup_retry_messages(messages) # Determine display name from conversation_id or messages display_name = 'Unknown' @@ -1895,11 +1883,12 @@ def get_dm_updates(): # Count unread if conv['last_message_timestamp'] > last_seen_ts: - # Need to count actual unread messages + # Need to count actual unread messages (dedup retries) messages, _ = parser.read_dm_messages( conversation_id=conv_id, days=7 ) + messages = parser.dedup_retry_messages(messages) unread_count = sum(1 for m in messages if m['timestamp'] > last_seen_ts) else: unread_count = 0