fix(channels): accept leading @mentions before /connect bind codes (#4222)

* fix(channels): accept leading @mentions before /connect bind codes

Group chats often deliver "@bot /connect <code>" (Feishu/DingTalk leave the
mention in the text). extract_connect_code required the message to start with
/connect, so those binds silently failed while Slack/Discord already strip
mentions before parsing. Skip leading mention tokens in the shared helper.

* test(channels): pin mention variants and case-insensitive /connect parsing
This commit is contained in:
Aari 2026-07-16 11:38:56 +08:00 committed by GitHub
parent 94a34f382d
commit b3a0dac8ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 64 additions and 4 deletions

View File

@ -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 <code>`` 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 <code>``
messages bind the same way as bare ``/connect <code>`` (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

View File

@ -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 <code> 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):