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
- 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.
Next step: read #56132, then check whether your report is one of the 1 still open.
Read #56132Pick up a Lab taskMultiple 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
Fixes on record
-
#56132 fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid
files:
gateway/platforms/bluebubbles.py,tests/gateway/test_bluebubbles.pyShow 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
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.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #56132
- Closed issues
- 2
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 1 Jul 2026