diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index c5e085972..f8868dbaf 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -483,7 +483,10 @@ class FeishuChannel(BaseChannel): for mention in mentions: 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 user_id_obj = mention.id or None @@ -502,7 +505,40 @@ class FeishuChannel(BaseChannel): else: 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 @@ -513,17 +549,8 @@ class FeishuChannel(BaseChannel): return True for mention in getattr(message, "mentions", None) or []: - mid = getattr(mention, "id", None) - if not mid: - 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 + if self._is_bot_mention_event(mention): + return True return False def _is_group_message_for_bot(self, message: Any) -> bool: @@ -1747,6 +1774,7 @@ class FeishuChannel(BaseChannel): text = content_json.get("text", "") if text: mentions = getattr(message, "mentions", None) + text = self._strip_leading_bot_mention(text, mentions) text = self._resolve_mentions(text, mentions) content_parts.append(text) diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index f9a03b395..0a6408da9 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -56,6 +56,7 @@ def _make_feishu_event( sender_open_id: str = "ou_alice", parent_id: str | None = None, root_id: str | None = None, + mentions=None, ): message = SimpleNamespace( message_id=message_id, @@ -65,7 +66,7 @@ def _make_feishu_event( content=content, parent_id=parent_id, root_id=root_id, - mentions=[], + mentions=mentions or [], ) sender = SimpleNamespace( 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 +@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 # ---------------------------------------------------------------------------