From 8b523568492a68ea0c8af1108ff4bb9be7c28e45 Mon Sep 17 00:00:00 2001 From: cypres0099 Date: Tue, 14 Apr 2026 10:30:58 -0500 Subject: [PATCH] fix(gateway/bluebubbles): fall back to data.chats[0].guid when chatGuid missing BlueBubbles v1.9+ webhook payloads for new-message events do not always include a top-level chatGuid field on the message data object. Instead, the chat GUID is nested under data.chats[0].guid. The adapter currently checks five top-level fallback locations (record and payload, snake_case and camelCase, plus payload.guid) but never looks inside the chats array. When none of those top-level fields contain the GUID, the adapter falls through to using the sender's phone/email as the session chat ID. This causes two observable bugs when a user is a participant in both a DM and a group chat with the bot: 1. DM and group sessions merge. Every message from that user ends up with the same session_chat_id (their own address), so the bot cannot distinguish which thread the message came from. 2. Outbound routing becomes ambiguous. _resolve_chat_guid() iterates all chats and returns the first one where the address appears as a participant; group chats typically sort ahead of DMs by activity, so replies and cron messages intended for the DM can land in a group. This was observed in production: a user's morning brief cron delivered to a group chat with his spouse instead of his DM thread. The fix adds a single fallback that extracts chat_guid from record["chats"][0]["guid"] when the top-level fields are empty. The chats array is included in every new-message webhook payload in BB v1.9.9 (verified against a live server). It is backwards compatible: if a future BB version starts including chatGuid at the top level, that still wins. Co-Authored-By: Claude Opus 4.6 (1M context) --- gateway/platforms/bluebubbles.py | 6 ++++ tests/gateway/test_bluebubbles.py | 57 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index fc179154..909a0be6 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -835,6 +835,12 @@ class BlueBubblesAdapter(BasePlatformAdapter): payload.get("chat_guid"), payload.get("guid"), ) + # Fallback: BlueBubbles v1.9+ webhook payloads omit top-level chatGuid; + # the chat GUID is nested under data.chats[0].guid instead. + if not chat_guid: + _chats = record.get("chats") or [] + if _chats and isinstance(_chats[0], dict): + chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") chat_identifier = self._value( record.get("chatIdentifier"), record.get("identifier"), diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 86220d44..639f81ae 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -167,6 +167,63 @@ class TestBlueBubblesWebhookParsing: chat_identifier = sender assert chat_identifier == "user@example.com" + def test_webhook_extracts_chat_guid_from_chats_array_dm(self, monkeypatch): + """BB v1.9+ webhook payloads omit top-level chatGuid; GUID is in chats[0].guid.""" + adapter = _make_adapter(monkeypatch) + payload = { + "type": "new-message", + "data": { + "guid": "MESSAGE-GUID", + "text": "hello", + "handle": {"address": "+15551234567"}, + "isFromMe": False, + "chats": [ + {"guid": "any;-;+15551234567", "chatIdentifier": "+15551234567"} + ], + }, + } + record = adapter._extract_payload_record(payload) or {} + chat_guid = adapter._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + if not chat_guid: + _chats = record.get("chats") or [] + if _chats and isinstance(_chats[0], dict): + chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") + assert chat_guid == "any;-;+15551234567" + + def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch): + """Group chat GUIDs contain ;+; and must be extracted from chats array.""" + adapter = _make_adapter(monkeypatch) + payload = { + "type": "new-message", + "data": { + "guid": "MESSAGE-GUID", + "text": "hello everyone", + "handle": {"address": "+15551234567"}, + "isFromMe": False, + "isGroup": True, + "chats": [{"guid": "any;+;chat-uuid-abc123"}], + }, + } + record = adapter._extract_payload_record(payload) or {} + chat_guid = adapter._value( + record.get("chatGuid"), + payload.get("chatGuid"), + record.get("chat_guid"), + payload.get("chat_guid"), + payload.get("guid"), + ) + if not chat_guid: + _chats = record.get("chats") or [] + if _chats and isinstance(_chats[0], dict): + chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") + assert chat_guid == "any;+;chat-uuid-abc123" + def test_extract_payload_record_accepts_list_data(self, monkeypatch): adapter = _make_adapter(monkeypatch) payload = {