mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
fix(channels): dispatch Feishu group commands prefixed with a bot @mention (#4229)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user