diff --git a/backend/app/channels/dingtalk.py b/backend/app/channels/dingtalk.py index 9d6f5ecc86..57cad05249 100644 --- a/backend/app/channels/dingtalk.py +++ b/backend/app/channels/dingtalk.py @@ -14,7 +14,7 @@ import httpx from app.channels.base import Channel -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, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths @@ -428,8 +428,16 @@ def _on_chatbot_message(self, message: Any) -> None: len(files), ) - if _is_dingtalk_command(text): + # DingTalk group chats often 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 @mentions intact for the agent; the stripped form also flows + # into the inbound so ChannelManager._handle_command parses the bare + # command. Mirrors FeishuChannel. + command_text = strip_leading_mentions(text) + if _is_dingtalk_command(command_text): msg_type = InboundMessageType.COMMAND + text = command_text else: msg_type = InboundMessageType.CHAT diff --git a/backend/tests/test_dingtalk_channel.py b/backend/tests/test_dingtalk_channel.py index 098edbb9da..f9776830a0 100644 --- a/backend/tests/test_dingtalk_channel.py +++ b/backend/tests/test_dingtalk_channel.py @@ -304,6 +304,71 @@ async def go(): _run(go()) + @pytest.mark.parametrize("text", ["@bot /new", "@_user_1 /help", "@bot /goal ship it"]) + def test_leading_mention_before_command_classifies_and_strips(self, text): + """DingTalk group chats leave "@bot /new" in the text; classify as COMMAND + and strip the mention so ChannelManager receives the bare command.""" + + async def go(): + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = DingTalkChannel(bus, config={}) + channel._client_id = "test_key" + channel._main_loop = asyncio.get_event_loop() + channel._running = True + + msg = _make_chatbot_message( + text=text, + conversation_type=_CONVERSATION_TYPE_GROUP, + sender_staff_id="user_002", + conversation_id="conv_group_001", + message_id="msg_mention_cmd", + ) + + channel._send_running_reply = AsyncMock() + channel._on_chatbot_message(msg) + + await asyncio.sleep(0.1) + + bus.publish_inbound.assert_awaited_once() + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.msg_type == InboundMessageType.COMMAND, f"{text!r} should be COMMAND" + assert not inbound.text.startswith("@"), "leading mention must be stripped for dispatch" + assert inbound.text.split(maxsplit=1)[0] in KNOWN_CHANNEL_COMMANDS + + _run(go()) + + def test_leading_mention_before_chat_keeps_mention(self): + """A mentioned non-command stays CHAT and keeps the mention for the agent.""" + + async def go(): + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = DingTalkChannel(bus, config={}) + channel._client_id = "test_key" + channel._main_loop = asyncio.get_event_loop() + channel._running = True + + msg = _make_chatbot_message( + text="@bot please summarise this", + conversation_type=_CONVERSATION_TYPE_GROUP, + sender_staff_id="user_002", + conversation_id="conv_group_001", + message_id="msg_mention_chat", + ) + + channel._send_running_reply = AsyncMock() + channel._on_chatbot_message(msg) + + await asyncio.sleep(0.1) + + bus.publish_inbound.assert_awaited_once() + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.msg_type == InboundMessageType.CHAT + assert inbound.text == "@bot please summarise this" + + _run(go()) + def test_group_message_integer_conversation_type_normalized(self): """SDK may deliver conversationType as int 2 — must still route as group."""