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

The outbound chat resolver in BlueBubblesAdapter._resolve_chat_guid()
matched on participant addresses after the exact chatIdentifier check,
which let an outbound DM reply leak into a group thread when the same
contact existed in both a 1:1 DM and a group chat: if the group chat
was returned earlier by /api/v1/chat/query and the DM's
chatIdentifier differed from the bare address, the participant match
on the group fired first and returned the group GUID. That GUID was
then cached under the bare address, so every subsequent reply went to
the wrong chat.

Restrict resolution to:
  1. raw GUID passthrough
  2. exact chatIdentifier / identifier match

When no exact match exists the resolver now returns None and the
caller already handles that path safely: send() creates a fresh DM via
_create_chat_for_handle for address-shaped targets, and
_send_attachment fails with a clear "chat not found" error rather than
guessing into a group.

Adds regression tests under TestBlueBubblesGuidResolution covering:
  - exact chatIdentifier match still resolves to the DM
  - participant-only presence does not resolve to the group
  - the DM is chosen even when the group is returned first
  - unresolved targets are not cached (no stale-None and no stale-group)

Fixes #24157.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
briandevans
2026-05-12 00:12:40 -07:00
committed by Teknium
parent 66325a7700
commit c279706d33
2 changed files with 113 additions and 8 deletions
+9 -8
View File
@@ -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
+104
View File
@@ -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."""