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:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user