Feishu group chat WebSocket event delivery fixes
NousResearch/hermes-agent · cluster 6889 · gateway
Data as of
Nothing open on GitHub. Fix merged in #7356. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #7356. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #61770. That is separate from the fix event. - Still open
- 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.
Open issues0
Open PRs0
Fixes on record
-
#7356 fix(gateway): reject file paths in get_command() + file-drop tests
files:
gateway/platforms/base.py,tests/test_cli_file_drop.py -
#7423 fix(feishu) + feat(matrix): group chat events + DM mention threads
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.mdShow the diff
-
gateway/config.py@@ -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@@ -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@@ -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 @@ -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) @@ -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@@ -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 # --------------------------------------------------------------------------- @@ -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@@ -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@@ -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. | @@ -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: @@ -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
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.pyShow the diff
-
plugins/platforms/feishu/adapter.py@@ -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@@ -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@@ -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@@ -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", @@ -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@@ -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@@ -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" }, @@ -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" }, @@ -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]]
-
3 PRs closed without merging
From the close comments: merged via another change 3
-
#6975 fix(feishu): register group chat member event handlers · merged via another change →
#7423,#7356 -
#7383 fix(feishu): register group chat member event handlers · merged via another change →
d8cd797,#6975 -
#52908 fix(feishu): add extra_ua_tags=["channel"] to FeishuWSClient for group @mention delivery · merged via another change →
#61770
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab