# NousResearch/hermes-agent · Feishu group chat WebSocket event delivery fixes

## Open now

- Nothing open on GitHub.

Multiple bug reports and fix attempts addressing Feishu/Lark group chat messages and @mentions not being received or routed through the WebSocket gateway. A minor unrelated gateway fix for file path rejection is also present.

## TL;DR

- 8 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 3 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #7356. Verify it addresses your case.
- **Recommended:** read PR #7356 (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-61770`. 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.23`: 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.

## Closed / duplicate issues

- #6889: \[Bug\]: Feishu group messages never reach gateway — DM works fine but @mention in group produces zero response
- #50656: \[Bug\]: Feishu/Lark group @mention messages not delivered — WebSocket never receives group events

## Prior & competing PRs

- **#7356: fix(gateway): reject file paths in get_command() + file-drop tests**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/base.py`, `tests/test_cli_file_drop.py`
- **#7423: fix(feishu) + feat(matrix): group chat events + DM mention threads**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/config.py`, `gateway/platforms/feishu.py`, `gateway/platforms/matrix.py`, `tests/gateway/test_matrix_mention.py`, `website/docs/reference/environment-variables.md`, `website/docs/user-guide/messaging/matrix.md`
  - fix hunks:
  - `gateway/config.py`

    ```diff
    @@ -642,6 +642,8 @@ def load_gateway_config() -> GatewayConfig:
                         os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
                     if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
                         os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
    +                if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
    +                    os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
     
         except Exception as e:
             logger.warning(
    ```
  - `gateway/platforms/feishu.py`

    ```diff
    @@ -1190,6 +1190,8 @@ def _build_event_handler(self) -> Any:
                     lambda data: self._on_reaction_event("im.message.reaction.deleted_v1", data)
                 )
                 .register_p2_card_action_trigger(self._on_card_action_trigger)
    +            .register_p2_im_chat_member_bot_added_v1(self._on_bot_added_to_chat)
    +            .register_p2_im_chat_member_bot_deleted_v1(self._on_bot_removed_from_chat)
                 .build()
             )
     
    ```
  - `gateway/platforms/matrix.py`

    ```diff
    @@ -18,6 +18,7 @@
         MATRIX_REQUIRE_MENTION      Require @mention in rooms (default: true)
         MATRIX_FREE_RESPONSE_ROOMS  Comma-separated room IDs exempt from mention requirement
         MATRIX_AUTO_THREAD          Auto-create threads for room messages (default: true)
    +    MATRIX_DM_MENTION_THREADS   Create a thread when bot is @mentioned in a DM (default: false)
     """
     
     from __future__ import annotations
    ```

    ```diff
    @@ -1043,6 +1044,13 @@ async def _on_room_message(self, room: Any, event: Any) -> None:
                     if not self._is_bot_mentioned(body, formatted_body):
                         return
     
    +        # DM mention-thread: when enabled, @mentioning bot in a DM creates a thread.
    +        if is_dm and not thread_id:
    +            dm_mention_threads = os.getenv("MATRIX_DM_MENTION_THREADS", "false").lower() in ("true", "1", "yes")
    +            if dm_mention_threads and self._is_bot_mentioned(body, source_content.get("formatted_body")):
    +                thread_id = event.event_id
    +                self._track_thread(thread_id)
    +
             # Strip mention from body when present (including in DMs).
             if self._is_bot_mentioned(body, source_content.get("formatted_body")):
                 body = self._strip_mention(body)
    ```

    ```diff
    @@ -1360,6 +1368,13 @@ def _safe_isinstance(obj, cls_name):
                     if not self._is_bot_mentioned(body, formatted_body):
                         return
     
    +        # DM mention-thread: when enabled, @mentioning bot in a DM creates a thread.
    +        if is_dm and not thread_id:
    +            dm_mention_threads = os.getenv("MATRIX_DM_MENTION_THREADS", "false").lower() in ("true", "1", "yes")
    +            if dm_mention_threads and self._is_bot_mentioned(body, source_content.get("formatted_body")):
    +                thread_id = event.event_id
    +                self._track_thread(thread_id)
    +
             # Strip mention from body when present (including in DMs).
             if self._is_bot_mentioned(body, source_content.get("formatted_body")):
                 body = self._strip_mention(body)
    ```
  - `tests/gateway/test_matrix_mention.py`

    ```diff
    @@ -436,6 +436,95 @@ def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
             assert len(data) == 5
     
     
    +# ---------------------------------------------------------------------------
    +# DM mention-thread feature
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email]
    +async def test_dm_mention_thread_disabled_by_default(monkeypatch):
    +    """Default (dm_mention_threads=false): DM with mention should NOT create a thread."""
    +    monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("@hermes:example.org help me", event_id="$dm1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id is None
    +
    +
    [redacted:email]
    +async def test_dm_mention_thread_creates_thread(monkeypatch):
    +    """MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread."""
    +    monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("@hermes:example.org help me", event_id="$dm1")
    +
    +    with patch.object(adapter, "_save_participated_threads"):
    +        await adapter._on_room_message(room, event)
    +
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id == "$dm1"
    +    assert msg.text == "help me"
    +
    +
    [redacted:email]
    +async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
    +    """MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread."""
    +    monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("hello without mention", event_id="$dm1")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id is None
    +
    +
    [redacted:email]
    +async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
    +    """MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id."""
    +    monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    adapter._bot_participated_threads.add("$existing_thread")
    +    room = _make_room(member_count=2)
    +    event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
    +
    +    await adapter._on_room_message(room, event)
    +    adapter.handle_message.assert_awaited_once()
    +    msg = adapter.handle_message.await_args.args[0]
    +    assert msg.source.thread_id == "$existing_thread"
    +
    +
    [redacted:email]
    +async def test_dm_mention_thread_tracks_participation(monkeypatch):
    +    """DM mention-thread tracks the thread in _bot_participated_threads."""
    +    monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
    +    monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
    +
    +    adapter = _make_adapter()
    +    room = _make_room(member_count=2)
    +    event = _make_event("@hermes:example.org help", event_id="$dm1")
    +
    +    with patch.object(adapter, "_save_participated_threads"):
    +        await adapter._on_room_message(room, event)
    +
    +    assert "$dm1" in adapter._bot_participated_threads
    +
    +
     # ---------------------------------------------------------------------------
     # YAML config bridge
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -480,6 +569,25 @@ def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):
             assert os.getenv("MATRIX_FREE_RESPONSE_ROOMS") == "!room1:example.org,!room2:example.org"
             assert os.getenv("MATRIX_AUTO_THREAD") == "false"
     
    +    def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
    +        """Matrix YAML dm_mention_threads should bridge to env var."""
    +        monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
    +
    +        import os
    +        import yaml
    +
    +        yaml_content = {"matrix": {"dm_mention_threads": True}}
    +        config_file = tmp_path / "config.yaml"
    +        config_file.write_text(yaml.dump(yaml_content))
    +
    +        yaml_cfg = yaml.safe_load(config_file.read_text())
    +        matrix_cfg = yaml_cfg.get("matrix", {})
    +        if isinstance(matrix_cfg, dict):
    +            if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
    +                monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", str(matrix_cfg["dm_mention_threads"]).lower())
    +
    +        assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
    +
         def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
             """Env vars should not be overwritten by YAML values."""
             monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
    ```
  - `website/docs/reference/environment-variables.md`

    ```diff
    @@ -262,6 +262,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
     | `MATRIX_REQUIRE_MENTION` | Require `@mention` in rooms (default: `true`). Set to `false` to respond to all messages. |
     | `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` |
     | `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) |
    +| `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) |
     | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
     | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
     | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) |
    ```
  - `website/docs/user-guide/messaging/matrix.md`

    ```diff
    @@ -16,7 +16,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
     
     | Context | Behavior |
     |---------|----------|
    -| **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. |
    +| **DMs** | Hermes responds to every message. No `@mention` needed. Each DM has its own session. Set `MATRIX_DM_MENTION_THREADS=true` to start a thread when the bot is `@mentioned` in a DM. |
     | **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. |
     | **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |
     | **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. |
    ```

    ```diff
    @@ -62,6 +62,7 @@ matrix:
       free_response_rooms:            # Rooms exempt from mention requirement
         - "!abc123:matrix.org"
       auto_thread: true               # Auto-create threads for responses (default: true)
    +  dm_mention_threads: false       # Create thread when @mentioned in DM (default: false)
     ```
     
     Or via environment variables:
    ```

    ```diff
    @@ -70,6 +71,7 @@ Or via environment variables:
     MATRIX_REQUIRE_MENTION=true
     MATRIX_FREE_RESPONSE_ROOMS=!abc123:matrix.org,!def456:matrix.org
     MATRIX_AUTO_THREAD=true
    +MATRIX_DM_MENTION_THREADS=false
     ```
     
     :::note
    ```
- **#61770: fix(feishu): restore group events over WebSocket**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `infographic/feishu-group-events/infographic.png`, `plugins/platforms/feishu/adapter.py`, `pyproject.toml`, `scripts/release.py`, `tests/gateway/test_feishu.py`, `tools/lazy_deps.py`
  - fix hunks:
  - `plugins/platforms/feishu/adapter.py`

    ```diff
    @@ -4730,6 +4730,12 @@ async def _connect_websocket(self) -> None:
                 log_level=lark.LogLevel.INFO,
                 event_handler=self._event_handler,
                 domain=domain,
    +            # Channel SDK signaling tag: without this UA tag the Feishu
    +            # server does not push group @mention events over the WebSocket
    +            # transport.  The tag tells the server to use the Channel protocol
    +            # which enables group-message routing in addition to P2P DM.
    +            # See https://github.com/NousResearch/hermes-agent/issues/50656
    +            extra_ua_tags=["channel"],
             )
             self._ws_future = loop.run_in_executor(
                 None,
    ```
  - `pyproject.toml`

    ```diff
    @@ -249,7 +249,7 @@ termux-all = [
       "hermes-agent[web]",
     ]
     dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
    -feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
    +feishu = ["lark-oapi==1.6.8", "qrcode==7.4.2"]
     google = [
       # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
       # Sheets, Docs).  Declared here so packagers (Nix, Homebrew) ship them with
    ```
  - `scripts/release.py`

    ```diff
    @@ -46,7 +46,7 @@
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
         "[redacted:email]": "lemonwan",  # PR #59430 sibling salvage (adapter reconnect contract guard)
    -    "[redacted:email]": "luxuguang-leo",  # PR #52966 salvage (QQBot reconnect contract)
    +    "[redacted:email]": "luxuguang-leo",  # PR #52966 + #52908 salvage (QQBot reconnect + Feishu Channel signaling)
         "[redacted:email]": "evelynburger",  # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)
         "[redacted:email]": "SiteupAgencia",  # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)
         "[redacted:email]": "rainbowgore",  # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)
    ```
  - `tests/gateway/test_feishu.py`

    ```diff
    @@ -173,6 +173,16 @@ def test_normalize_interactive_card_preserves_title_body_and_actions(self):
     
     
     class TestFeishuAdapterMessaging(unittest.TestCase):
    +    @unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
    +    def test_websocket_sdk_accepts_channel_ua_tag(self):
    +        """The shipped SDK must support the Channel signaling argument."""
    +        import inspect
    +
    +        from lark_oapi.ws import Client as FeishuWSClient
    +
    +        signature = inspect.signature(FeishuWSClient)
    +        self.assertIn("extra_ua_tags", signature.parameters)
    +
         @patch.dict(os.environ, {
             "FEISHU_APP_ID": "cli_app",
             "FEISHU_APP_SECRET": "secret_app",
    ```

    ```diff
    @@ -414,6 +424,65 @@ def is_closed(self):
             self.assertEqual(sleeps, [1])
             self.assertEqual(fake_loop.calls, 2)
     
    +    @patch.dict(os.environ, {
    +        "FEISHU_APP_ID": "cli_app",
    +        "FEISHU_APP_SECRET": "secret_app",
    +    }, clear=True)
    +    def test_connect_websocket_sets_channel_ua_tag(self):
    +        """Verify that FeishuWSClient receives extra_ua_tags=["channel"].
    +
    +        Without this UA tag the Feishu server does not push group @mention
    +        events over the WebSocket transport.  See
    +        https://github.com/NousResearch/hermes-agent/issues/50656
    +        """
    +        from gateway.config import PlatformConfig
    +        from plugins.platforms.feishu.adapter import FeishuAdapter
    +
    +        adapter = FeishuAdapter(PlatformConfig())
    +        ws_client = SimpleNamespace()
    +
    +        with (
    +            patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True),
    +            patch("plugins.platforms.feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True),
    +            patch("plugins.platforms.feishu.adapter.lark",
    +                  SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))),
    +            patch("plugins.platforms.feishu.adapter.EventDispatcherHandler") as mock_handler_class,
    +            patch("plugins.platforms.feishu.adapter.FeishuWSClient") as mock_ws_client,
    +            patch("plugins.platforms.feishu.adapter._run_official_feishu_ws_client"),
    +            patch("plugins.platforms.feishu.adapter.acquire_scoped_lock", return_value=(True, None)),
    +            patch("plugins.platforms.feishu.adapter.release_scoped_lock"),
    +            patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()),
    +            patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()),
    +        ):
    +            _mock_event_dispatcher_builder(mock_handler_class)
    +
    +            loop = asyncio.new_event_loop()
    +            future = loop.create_future()
    +            future.set_result(None)
    +
    +            class _Loop:
    +                def run_in_executor(self, *_args, **_kwargs):
    +                    return future
    +                def is_closed(self):
    +                    return False
    +
    +            try:
    +                with patch("plugins.platforms.feishu.adapter.asyncio.get_running_loop",
    +                           return_value=_Loop()):
    +                    connected = asyncio.run(adapter.connect())
    +            finally:
    +                loop.close()
    +
    +        self.assertTrue(connected)
    +        # Verify the Channel SDK UA tag is present — this is the fix for
    +        # group @mention message delivery over WebSocket.
    +        mock_ws_client.assert_called_once()
    +        call_kwargs = mock_ws_client.call_args.kwargs
    +        self.assertIn("extra_ua_tags", call_kwargs,
    +                      "FeishuWSClient must receive extra_ua_tags for group @mention delivery")
    +        self.assertEqual(call_kwargs["extra_ua_tags"], ["channel"],
    +                         "extra_ua_tags must be ['channel'] to enable group event routing")
    +
         @patch.dict(os.environ, {}, clear=True)
         def test_edit_message_updates_existing_feishu_message(self):
             from gateway.config import PlatformConfig
    ```
  - `tools/lazy_deps.py`

    ```diff
    @@ -188,7 +188,7 @@
             "qrcode==7.4.2",
         ),
         "platform.feishu": (
    -        "lark-oapi==1.5.3",
    +        "lark-oapi==1.6.8",
             "qrcode==7.4.2",
         ),
         # WeCom callback-mode adapter — parses untrusted XML POST bodies. Pulls
    ```
  - `uv.lock`

    ```diff
    @@ -1794,7 +1794,7 @@ requires-dist = [
         { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" },
         { name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
         { name = "jinja2", specifier = "==3.1.6" },
    -    { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" },
    +    { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" },
         { name = "markdown", specifier = "==3.10.2" },
         { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" },
         { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" },
    ```

    ```diff
    @@ -2184,7 +2184,7 @@ wheels = [
     
     [[package]]
     name = "lark-oapi"
    -version = "1.5.3"
    +version = "1.6.8"
     source = { registry = "https://pypi.org/simple" }
     dependencies = [
         { name = "httpx" },
    ```

    ```diff
    @@ -2194,7 +2194,7 @@ dependencies = [
         { name = "websockets" },
     ]
     wheels = [
    -    { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" },
    +    { url = "https://files.pythonhosted.org/packages/4a/ad/1ab04db5d549ad1a7a2cd33682b1c38dee1d65019cb24fd7a23270e6337d/lark_oapi-1.6.8-py3-none-any.whl", hash = "sha256:9b443a5d47a7d204dd42dc40896c8b75087cc35788e45c48c140806d7df7e5e8", size = 7798300, upload-time = "2026-06-02T07:40:05.492Z" },
     ]
     
     [[package]]
    ```
- **#6975: fix(feishu): register group chat member event handlers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#7423`, `#7356`
- **#7383: fix(feishu): register group chat member event handlers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `d8cd7974d86cdfaf1f2bc4684cb233470491b0c8`, `#6975`
- **#52908: fix(feishu): add extra_ua_tags=\["channel"\] to FeishuWSClient for group @mention delivery**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#61770`

## Fix status

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