diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index d4adbc7315..c3aae523ef 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -433,8 +433,15 @@ class BlueBubblesAdapter(BasePlatformAdapter): If *target* already contains a semicolon (raw GUID format like ``iMessage;-;user@example.com``), 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: @@ -459,12 +466,6 @@ class BlueBubblesAdapter(BasePlatformAdapter): 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 diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 7d4a71378c..11358ab2b8 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -426,6 +426,110 @@ class TestBlueBubblesGuidResolution: ) 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;-;user@example.com", + "chatIdentifier": "user@example.com", + "participants": [{"address": "user@example.com"}], + } + ] + } + + monkeypatch.setattr(adapter, "_api_post", fake_api_post) + result = await adapter._resolve_chat_guid("user@example.com") + assert result == "iMessage;-;user@example.com" + + @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": "user@example.com"}, + {"address": "+15555550100"}, + ], + } + ] + } + + monkeypatch.setattr(adapter, "_api_post", fake_api_post) + result = await adapter._resolve_chat_guid("user@example.com") + 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": "user@example.com"}], + }, + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + "participants": [{"address": "user@example.com"}], + }, + ] + } + + monkeypatch.setattr(adapter, "_api_post", fake_api_post) + result = await adapter._resolve_chat_guid("user@example.com") + assert result == "iMessage;-;user@example.com" + + @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": "user@example.com"}], + } + ] + } + + monkeypatch.setattr(adapter, "_api_post", fake_api_post) + await adapter._resolve_chat_guid("user@example.com") + assert "user@example.com" not in adapter._guid_cache + class TestBlueBubblesAttachmentDownload: """Verify _download_attachment routes to the correct cache helper."""