{"repo":"NousResearch/hermes-agent","public_id":"8514","display_label":"BlueBubbles chat routing and webhook parsing bugs","summary":"Multiple related bugs and fixes around the BlueBubbles gateway: incorrect chat GUID resolution causing DMs to misroute to group chats, webhook payloads with null or nested chatGuid fields, duplicate message processing, and group chat session separation issues.","subsystem":"gateway","counts":{"members":10,"open_issues":1,"open_issues_distinct":1,"open_prs":3,"closed_issues":2,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":56132},"resolution":{"state":"partially_resolved","by":"pr-56132"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T07:42:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17155555555555554},"fix_edges":[{"issue_number":24157,"pr_number":56132,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8514,"title":"[Bug]: BlueBubbles webhook payload chatGuid is null — DM replies go to wrong chat","url":null,"state":"closed"},{"kind":"issue","number":24157,"title":"[Bug]: BlueBubbles outbound chat resolution can misroute DM replies to group chats. Privacy leak!","url":null,"state":"closed"},{"kind":"issue","number":38339,"title":"BlueBubbles group webhooks can lose chat identity when only chats[0][\"[auth-key]\"] is populated","url":null,"state":"open"},{"kind":"pr","number":8295,"title":"fix(bluebubbles): extract chatGuid from chats array in webhook payload","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8b5235684","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":8586,"title":"fix(bluebubbles): recover chat identity when chatGuid is missing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#8295","number":8295}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":24229,"title":"fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"852c9b3cb","number":0},{"kind":"commit","value":"c279706d3","number":0},{"kind":"pr","value":"#56132","number":56132}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":38342,"title":"fix(bluebubbles): parse nested auth-key group webhooks","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#38379","number":38379},{"kind":"pr","value":"#30708","number":30708},{"kind":"pr","value":"#38342","number":38342},{"kind":"pr","value":"#30996","number":30996},{"kind":"pr","value":"#38379","number":38379},{"kind":"pr","value":"#38379","number":38379}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":38379,"title":"fix(bluebubbles): ignore update echoes before dedup","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":45717,"title":"fix(bluebubbles): prevent duplicate processing and DM-to-group misrouting","url":null,"state":"open","close_reason":{"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":56132,"title":"fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/bluebubbles.py","tests/gateway/test_bluebubbles.py"],"fix_hunks":[{"file":"gateway/platforms/bluebubbles.py","hunks":["@@ -433,8 +433,15 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:\n \n         If *target* already contains a semicolon (raw GUID format like\n         ``iMessage;-;[redacted:email]``), it is returned as-is.  Otherwise\n-        the adapter queries the BlueBubbles chat list and matches on\n-        ``chatIdentifier`` or participant address.\n+        the adapter queries the BlueBubbles chat list and matches strictly\n+        on ``chatIdentifier`` / ``identifier``.\n+\n+        Participant membership is intentionally NOT used as a fallback:\n+        the same contact can appear in a 1:1 DM and in any number of group\n+        chats, so a participant match would let an outbound DM reply leak\n+        into a group thread (see #24157). When no exact chat identity\n+        matches, return ``None`` and let the caller create a fresh DM\n+        explicitly via ``_create_chat_for_handle``.\n         \"\"\"\n         target = (target or \"\").strip()\n         if not target:","@@ -448,7 +455,7 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:\n         try:\n             payload = await self._api_post(\n                 \"/api/v1/chat/query\",\n-                {\"limit\": 100, \"offset\": 0, \"with\": [\"participants\"]},\n+                {\"limit\": 100, \"offset\": 0},\n             )\n             for chat in payload.get(\"data\", []) or []:\n                 guid = chat.get(\"guid\") or chat.get(\"chatGuid\")","@@ -459,12 +466,6 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:\n                         while len(self._guid_cache) > _GUID_CACHE_SIZE:\n                             self._guid_cache.popitem(last=False)\n                     return guid\n-                for part in chat.get(\"participants\", []) or []:\n-                    if (part.get(\"address\") or \"\").strip() == target and guid:\n-                        self._guid_cache[target] = guid\n-                        while len(self._guid_cache) > _GUID_CACHE_SIZE:\n-                            self._guid_cache.popitem(last=False)\n-                        return guid\n         except Exception:\n             pass\n         return None"]},{"file":"tests/gateway/test_bluebubbles.py","hunks":["@@ -426,6 +426,110 @@ def test_empty_target_returns_none(self, monkeypatch):\n         )\n         assert result is None\n \n+    @pytest.mark.asyncio\n+    async def test_exact_chat_identifier_match_returns_dm_guid(self, monkeypatch):\n+        \"\"\"A 1:1 DM whose chatIdentifier equals the target resolves to its guid.\"\"\"\n+        adapter = _make_adapter(monkeypatch)\n+\n+        async def fake_api_post(path, payload):\n+            return {\n+                \"data\": [\n+                    {\n+                        \"guid\": \"iMessage;-;[redacted:email]\",\n+                        \"chatIdentifier\": \"[redacted:email]\",\n+                        \"participants\": [{\"address\": \"[redacted:email]\"}],\n+                    }\n+                ]\n+            }\n+\n+        monkeypatch.setattr(adapter, \"_api_post\", fake_api_post)\n+        result = await adapter._resolve_chat_guid(\"[redacted:email]\")\n+        assert result == \"iMessage;-;[redacted:email]\"\n+\n+    @pytest.mark.asyncio\n+    async def test_participant_only_match_does_not_resolve_to_group(self, monkeypatch):\n+        \"\"\"Regression for #24157: contact appearing as a participant in a group\n+        chat must NOT be selected when no DM with that exact chatIdentifier exists.\n+\n+        Otherwise an outbound DM reply leaks into the group thread.\n+        \"\"\"\n+        adapter = _make_adapter(monkeypatch)\n+\n+        async def fake_api_post(path, payload):\n+            return {\n+                \"data\": [\n+                    {\n+                        \"guid\": \"iMessage;+;chat0000000000-family-group\",\n+                        \"chatIdentifier\": \"chat0000000000\",\n+                        \"participants\": [\n+                            {\"address\": \"[redacted:email]\"},\n+                            {\"address\": \"+15555550100\"},\n+                        ],\n+                    }\n+                ]\n+            }\n+\n+        monkeypatch.setattr(adapter, \"_api_post\", fake_api_post)\n+        result = await adapter._resolve_chat_guid(\"[redacted:email]\")\n+        assert result is None, (\n+            \"participant-only match must not resolve to a group GUID — DM \"\n+            \"replies would leak into the group thread\"\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_dm_chosen_over_group_when_both_contain_contact(self, monkeypatch):\n+        \"\"\"Even when a group chat is returned BEFORE a DM in the query result,\n+        the resolver must lock onto the DM by chatIdentifier and not the\n+        group via participant fallback.\n+        \"\"\"\n+        adapter = _make_adapter(monkeypatch)\n+\n+        async def fake_api_post(path, payload):\n+            return {\n+                \"data\": [\n+                    {\n+                        \"guid\": \"iMessage;+;chat0000000000-family-group\",\n+                        \"chatIdentifier\": \"chat0000000000\",\n+                        \"participants\": [{\"address\": \"[redacted:email]\"}],\n+                    },\n+                    {\n+                        \"guid\": \"iMessage;-;[redacted:email]\",\n+                        \"chatIdentifier\": \"[redacted:email]\",\n+                        \"participants\": [{\"address\": \"[redacted:email]\"}],\n+                    },\n+                ]\n+            }\n+\n+        monkeypatch.setattr(adapter, \"_api_post\", fake_api_post)\n+        result = await adapter._resolve_chat_guid(\"[redacted:email]\")\n+        assert result == \"iMessage;-;[redacted:email]\"\n+\n+    @pytest.mark.asyncio\n+    async def test_unresolved_target_is_not_cached(self, monkeypatch):\n+        \"\"\"When no exact match is found, the resolver must NOT cache anything.\n+\n+        Otherwise a later attempt — after the DM has been created — would\n+        keep returning the stale ``None`` from cache. Also guards against a\n+        latent variant of #24157 where a group GUID could be cached under a\n+        bare address key and persist across calls.\n+        \"\"\"\n+        adapter = _make_adapter(monkeypatch)\n+\n+        async def fake_api_post(path, payload):\n+            return {\n+                \"data\": [\n+                    {\n+                        \"guid\": \"iMessage;+;chat0000000000-family-group\",\n+                        \"chatIdentifier\": \"chat0000000000\",\n+                        \"participants\": [{\"address\": \"[redacted:email]\"}],\n+                    }\n+                ]\n+            }\n+\n+        monkeypatch.setattr(adapter, \"_api_post\", fake_api_post)\n+        await adapter._resolve_chat_guid(\"[redacted:email]\")\n+        assert \"[redacted:email]\" not in adapter._guid_cache\n+\n \n class TestBlueBubblesAttachmentDownload:\n     \"\"\"Verify _download_attachment routes to the correct cache helper.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}