diff --git a/backend/app/channels/commands.py b/backend/app/channels/commands.py new file mode 100644 index 000000000..704330410 --- /dev/null +++ b/backend/app/channels/commands.py @@ -0,0 +1,20 @@ +"""Shared command definitions used by all channel implementations. + +Keeping the authoritative command set in one place ensures that channel +parsers (e.g. Feishu) and the ChannelManager dispatcher stay in sync +automatically — adding or removing a command here is the single edit +required. +""" + +from __future__ import annotations + +KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset( + { + "/bootstrap", + "/new", + "/status", + "/models", + "/memory", + "/help", + } +) diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 2001a87de..32f71f1f6 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -9,11 +9,18 @@ import threading from typing import Any from app.channels.base import Channel +from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment logger = logging.getLogger(__name__) +def _is_feishu_command(text: str) -> bool: + if not text.startswith("/"): + return False + return text.split(maxsplit=1)[0].lower() in KNOWN_CHANNEL_COMMANDS + + class FeishuChannel(Channel): """Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client. @@ -509,8 +516,9 @@ class FeishuChannel(Channel): logger.info("[Feishu] empty text, ignoring message") return - # Check if it's a command - if text.startswith("/"): + # 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): msg_type = InboundMessageType.COMMAND else: msg_type = InboundMessageType.CHAT diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 3e3fa17d4..ab63100fa 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -12,6 +12,7 @@ from typing import Any from langgraph_sdk.errors import ConflictError +from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from app.channels.store import ChannelStore @@ -735,7 +736,8 @@ class ChannelManager: "/help — Show this help" ) else: - reply = f"Unknown command: /{command}. Type /help for available commands." + available = " | ".join(sorted(KNOWN_CHANNEL_COMMANDS)) + reply = f"Unknown command: /{command}. Available commands: {available}" outbound = OutboundMessage( channel_name=msg.channel_name, diff --git a/backend/tests/test_feishu_parser.py b/backend/tests/test_feishu_parser.py index 39dc6e450..7a1fd9fc7 100644 --- a/backend/tests/test_feishu_parser.py +++ b/backend/tests/test_feishu_parser.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.feishu import FeishuChannel from app.channels.message_bus import MessageBus @@ -68,3 +69,57 @@ def test_feishu_on_message_rich_text(): assert "Paragraph 1, part 1. Paragraph 1, part 2." in parsed_text assert "@bot Paragraph 2." in parsed_text assert "\n\n" in parsed_text + + +@pytest.mark.parametrize("command", sorted(KNOWN_CHANNEL_COMMANDS)) +def test_feishu_recognizes_all_known_slash_commands(command): + """Every entry in KNOWN_CHANNEL_COMMANDS must be classified as a command.""" + 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": command}) + + 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"{command!r} should be classified as COMMAND" + + +@pytest.mark.parametrize( + "text", + [ + "/unknown", + "/mnt/user-data/outputs/prd/technical-design.md", + "/etc/passwd", + "/not-a-command at all", + ], +) +def test_feishu_treats_unknown_slash_text_as_chat(text): + """Slash-prefixed text that is not a known command must be classified as CHAT.""" + 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 == "chat", f"{text!r} should be classified as CHAT"