diff --git a/backend/app/channels/commands.py b/backend/app/channels/commands.py index 2a2c5acad..376d7f784 100644 --- a/backend/app/channels/commands.py +++ b/backend/app/channels/commands.py @@ -21,14 +21,42 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset( ) +def _is_leading_mention_token(token: str) -> bool: + """Return whether *token* looks like a platform bot/user mention. + + Group chats often require ``@bot`` before the message is delivered. Slack + and Discord strip those tokens before connect parsing; Feishu / DingTalk + leave them in the text (``@_user_1``, ``@bot``, ``<@id>``). Treat them as + transport noise only when they lead the message so + ``@bot /connect `` still binds. + """ + if not token: + return False + # Slack / Discord style: <@U123> or <@!U123> or <@U123|name> + if token.startswith("<@") and token.endswith(">"): + return True + # Feishu / DingTalk / generic: @_user_1, @bot, @nickname + if token.startswith("@") and len(token) > 1: + return True + return False + + def extract_connect_code(text: str) -> str | None: - """Extract the one-time channel binding code from a connect command.""" + """Extract the one-time channel binding code from a connect command. + + Accepts a leading platform mention so group ``@bot /connect `` + messages bind the same way as bare ``/connect `` (Slack/Discord + already strip mentions before calling this helper). + """ parts = text.strip().split() - if len(parts) < 2: + index = 0 + while index < len(parts) and _is_leading_mention_token(parts[index]): + index += 1 + if index + 1 >= len(parts): return None - command = parts[0].lower() + command = parts[index].lower() if command in {"/connect", "connect"}: - return parts[1] + return parts[index + 1] return None diff --git a/backend/tests/test_additional_channel_connections.py b/backend/tests/test_additional_channel_connections.py index e7da77d8c..546860f56 100644 --- a/backend/tests/test_additional_channel_connections.py +++ b/backend/tests/test_additional_channel_connections.py @@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock from app.channels.base import Channel +from app.channels.commands import extract_connect_code from app.channels.message_bus import InboundMessage, MessageBus, OutboundMessage @@ -29,11 +30,42 @@ def test_pending_connect_code_extracts_code_when_connections_configured(): assert channel._pending_connect_code("hello world") is None +def test_pending_connect_code_accepts_leading_platform_mentions(): + """Group chats prefix @bot; Feishu/DingTalk leave that noise in the text. + + Slack/Discord strip mentions before this helper; the shared parser must + still accept the unstripped form so @bot /connect binds. + """ + assert extract_connect_code("@_user_1 /connect abc123") == "abc123" + assert extract_connect_code("@bot /connect code-xyz") == "code-xyz" + assert extract_connect_code("@bot @_user_2 /connect multi") == "multi" + # All three Slack/Discord mention forms the _is_leading_mention_token + # docstring enumerates: plain, ping (<@!id>), and name (<@id|name>). + assert extract_connect_code("<@U123ABC> /connect slackish") == "slackish" + assert extract_connect_code("<@!U123ABC> /connect pinged") == "pinged" + assert extract_connect_code("<@U123ABC|alice> /connect named") == "named" + # Command match is case-insensitive after the mention prefix. + assert extract_connect_code("@bot /Connect cased") == "cased" + # Mentions without a connect command stay non-binding. + assert extract_connect_code("@_user_1 hello") is None + # Mention-only / empty input yields no code (boundary guard). + assert extract_connect_code("@bot") is None + assert extract_connect_code("") is None + # Connect not at the command position after mentions does not bind. + assert extract_connect_code("please @bot /connect leaked") is None + # Mid-sentence /connect must not bind (unchanged). + assert extract_connect_code("hi /connect abc123") is None + + channel = _StubChannel(name="stub", bus=MessageBus(), config={"connection_repo": object()}) + assert channel._pending_connect_code("@_user_1 /connect via-base") == "via-base" + + def test_pending_connect_code_is_none_when_connections_disabled(): # With no connection repo, binding is not configured and connect codes are # ignored so the message falls through to normal handling. channel = _StubChannel(name="stub", bus=MessageBus(), config={}) assert channel._pending_connect_code("/connect abc123") is None + assert channel._pending_connect_code("@bot /connect abc123") is None async def _make_repo(tmp_path, name: str):