diff --git a/backend/app/channels/commands.py b/backend/app/channels/commands.py index 376d7f784..2293b03ba 100644 --- a/backend/app/channels/commands.py +++ b/backend/app/channels/commands.py @@ -41,6 +41,28 @@ def _is_leading_mention_token(token: str) -> bool: return False +def strip_leading_mentions(text: str) -> str: + """Drop leading platform mention tokens (``@bot``, ``<@id>``) so a group-chat + ``@bot /goal`` reads as ``/goal`` for command classification and dispatch. + + A mention must be flush at the start (no preceding whitespace), mirroring the + "a control command must be at position 0" rule in :func:`is_known_channel_command`: + text with a leading space or no leading mention is returned unchanged, so + ``" /new"`` stays a non-command. Whitespace is otherwise preserved (unlike + :func:`extract_connect_code`, which is deliberately whitespace-lenient for the + bind path). Channels that resolve their own bot id (Slack/Discord) strip only + the bot's mention upstream; this is for adapters that leave the mention in the + text and cannot tell the bot's mention from another user's (Feishu/DingTalk). + """ + remainder = text + while True: + parts = remainder.split(maxsplit=1) + if not parts or remainder[0].isspace() or not _is_leading_mention_token(parts[0]): + break + remainder = parts[1] if len(parts) > 1 else "" + return remainder + + def extract_connect_code(text: str) -> str | None: """Extract the one-time channel binding code from a connect command. diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 3c00aa9dc..1d1906388 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -11,7 +11,7 @@ import time from typing import Any, Literal 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 ( PENDING_CLARIFICATION_METADATA_KEY, @@ -1058,8 +1058,15 @@ class FeishuChannel(Channel): # Only treat known slash commands as commands; absolute paths and # other slash-prefixed text should be handled as normal chat. - if _is_feishu_command(text): + # Feishu group chats deliver "@bot /goal" 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. + command_text = strip_leading_mentions(text) + if _is_feishu_command(command_text): msg_type = InboundMessageType.COMMAND + text = command_text else: msg_type = InboundMessageType.CHAT diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 04ce43160..bbf971c10 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -37,6 +37,25 @@ def test_known_channel_command_detection_only_matches_control_commands(): assert not is_known_channel_command(" /new") +def test_strip_leading_mentions_only_drops_flush_leading_mentions(): + from app.channels.commands import is_known_channel_command, strip_leading_mentions + + assert strip_leading_mentions("@bot /goal") == "/goal" + assert strip_leading_mentions("@_user_1 /goal ship") == "/goal ship" + assert strip_leading_mentions("<@U1> /status") == "/status" + assert strip_leading_mentions("@bot @_user_2 /help") == "/help" + assert strip_leading_mentions("@bot") == "" + assert strip_leading_mentions("") == "" + # No leading mention -> unchanged, including the leading-space non-command case. + assert strip_leading_mentions("/goal") == "/goal" + assert strip_leading_mentions(" /new") == " /new" + assert strip_leading_mentions("hello /goal") == "hello /goal" + # The shared classifier is deliberately NOT changed to strip mentions: Slack + # relies on it keeping a leading non-bot mention as chat (see Slack tests), so + # mention handling lives in the adapters, not here. + assert not is_known_channel_command("@bot /goal") + + def _make_channel_skill(tmp_path: Path, name: str, *, enabled: bool = True) -> Skill: skill_dir = tmp_path / name skill_dir.mkdir(parents=True, exist_ok=True) diff --git a/backend/tests/test_feishu_parser.py b/backend/tests/test_feishu_parser.py index e409055ba..36d45926b 100644 --- a/backend/tests/test_feishu_parser.py +++ b/backend/tests/test_feishu_parser.py @@ -679,3 +679,53 @@ def test_feishu_treats_unknown_slash_text_as_chat(text): mock_make_inbound.assert_called_once() assert mock_make_inbound.call_args[1]["msg_type"].value == "chat", f"{text!r} should be classified as CHAT" + + +@pytest.mark.parametrize("text", ["@_user_1 /goal ship it", "@bot /status", "@_user_1 @_user_2 /new"]) +def test_feishu_leading_mention_before_command_classifies_and_strips(text): + """Feishu group chats deliver "@bot /goal" with the mention left in the text + (im.message.group_at_msg requires @bot). It must classify as COMMAND and the + inbound must carry the bare command so ChannelManager parses it.""" + bus = MessageBus() + config = {"app_id": "test", "app_secret": "test"} + channel = FeishuChannel(bus, config) + + event = MagicMock() + event.event.message.chat_id = "chat_1" + event.event.message.message_id = "msg_1" + event.event.message.root_id = None + event.event.sender.sender_id.open_id = "user_1" + event.event.message.content = json.dumps({"text": text}) + + with pytest.MonkeyPatch.context() as m: + mock_make_inbound = MagicMock() + m.setattr(channel, "_make_inbound", mock_make_inbound) + channel._on_message(event) + + mock_make_inbound.assert_called_once() + assert mock_make_inbound.call_args[1]["msg_type"].value == "command", f"{text!r} should be classified as COMMAND" + assert not mock_make_inbound.call_args[1]["text"].startswith("@"), "leading mention must be stripped for dispatch" + + +def test_feishu_leading_mention_before_chat_keeps_mention(): + """A mentioned non-command stays CHAT and keeps the mention so the agent still + sees who was addressed (only the command path is stripped).""" + bus = MessageBus() + config = {"app_id": "test", "app_secret": "test"} + channel = FeishuChannel(bus, config) + + event = MagicMock() + event.event.message.chat_id = "chat_1" + event.event.message.message_id = "msg_1" + event.event.message.root_id = None + event.event.sender.sender_id.open_id = "user_1" + event.event.message.content = json.dumps({"text": "@_user_1 please summarise this"}) + + with pytest.MonkeyPatch.context() as m: + mock_make_inbound = MagicMock() + m.setattr(channel, "_make_inbound", mock_make_inbound) + channel._on_message(event) + + mock_make_inbound.assert_called_once() + assert mock_make_inbound.call_args[1]["msg_type"].value == "chat" + assert mock_make_inbound.call_args[1]["text"] == "@_user_1 please summarise this"