From 041d9e375ee1d68b2de2b6a2509d3bd93d924761 Mon Sep 17 00:00:00 2001 From: mosifu Date: Thu, 12 Nov 2026 08:01:39 +0800 Subject: [PATCH] fix(wecom): treat "@bot /new" in group chats as a command (#8118) WeCom leaves the bot mention in group message text, so `_publish_ws_inbound` handed "@RobotA /new" to `is_known_channel_command`, which requires a bare leading slash. The turn was classified as chat and the command never ran. Strip leading mentions for the command lookup only, mirroring Feishu (#4229) and DingTalk (#4724): ordinary chat keeps its mentions, and the stripped form is what reaches ChannelManager so it parses `/new`. The comment spells out that this drops any leading mention, not only the bot's -- WeCom text mentions carry display names, so `aibotid` cannot be matched against them. Recorded in CHANGELOG.md / CHANGELOG_zh.md under Unreleased -> Fixed. Fixes #8099 Co-authored-by: Willem Jiang --- CHANGELOG.md | 8 +++ CHANGELOG_zh.md | 5 ++ backend/app/channels/wecom.py | 19 ++++- backend/tests/test_wecom_ws_text.py | 104 +++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 901340c19a..05760f74ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,13 @@ This section accumulates work toward the **2.3.0** milestone ### Fixed +- **channels:** A WeCom group command that follows the bot mention now runs as a + command. WeCom keeps the mention inside the message text (`@RobotA /new`), and + the classifier required a bare leading `/`, so `/new` and `/status` sent in a + group became chat turns. Leading mentions are skipped for the command lookup + only; ordinary chat keeps them and `/connect` is unchanged. Mirrors the Feishu + and DingTalk handling. ([#8118]) + - **frontend:** **Share** in a sidebar chat's menu now copies a link to the deployment you are using. On `localhost` and `127.0.0.1`, the default `make dev` and Docker addresses, it copied a link to the hosted demo @@ -16309,5 +16316,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#8069]: https://github.com/bytedance/deer-flow/issues/8069 [#8071]: https://github.com/bytedance/deer-flow/issues/8071 [#8072]: https://github.com/bytedance/deer-flow/pull/8072 +[#8118]: https://github.com/bytedance/deer-flow/pull/8118 [#8126]: https://github.com/bytedance/deer-flow/pull/8126 [#8131]: https://github.com/bytedance/deer-flow/pull/8131 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index e2cbf0ca80..ea21e9c16b 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -26,6 +26,10 @@ ### 修复 +- **通道:** WeCom 群聊中 `@机器人 /new` 这类带提及的命令现在按命令执行。WeCom 会把提及保留在 + 正文里,而命令识别要求裸的前导 `/`,于是群里的 `/new`、`/status` 被当成聊天轮次。现在仅在命令 + 识别时跳过前导提及;普通聊天保留提及,`/connect` 行为不变。与飞书、钉钉的处理一致。([#8118]) + - **前端:** 侧边栏对话菜单中的 **分享** 现在复制指向当前部署的链接。此前在 `localhost` 和 `127.0.0.1`(`make dev` 与 Docker 的默认地址)上,复制的是托管演示站 (`deer-flow-v2.vercel.app`)的链接:该链接无法打开对话,打开时还会把线程 ID 发送到外部主机。 @@ -12866,4 +12870,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#8043]: https://github.com/bytedance/deer-flow/pull/8043 [#8046]: https://github.com/bytedance/deer-flow/pull/8046 [#8064]: https://github.com/bytedance/deer-flow/pull/8064 +[#8118]: https://github.com/bytedance/deer-flow/pull/8118 [#8126]: https://github.com/bytedance/deer-flow/pull/8126 diff --git a/backend/app/channels/wecom.py b/backend/app/channels/wecom.py index 1002e7a759..a20bc0f4e8 100644 --- a/backend/app/channels/wecom.py +++ b/backend/app/channels/wecom.py @@ -9,7 +9,7 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any, cast from app.channels.base import Channel, ChannelUnavailable, attachment_delivery_text -from app.channels.commands import is_known_channel_command +from app.channels.commands import is_known_channel_command, strip_leading_mentions from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import ( InboundMessage, @@ -583,7 +583,22 @@ class WeComChannel(Channel): if handled: return - inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT + # WeCom group chats deliver "@bot /new" with the mention left in the text + # (Slack/Discord strip their own bot mention upstream). Skip a leading + # mention only for the command path so ordinary chat keeps any @mentions + # intact for the agent; the stripped form also flows into the inbound so + # ChannelManager._handle_command parses the bare command. Mirrors + # DingTalkChannel / FeishuChannel. + # + # This drops *any* leading mention, not only the bot's: WeCom text + # mentions carry display names, so the frame's `aibotid` cannot be + # matched against them and "@alice /status" is indistinguishable from + # "@bot /status". Feishu and DingTalk accept the same tradeoff; + # test_member_mention_before_a_command_also_classifies pins it. + command_text = strip_leading_mentions(text) + inbound_type = InboundMessageType.COMMAND if is_known_channel_command(command_text) else InboundMessageType.CHAT + if inbound_type is InboundMessageType.COMMAND: + text = command_text inbound_metadata: dict[str, Any] = { "aibotid": body.get("aibotid"), "chattype": body.get("chattype"), diff --git a/backend/tests/test_wecom_ws_text.py b/backend/tests/test_wecom_ws_text.py index ca8d4c0897..9c750dc2cd 100644 --- a/backend/tests/test_wecom_ws_text.py +++ b/backend/tests/test_wecom_ws_text.py @@ -10,10 +10,15 @@ with a null value, so chaining ``.get``/``.strip`` on it raised from __future__ import annotations import asyncio +import sys +import types +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock -from app.channels.message_bus import MessageBus +import pytest + +from app.channels.message_bus import InboundMessageType, MessageBus from app.channels.wecom import WeComChannel @@ -70,3 +75,100 @@ class TestOnWsTextQuoteParsing: } _run(ch._on_ws_text(frame)) ch._publish_ws_inbound.assert_called_once_with(frame, "T\nQuote message: Q") + + +class TestGroupMentionBeforeCommand: + """WeCom keeps the bot mention in group text, so "@bot /new" has to be + classified as a command and reach the dispatcher without the mention. + + Feishu (#4229) and DingTalk (#4724) already strip a leading mention on the + command path; ordinary chat must keep its mentions so the agent still sees + who was addressed. + """ + + @staticmethod + def _stub_aibot(monkeypatch: pytest.MonkeyPatch) -> None: + """Make ``_publish_ws_inbound`` run without the optional WeCom SDK.""" + sdk = types.ModuleType("aibot") + sdk.generate_req_id = lambda prefix="": f"{prefix}-test" + monkeypatch.setitem(sys.modules, "aibot", sdk) + + @staticmethod + def _live_channel() -> tuple[WeComChannel, MessageBus]: + bus = MessageBus() + channel = WeComChannel(bus=bus, config={}) + channel._ws_client = SimpleNamespace( + reply=AsyncMock(), + reply_stream=AsyncMock(), + send_message=AsyncMock(), + ) + return channel, bus + + @staticmethod + def _group_frame(text: str) -> dict[str, Any]: + return { + "body": { + "msgid": "msg-mention", + "aibotid": "bot-1", + "chatid": "wr-team", + "chattype": "group", + "from": {"userid": "alice"}, + "msgtype": "text", + "text": {"content": text}, + } + } + + @pytest.mark.parametrize( + ("text", "expected"), + [ + ("@RobotA /new", "/new"), + ("@RobotA @_user_2 /status", "/status"), + ], + ) + def test_leading_mention_before_command_is_stripped( + self, + monkeypatch: pytest.MonkeyPatch, + text: str, + expected: str, + ) -> None: + self._stub_aibot(monkeypatch) + channel, bus = self._live_channel() + + _run(channel._on_ws_text(self._group_frame(text))) + + inbound = bus.get_inbound_nowait() + assert inbound.msg_type is InboundMessageType.COMMAND + assert inbound.text == expected + + def test_plain_chat_keeps_its_mention( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + self._stub_aibot(monkeypatch) + channel, bus = self._live_channel() + + _run(channel._on_ws_text(self._group_frame("@RobotA hello there"))) + + inbound = bus.get_inbound_nowait() + assert inbound.msg_type is InboundMessageType.CHAT + assert inbound.text == "@RobotA hello there" + + def test_member_mention_before_a_command_also_classifies( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Accepted tradeoff: a member's mention looks the same as the bot's. + + WeCom text mentions are display names, so the frame's ``aibotid`` cannot + be matched against them; ``@alice /status`` is therefore classified as + the ``/status`` command, exactly as Feishu and DingTalk behave. Pinned + here so a future change to mention matching shows up in review. + """ + self._stub_aibot(monkeypatch) + channel, bus = self._live_channel() + + _run(channel._on_ws_text(self._group_frame("@alice /status"))) + + inbound = bus.get_inbound_nowait() + assert inbound.msg_type is InboundMessageType.COMMAND + assert inbound.text == "/status"