BlueBubbles chat routing and webhook parsing bugs

NousResearch/hermes-agent · cluster 8514 · gateway

Data as of

4 open items. Fix merged in #56132. Check it covers your case.

The short answer

1open issue
3open PRs
10items grouped
Known?
Yes. 10 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #56132. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #56132. That is separate from the fix event.
Still open
1 issue and 3 PRs.

Multiple related bugs and fixes around the BlueBubbles gateway: incorrect chat GUID resolution causing DMs to misroute to group chats, webhook payloads with null or nested chatGuid fields, duplicate message processing, and group chat session separation issues.

Open issues1

  • Issue#38339BlueBubbles group webhooks can lose chat identity when only chats[0]["[auth-key]"] is populated

Open PRs3

  • PR#38342fix(bluebubbles): parse nested auth-key group webhooks
  • PR#38379fix(bluebubbles): ignore update echoes before dedup
  • PR#45717fix(bluebubbles): prevent duplicate processing and DM-to-group misrouting

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56132 fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid

    merged to main

    files: gateway/platforms/bluebubbles.py, tests/gateway/test_bluebubbles.py

    Show the diff
    • gateway/platforms/bluebubbles.py
      @@ -433,8 +433,15 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
       
               If *target* already contains a semicolon (raw GUID format like
               ``iMessage;-;[redacted:email]``), it is returned as-is.  Otherwise
      -        the adapter queries the BlueBubbles chat list and matches on
      -        ``chatIdentifier`` or participant address.
      +        the adapter queries the BlueBubbles chat list and matches strictly
      +        on ``chatIdentifier`` / ``identifier``.
      +
      +        Participant membership is intentionally NOT used as a fallback:
      +        the same contact can appear in a 1:1 DM and in any number of group
      +        chats, so a participant match would let an outbound DM reply leak
      +        into a group thread (see #24157). When no exact chat identity
      +        matches, return ``None`` and let the caller create a fresh DM
      +        explicitly via ``_create_chat_for_handle``.
               """
               target = (target or "").strip()
               if not target:
      @@ -448,7 +455,7 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
               try:
                   payload = await self._api_post(
                       "/api/v1/chat/query",
      -                {"limit": 100, "offset": 0, "with": ["participants"]},
      +                {"limit": 100, "offset": 0},
                   )
                   for chat in payload.get("data", []) or []:
                       guid = chat.get("guid") or chat.get("chatGuid")
      @@ -459,12 +466,6 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
                               while len(self._guid_cache) > _GUID_CACHE_SIZE:
                                   self._guid_cache.popitem(last=False)
                           return guid
      -                for part in chat.get("participants", []) or []:
      -                    if (part.get("address") or "").strip() == target and guid:
      -                        self._guid_cache[target] = guid
      -                        while len(self._guid_cache) > _GUID_CACHE_SIZE:
      -                            self._guid_cache.popitem(last=False)
      -                        return guid
               except Exception:
                   pass
               return None
    • tests/gateway/test_bluebubbles.py
      @@ -426,6 +426,110 @@ def test_empty_target_returns_none(self, monkeypatch):
               )
               assert result is None
       
      +    @pytest.mark.asyncio
      +    async def test_exact_chat_identifier_match_returns_dm_guid(self, monkeypatch):
      +        """A 1:1 DM whose chatIdentifier equals the target resolves to its guid."""
      +        adapter = _make_adapter(monkeypatch)
      +
      +        async def fake_api_post(path, payload):
      +            return {
      +                "data": [
      +                    {
      +                        "guid": "iMessage;-;[redacted:email]",
      +                        "chatIdentifier": "[redacted:email]",
      +                        "participants": [{"address": "[redacted:email]"}],
      +                    }
      +                ]
      +            }
      +
      +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
      +        result = await adapter._resolve_chat_guid("[redacted:email]")
      +        assert result == "iMessage;-;[redacted:email]"
      +
      +    @pytest.mark.asyncio
      +    async def test_participant_only_match_does_not_resolve_to_group(self, monkeypatch):
      +        """Regression for #24157: contact appearing as a participant in a group
      +        chat must NOT be selected when no DM with that exact chatIdentifier exists.
      +
      +        Otherwise an outbound DM reply leaks into the group thread.
      +        """
      +        adapter = _make_adapter(monkeypatch)
      +
      +        async def fake_api_post(path, payload):
      +            return {
      +                "data": [
      +                    {
      +                        "guid": "iMessage;+;chat0000000000-family-group",
      +                        "chatIdentifier": "chat0000000000",
      +                        "participants": [
      +                            {"address": "[redacted:email]"},
      +                            {"address": "+15555550100"},
      +                        ],
      +                    }
      +                ]
      +            }
      +
      +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
      +        result = await adapter._resolve_chat_guid("[redacted:email]")
      +        assert result is None, (
      +            "participant-only match must not resolve to a group GUID — DM "
      +            "replies would leak into the group thread"
      +        )
      +
      +    @pytest.mark.asyncio
      +    async def test_dm_chosen_over_group_when_both_contain_contact(self, monkeypatch):
      +        """Even when a group chat is returned BEFORE a DM in the query result,
      +        the resolver must lock onto the DM by chatIdentifier and not the
      +        group via participant fallback.
      +        """
      +        adapter = _make_adapter(monkeypatch)
      +
      +        async def fake_api_post(path, payload):
      +            return {
      +                "data": [
      +                    {
      +                        "guid": "iMessage;+;chat0000000000-family-group",
      +                        "chatIdentifier": "chat0000000000",
      +                        "participants": [{"address": "[redacted:email]"}],
      +                    },
      +                    {
      +                        "guid": "iMessage;-;[redacted:email]",
      +                        "chatIdentifier": "[redacted:email]",
      +                        "participants": [{"address": "[redacted:email]"}],
      +                    },
      +                ]
      +            }
      +
      +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
      +        result = await adapter._resolve_chat_guid("[redacted:email]")
      +        assert result == "iMessage;-;[redacted:email]"
      +
      +    @pytest.mark.asyncio
      +    async def test_unresolved_target_is_not_cached(self, monkeypatch):
      +        """When no exact match is found, the resolver must NOT cache anything.
      +
      +        Otherwise a later attempt — after the DM has been created — would
      +        keep returning the stale ``None`` from cache. Also guards against a
      +        latent variant of #24157 where a group GUID could be cached under a
      +        bare address key and persist across calls.
      +        """
      +        adapter = _make_adapter(monkeypatch)
      +
      +        async def fake_api_post(path, payload):
      +            return {
      +                "data": [
      +                    {
      +                        "guid": "iMessage;+;chat0000000000-family-group",
      +                        "chatIdentifier": "chat0000000000",
      +                        "participants": [{"address": "[redacted:email]"}],
      +                    }
      +                ]
      +            }
      +
      +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
      +        await adapter._resolve_chat_guid("[redacted:email]")
      +        assert "[redacted:email]" not in adapter._guid_cache
      +
       
       class TestBlueBubblesAttachmentDownload:
           """Verify _download_attachment routes to the correct cache helper."""

3 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1 · superseded by another PR 1

  • #8295 fix(bluebubbles): extract chatGuid from chats array in webhook payload · done elsewhere → 8b52356
  • #8586 fix(bluebubbles): recover chat identity when chatGuid is missing · superseded by another PR → #8295
  • #24229 fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid · merged via another change → 852c9b3, c279706, #56132
Closed issues · 2
  • Closed#8514[Bug]: BlueBubbles webhook payload chatGuid is null — DM replies go to wrong chat
  • Closed#24157[Bug]: BlueBubbles outbound chat resolution can misroute DM replies to group chats. Privacy leak!
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#56132
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.