# NousResearch/hermes-agent · BlueBubbles chat routing and webhook parsing bugs

## Open now

- 1 issue, 3 PRs open on GitHub.
- #38339: BlueBubbles group webhooks can lose chat identity when only chats\[0\]\["\[auth-key\]"\] is populated
- PR #38342: fix(bluebubbles): parse nested auth-key group webhooks
- PR #38379: fix(bluebubbles): ignore update echoes before dedup
- PR #45717: fix(bluebubbles): prevent duplicate processing and DM-to-group misrouting

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.

## TL;DR

- 10 member(s): 1 open issue(s), 2 closed issue(s), 3 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #56132. Verify it addresses your case.
- **Recommended:** read PR #56132 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-56132`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.17`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #38339: BlueBubbles group webhooks can lose chat identity when only chats\[0\]\["\[auth-key\]"\] is populated

## Closed / duplicate issues

- #8514: \[Bug\]: BlueBubbles webhook payload chatGuid is null — DM replies go to wrong chat
- #24157: \[Bug\]: BlueBubbles outbound chat resolution can misroute DM replies to group chats. Privacy leak!

## Prior & competing PRs

- **#8295: fix(bluebubbles): extract chatGuid from chats array in webhook payload**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `8b5235684`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#8586: fix(bluebubbles): recover chat identity when chatGuid is missing**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#8295`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56132: fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/bluebubbles.py`, `tests/gateway/test_bluebubbles.py`
  - fix hunks:
  - `gateway/platforms/bluebubbles.py`

    ```diff
    @@ -433,8 +433,15 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
     
             If *target* already contains a semicolon (raw GUID format like
             ``iMessage;-;[redacted:email]``), 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:
    ```

    ```diff
    @@ -448,7 +455,7 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
             try:
                 payload = await self._api_post(
                     "/api/v1/chat/query",
    -                {"limit": 100, "offset": 0, "with": ["participants"]},
    +                {"limit": 100, "offset": 0},
                 )
                 for chat in payload.get("data", []) or []:
                     guid = chat.get("guid") or chat.get("chatGuid")
    ```

    ```diff
    @@ -459,12 +466,6 @@ async def _resolve_chat_guid(self, target: str) -> Optional[str]:
                             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
    ```
  - `tests/gateway/test_bluebubbles.py`

    ```diff
    @@ -426,6 +426,110 @@ def test_empty_target_returns_none(self, monkeypatch):
             )
             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;-;[redacted:email]",
    +                        "chatIdentifier": "[redacted:email]",
    +                        "participants": [{"address": "[redacted:email]"}],
    +                    }
    +                ]
    +            }
    +
    +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
    +        result = await adapter._resolve_chat_guid("[redacted:email]")
    +        assert result == "iMessage;-;[redacted:email]"
    +
    +    @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": "[redacted:email]"},
    +                            {"address": "+15555550100"},
    +                        ],
    +                    }
    +                ]
    +            }
    +
    +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
    +        result = await adapter._resolve_chat_guid("[redacted:email]")
    +        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": "[redacted:email]"}],
    +                    },
    +                    {
    +                        "guid": "iMessage;-;[redacted:email]",
    +                        "chatIdentifier": "[redacted:email]",
    +                        "participants": [{"address": "[redacted:email]"}],
    +                    },
    +                ]
    +            }
    +
    +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
    +        result = await adapter._resolve_chat_guid("[redacted:email]")
    +        assert result == "iMessage;-;[redacted:email]"
    +
    +    @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": "[redacted:email]"}],
    +                    }
    +                ]
    +            }
    +
    +        monkeypatch.setattr(adapter, "_api_post", fake_api_post)
    +        await adapter._resolve_chat_guid("[redacted:email]")
    +        assert "[redacted:email]" not in adapter._guid_cache
    +
     
     class TestBlueBubblesAttachmentDownload:
         """Verify _download_attachment routes to the correct cache helper."""
    ```
- **#24229: fix(bluebubbles): drop participant-address fallback in _resolve_chat_guid**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `852c9b3cb`, `c279706d3`, `#56132`
- **#38342: fix(bluebubbles): parse nested auth-key group webhooks**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#38379`, `#30708`, `#38342`, `#30996`, `#38379`, `#38379`
  - files: `gateway/platforms/bluebubbles.py`, `tests/gateway/test_bluebubbles.py`
- **#38379: fix(bluebubbles): ignore update echoes before dedup**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `gateway/platforms/bluebubbles.py`, `tests/gateway/test_bluebubbles.py`
- **#45717: fix(bluebubbles): prevent duplicate processing and DM-to-group misrouting**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`
  - files: `gateway/platforms/bluebubbles.py`, `tests/gateway/test_bluebubbles.py`

## Fix status

- Merged to main in PR #56132 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `gateway`
