fix(feishu): strip leading bot mention before commands

This commit is contained in:
Xubin Ren 2026-06-04 10:51:41 +08:00
parent fa423dffbc
commit 894811db8b
2 changed files with 108 additions and 14 deletions

View File

@ -483,7 +483,10 @@ class FeishuChannel(BaseChannel):
for mention in mentions: for mention in mentions:
key = mention.key or None key = mention.key or None
if not key or key not in text: if not key:
continue
pattern = rf"{re.escape(key)}(?=\s|$)"
if not re.search(pattern, text):
continue continue
user_id_obj = mention.id or None user_id_obj = mention.id or None
@ -502,7 +505,40 @@ class FeishuChannel(BaseChannel):
else: else:
replacement = f"@{name}" replacement = f"@{name}"
text = text.replace(key, replacement) text = re.sub(pattern, replacement, text)
return text
def _is_bot_mention_event(self, mention: Any) -> bool:
mid = getattr(mention, "id", None)
if not mid:
return False
mention_open_id = getattr(mid, "open_id", None) or ""
bot_open_id = getattr(self, "_bot_open_id", None) or ""
if bot_open_id:
return mention_open_id == bot_open_id
# Fallback heuristic when bot open_id is unavailable.
return not getattr(mid, "user_id", None) and mention_open_id.startswith("ou_")
def _strip_leading_bot_mention(
self, text: str, mentions: list[MentionEvent] | None
) -> str:
"""Remove a required leading bot mention before slash command routing."""
if not mentions or not text:
return text
candidate = text.lstrip()
for mention in mentions:
key = getattr(mention, "key", None) or ""
if not key or not re.match(rf"{re.escape(key)}(?=\s|$)", candidate):
continue
if not self._is_bot_mention_event(mention):
continue
stripped = candidate[len(key) :].strip()
return stripped or text
return text return text
@ -513,17 +549,8 @@ class FeishuChannel(BaseChannel):
return True return True
for mention in getattr(message, "mentions", None) or []: for mention in getattr(message, "mentions", None) or []:
mid = getattr(mention, "id", None) if self._is_bot_mention_event(mention):
if not mid: return True
continue
mention_open_id = getattr(mid, "open_id", None) or ""
if self._bot_open_id:
if mention_open_id == self._bot_open_id:
return True
else:
# Fallback heuristic when bot open_id is unavailable
if not getattr(mid, "user_id", None) and mention_open_id.startswith("ou_"):
return True
return False return False
def _is_group_message_for_bot(self, message: Any) -> bool: def _is_group_message_for_bot(self, message: Any) -> bool:
@ -1747,6 +1774,7 @@ class FeishuChannel(BaseChannel):
text = content_json.get("text", "") text = content_json.get("text", "")
if text: if text:
mentions = getattr(message, "mentions", None) mentions = getattr(message, "mentions", None)
text = self._strip_leading_bot_mention(text, mentions)
text = self._resolve_mentions(text, mentions) text = self._resolve_mentions(text, mentions)
content_parts.append(text) content_parts.append(text)

View File

@ -56,6 +56,7 @@ def _make_feishu_event(
sender_open_id: str = "ou_alice", sender_open_id: str = "ou_alice",
parent_id: str | None = None, parent_id: str | None = None,
root_id: str | None = None, root_id: str | None = None,
mentions=None,
): ):
message = SimpleNamespace( message = SimpleNamespace(
message_id=message_id, message_id=message_id,
@ -65,7 +66,7 @@ def _make_feishu_event(
content=content, content=content,
parent_id=parent_id, parent_id=parent_id,
root_id=root_id, root_id=root_id,
mentions=[], mentions=mentions or [],
) )
sender = SimpleNamespace( sender = SimpleNamespace(
sender_type="user", sender_type="user",
@ -547,6 +548,71 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
assert len(captured) == 1 assert len(captured) == 1
@pytest.mark.asyncio
async def test_on_message_strips_required_leading_bot_mention_for_commands() -> None:
channel = _make_feishu_channel(group_policy="mention")
channel._processed_message_ids.clear()
channel._bot_open_id = "ou_bot"
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
mention = SimpleNamespace(
key="@_user_1",
name="nanobot",
id=SimpleNamespace(open_id="ou_bot", user_id=None),
)
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(
_make_feishu_event(
chat_type="group",
content=json.dumps({"text": "@_user_1 /new"}),
mentions=[mention],
)
)
assert len(captured) == 1
assert captured[0]["content"] == "/new"
@pytest.mark.asyncio
async def test_on_message_keeps_longer_mention_key_that_shares_bot_prefix() -> None:
channel = _make_feishu_channel(group_policy="mention")
channel._processed_message_ids.clear()
channel._bot_open_id = "ou_bot"
captured = []
async def _capture(**kwargs):
captured.append(kwargs)
channel._handle_message = _capture
bot_mention = SimpleNamespace(
key="@_user_1",
name="nanobot",
id=SimpleNamespace(open_id="ou_bot", user_id=None),
)
user_mention = SimpleNamespace(
key="@_user_10",
name="Alice",
id=SimpleNamespace(open_id="ou_alice", user_id=None),
)
with patch.object(channel, "_add_reaction", return_value=None):
await channel._on_message(
_make_feishu_event(
chat_type="group",
content=json.dumps({"text": "@_user_10 /new @_user_1"}),
mentions=[bot_mention, user_mention],
)
)
assert len(captured) == 1
assert captured[0]["content"] == "@Alice (ou_alice) /new @nanobot (ou_bot)"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Inbound media tests # Inbound media tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------