BlueBubbles chat GUID resolution misrouting DMs to groups
Cluster 24157 · gateway. Data as of
1 open item. Fix merged in #56132 on 1 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue 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
resolved, deciding PR #56132. That is separate from the fix event. - Still open
- 0 issues and 1 PR.
The members focus on BlueBubbles outbound chat resolution, with a bug report of DM replies possibly being sent to group chats and several fix PRs that drop the participant-address fallback in _resolve_chat_guid. One PR also mentions duplicate processing, but the shared theme is DM-to-group misrouting.
Open issues0
Open PRs1
- PR#45717fix(bluebubbles): prevent duplicate processing and DM-to-group misrouting
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."""
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#24229 fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid · merged via another change →
852c9b3,c279706,#56132
Closed issues · 1
- Closed#24157[Bug]: BlueBubbles outbound chat resolution can misroute DM replies to group chats. Privacy leak!
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: 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
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 1 Jul 2026