Skip to content

fix(gateway): deduplicate BlueBubbles inbound messages by GUID + content hash - #19976

Open
alexmacarthur wants to merge 1 commit into
NousResearch:mainfrom
alexmacarthur:fix/bluebubbles-dedup
Open

fix(gateway): deduplicate BlueBubbles inbound messages by GUID + content hash#19976
alexmacarthur wants to merge 1 commit into
NousResearch:mainfrom
alexmacarthur:fix/bluebubbles-dedup

Conversation

@alexmacarthur

@alexmacarthur alexmacarthur commented May 5, 2026

Copy link
Copy Markdown

What does this PR do?

After disabling SIP in BlueBubbles, which Hermes requires in order to access the Private API, I began getting two independent responses for any given message (see screenshot below).

Digging through the BlueBubbles logs and Hermes integration, I discovered it's due to BlueBubbles dispatching webhook requests after both the incoming new message as well as the "read" event:

[2026-05-04 18:39:19.396][info] [BlueBubblesServer] New Message from +*******1111, "new message"
[2026-05-04 18:39:19.398][debug] [WebhookService] Dispatching event to webhook: ...

[2026-05-04 18:39:20.201][info] [BlueBubblesServer] Read message from [+*******1111]: ["new message"]
[2026-05-04 18:39:20.202][debug] [WebhookService] Dispatching event to webhook: ...

The first fires from the private API (phone helper) on immediate detection. The second fires ~800ms later when the Mac Messages DB sync confirms the message. Both carry the same guid and dateCreated — the second is simply a read-receipt-triggered re-dispatch.

The fix adds a bounded OrderedDict cache of (guid, text_hash) pairs that suppresses the duplicate webhook before it reaches the agent loop. The cache is size-bounded (max 1000). I considered making eviction time-bound, but doing so by size is simpler and fully deterministic.

Related Issue

I consider this to be a small slice for the feature discussed here.

#8513

This PR solely focuses on deduplicating message requests, which will arguably remain a concern even after a more mature feature is fleshed out.

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • gateway/platforms/bluebubbles.py
  • tests/gateway/test_bluebubbles.py

How to Test

  1. Send an iMessage to the Hermes BlueBubbles integration.
  2. Verify only one response is produced (not two).
  3. Check ~/.hermes/logs/gateway.log — confirm only one inbound message: platform=bluebubbles entry per message.
  4. Check the BlueBubbles server log (~/Library/Logs/bluebubbles-server/main.log) — confirm two dispatches still arrive, but only one is processed by Hermes.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform:

Documentation & Housekeeping

  • [N/A] I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • [N/A] I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • [N/A] I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • [N/A] I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots

Example of receiving duplicate messages:
CleanShot 2026-05-04 at 19 27 45@2x

@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists labels May 5, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #18395 — same root cause: BlueBubbles dispatches duplicate webhooks for same message GUID. #18395 already implements dedup by GUID+chat; this PR narrows scope to GUID+content-hash.

@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #18395

@alexmacarthur

Copy link
Copy Markdown
Author

Good callout, @alt-glitch. I weighed whether this is its own PR, but the other MR has been out for a few weeks, and the issue is impacting people right now. I'm hoping that one can build upon the deduplication handling contained here, while fixing the problem more quickly for users.

# BlueBubbles can dispatch requests for the same message ~500-900ms apart,
# particularly when SIP is disabled. De-duplciation tracks by (guid, text_hash)
# to avoid sending the same message multiple times.
_MAX_DEDUP_CACHE = 1000

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a largely arbitrary number that felt fine. Up for debate, tho.

# Inbound message dedup (BlueBubbles fires each message twice)
# ------------------------------------------------------------------

def _check_dedup(self, guid: Optional[str], text: str) -> bool:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a case to be made for separating writing & reading to the dictionary, rather than co-locate them here.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for isolating the duplicate-webhook problem. The premise is still present on current main: every accepted BlueBubbles webhook reaches asyncio.create_task(self.handle_message(event)) at gateway/platforms/bluebubbles.py:1040, while the adapter subscribes to both new-message and updated-message at gateway/platforms/bluebubbles.py:374-377.

Problems

  • The proposed (guid, text_hash) key can drop a later same-GUID, same-text attachment/lifecycle update. Current main builds attachment media at gateway/platforms/bluebubbles.py:934-968; the PR's key does not include event type or attachment state.
  • Current main already provides bounded TTL deduplication in gateway/platforms/helpers.py:27-75. The PR adds a second size-only implementation instead of extending that helper.
  • The new tests call _check_dedup directly, but do not verify duplicate payloads through _handle_webhook result in one handle_message dispatch.

Suggested changes

  • Use MessageDeduplicator with a canonical replay fingerprint including GUID, event type, text, and relevant attachment/update metadata.
  • Add async webhook-path tests for exact replay suppression and same-GUID lifecycle updates.
  • Remove the unused time import.

Automated hermes-sweeper review.

import logging
import os
import re
import time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

time is not referenced by this PR's implementation. Please remove the unused import.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
JoshHobbs added a commit to JoshHobbs/hermes-agent that referenced this pull request Jul 18, 2026
BlueBubbles surfaces a single 1:1 conversation under more than one chat_id,
and build_session_key used the raw value, so one thread split across several
session keys:

  1. The adapter sets `session_chat_id = chat_guid or chat_identifier`
     (gateway/platforms/bluebubbles.py), so a webhook carrying no chat GUID
     falls back to the bare handle. The two forms key differently:
     `any;-;+1555…` vs `+1555…`.
  2. The GUID form recorded for one conversation is not stable over time. On
     the deployment this was found on, sessions carry `iMessage;-;+1555…` from
     May and `any;-;+1555…` since July, while the server today reports exactly
     one chat for that handle (`any;-;+1555…`, chatIdentifier `+1555…`) and
     uses the `any` prefix for every chat it knows about. Whatever drove that
     change server-side, the routing key should not depend on it.

The usual report of this is duplicate replies (NousResearch#30708, NousResearch#34372): two chat-id
variants defeat the in-flight guard, so a message gets answered twice. The
split has a second and worse consequence that has not been reported. Each
variant is a separate SessionEntry with its own updated_at, so a variant that
has not been messaged recently goes stale while the conversation continues
under another. When a webhook eventually routes to the stale variant,
_should_reset() finds it idle and clears an actively-used conversation.
Observed in production: a thread whose live session held 298 messages was
reset because a GUID-less webhook landed on a sibling key last touched 20 days
earlier. The notice reads "inactive for 3h" because it renders
policy.idle_minutes rather than measured elapsed time, so it does not point at
the real cause.

Canonicalize the DM chat_id the way WhatsApp already canonicalizes JID/LID
aliases: unwrap the `<service>;-;` prefix so every form of one conversation
maps to the bare handle. Group GUIDs use `;+;` and carry an opaque chat id
rather than a participant handle, so they are returned untouched, as is every
other platform. BlueBubbles needs no group-participant equivalent of the
WhatsApp fix: the adapter already sets user_id from handle.address, which is
a bare handle.

Where a deployment does have distinct iMessage and SMS chats for the same
handle, those now share one session key. That is intended — one human, one
agent conversation — and replies to an inbound message are unaffected, since
they route on the live event's source.chat_id rather than on the key.

Existing sessions are not orphaned. Canonicalization rewrites only the routing
key, never source.chat_id, so when the exact-key lookup misses after upgrade,
find_latest_gateway_session_for_peer's peer-tuple fallback still matches the
stored row on (source, user_id, chat_id, chat_type, thread_id) and adopts the
transcript under the new key. The regression test drives build_session_key
rather than hardcoding the key, so it fails both if the canonicalization is
dropped and if source.chat_id is ever canonicalized too.

This is the session-key half of NousResearch#30708, complementary to the open adapter-side
PRs (NousResearch#45717, NousResearch#34378, NousResearch#18395, NousResearch#19976, NousResearch#27985) that suppress the duplicate-event
trigger. Those do not make the key stable on their own: the form drift in (2)
puts one conversation under two keys with no duplicate event involved, so the
reset stays reachable with any of them merged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants