fix(channels): dispatch Feishu group commands prefixed with a bot @mention (#4229)

This commit is contained in:
Aari
2026-07-17 11:18:20 +08:00
committed by GitHub
parent 693507870c
commit bc6f1adc71
4 changed files with 100 additions and 2 deletions
+22
View File
@@ -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.
+9 -2
View File
@@ -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
+19
View File
@@ -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)
+50
View File
@@ -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"