mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
feat(feishu): operator-configurable bot admission and mention policy
Add two operator-facing toggles for inbound Feishu admission, enabling
bot-to-bot scenarios such as A2A orchestration and inter-bot
notifications:
FEISHU_ALLOW_BOTS=none|mentions|all (default: none)
Accept messages from other bots. `mentions` requires the peer
bot to @-mention Hermes; `all` admits every peer-bot message.
FEISHU_REQUIRE_MENTION=true|false (default: true)
Whether group messages must @-mention the bot. Override per-chat
via `group_rules.<chat_id>.require_mention` in config.yaml.
Defaults preserve prior behavior. Self-echo protection is always on:
when the bot's identity is unresolved (auto-detection failed and
FEISHU_BOT_OPEN_ID unset), peer-bot messages are rejected fail-closed
to avoid feedback loops.
Admitted peer bots bypass the human-user allowlist
(FEISHU_ALLOWED_USERS) to match existing Discord behavior; humans
still need an explicit allowlist entry. yaml feishu.allow_bots is
bridged to the env var so the adapter and gateway auth layer share
one source of truth.
Resolving peer-bot display names requires the
application:bot.basic_info:read scope; without it, peers still route
but appear as their open_id.
Test: tests/gateway/test_feishu_bot_admission.py covers the admission
pipeline, group-policy bot-bypass, hydration, and event-dispatch
plumbing as a parametrized matrix.
Change-Id: I363cccb578c2a5c8b8bf0f0a890c01c89909e256
This commit is contained in:
parent
fa9fd26acb
commit
b94cb8e2c4
10 changed files with 1478 additions and 182 deletions
|
|
@ -8,6 +8,7 @@ import time
|
|||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.base import ProcessingOutcome
|
||||
|
|
@ -557,6 +558,16 @@ class TestAdapterModule(unittest.TestCase):
|
|||
self.assertEqual(fake_client._ping_interval, 4)
|
||||
|
||||
|
||||
def _admits_group(adapter, message, sender_id, chat_id=""):
|
||||
"""Group-path shim: run a message through ``_admit`` and return a bool."""
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
if not hasattr(message, "chat_type"):
|
||||
message.chat_type = "group"
|
||||
if chat_id:
|
||||
message.chat_id = chat_id
|
||||
return adapter._admit(sender, message) is None
|
||||
|
||||
|
||||
class TestAdapterBehavior(unittest.TestCase):
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_event_handler_registers_reaction_and_card_processors(self):
|
||||
|
|
@ -689,6 +700,67 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_called_once()
|
||||
|
||||
def _build_reaction_adapter(self, *, msg_sender_id: str):
|
||||
"""Build a FeishuAdapter wired up to return a single GET-message result."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._app_id = "cli_self_app"
|
||||
adapter._bot_open_id = "ou_self_bot"
|
||||
adapter._bot_user_id = "u_self_bot"
|
||||
|
||||
msg = SimpleNamespace(
|
||||
sender=SimpleNamespace(sender_type="app", id=msg_sender_id, id_type="app_id"),
|
||||
chat_id="oc_chat",
|
||||
chat_type="group",
|
||||
)
|
||||
response = SimpleNamespace(success=lambda: True, data=SimpleNamespace(items=[msg]))
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(message=SimpleNamespace(get=Mock(return_value=response)))
|
||||
)
|
||||
)
|
||||
adapter._build_get_message_request = Mock(return_value=object())
|
||||
adapter._handle_message_with_guards = AsyncMock()
|
||||
adapter._resolve_sender_profile = AsyncMock(
|
||||
return_value={"user_id": "u_human", "user_name": "Human", "user_id_alt": None}
|
||||
)
|
||||
adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"})
|
||||
return adapter
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_reaction_on_peer_bot_message_is_not_routed(self):
|
||||
# GET im/v1/messages sender for bot messages carries id=app_id; a peer
|
||||
# bot's message has a different app_id than ours, so it must be dropped.
|
||||
adapter = self._build_reaction_adapter(msg_sender_id="cli_peer_app")
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_peer_msg",
|
||||
user_id=SimpleNamespace(open_id="ou_human", user_id=None, union_id=None),
|
||||
reaction_type=SimpleNamespace(emoji_type="THUMBSUP"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
asyncio.run(
|
||||
adapter._handle_reaction_event("im.message.reaction.created_v1", data)
|
||||
)
|
||||
adapter._handle_message_with_guards.assert_not_awaited()
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_reaction_on_our_own_bot_message_is_routed(self):
|
||||
adapter = self._build_reaction_adapter(msg_sender_id="cli_self_app")
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_self_msg",
|
||||
user_id=SimpleNamespace(open_id="ou_human", user_id=None, union_id=None),
|
||||
reaction_type=SimpleNamespace(emoji_type="THUMBSUP"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
asyncio.run(
|
||||
adapter._handle_reaction_event("im.message.reaction.created_v1", data)
|
||||
)
|
||||
adapter._handle_message_with_guards.assert_awaited_once()
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_requires_mentions_even_when_policy_open(self):
|
||||
from gateway.config import PlatformConfig
|
||||
|
|
@ -697,10 +769,10 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
adapter = FeishuAdapter(PlatformConfig())
|
||||
message = SimpleNamespace(mentions=[])
|
||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||
self.assertFalse(adapter._should_accept_group_message(message, sender_id, ""))
|
||||
self.assertFalse(_admits_group(adapter, message, sender_id, ""))
|
||||
|
||||
message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key="@_user_1")])
|
||||
self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id, ""))
|
||||
self.assertFalse(_admits_group(adapter, message_with_mention, sender_id, ""))
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self):
|
||||
|
|
@ -714,59 +786,10 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
|
||||
)
|
||||
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_other_bot_sender_is_not_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_other_bot", user_id="u_other_bot"),
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(event))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_self_bot_sender_is_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
by_open_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id="u_other"),
|
||||
)
|
||||
)
|
||||
by_user_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="app",
|
||||
sender_id=SimpleNamespace(open_id="ou_other", user_id="u_hermes"),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_open_id))
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_user_id))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
|
|
@ -792,14 +815,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
mentioned,
|
||||
SimpleNamespace(open_id="ou_allowed", user_id=None),
|
||||
"",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
mentioned,
|
||||
SimpleNamespace(open_id="ou_blocked", user_id=None),
|
||||
"",
|
||||
|
|
@ -828,14 +851,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_alice", user_id=None),
|
||||
"oc_chat_a",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_charlie", user_id=None),
|
||||
"oc_chat_a",
|
||||
|
|
@ -864,14 +887,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_alice", user_id=None),
|
||||
"oc_chat_b",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_blocked", user_id=None),
|
||||
"oc_chat_b",
|
||||
|
|
@ -900,14 +923,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_c",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_regular", user_id=None),
|
||||
"oc_chat_c",
|
||||
|
|
@ -936,14 +959,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_d",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_regular", user_id=None),
|
||||
"oc_chat_d",
|
||||
|
|
@ -973,7 +996,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_e",
|
||||
|
|
@ -997,7 +1020,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_anyone", user_id=None),
|
||||
"oc_chat_unknown",
|
||||
|
|
@ -1022,8 +1045,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||
self.assertTrue(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[bot_mention]), sender_id, "")
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_matches_bot_name_when_only_name_available(self):
|
||||
|
|
@ -1048,8 +1075,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
id=SimpleNamespace(open_id=None, user_id=None),
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[name_only_mention]), sender_id, ""))
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id, ""))
|
||||
self.assertTrue(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[name_only_mention]), sender_id, "")
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[different_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
# Case 2: bot's open_id IS known — a same-name human with different
|
||||
# open_id must NOT admit (IDs override names).
|
||||
|
|
@ -1066,8 +1097,17 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
id=SimpleNamespace(open_id="ou_bot", user_id=None),
|
||||
)
|
||||
|
||||
self.assertFalse(adapter2._should_accept_group_message(SimpleNamespace(mentions=[same_name_other_id_mention]), sender_id, ""))
|
||||
self.assertTrue(adapter2._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
||||
self.assertFalse(
|
||||
_admits_group(
|
||||
adapter2,
|
||||
SimpleNamespace(mentions=[same_name_other_id_mention]),
|
||||
sender_id,
|
||||
"",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
_admits_group(adapter2, SimpleNamespace(mentions=[bot_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_extract_post_message_as_text(self):
|
||||
|
|
@ -1411,6 +1451,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
data=SimpleNamespace(event=SimpleNamespace(message=message)),
|
||||
message=message,
|
||||
sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
|
||||
is_bot=False,
|
||||
chat_type="p2p",
|
||||
message_id="om_command",
|
||||
)
|
||||
|
|
@ -1522,13 +1563,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
user_id="u_user",
|
||||
union_id="on_union",
|
||||
)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message, sender=SimpleNamespace(sender_id=sender_id)))
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))
|
||||
|
||||
asyncio.run(
|
||||
adapter._process_inbound_message(
|
||||
data=data,
|
||||
message=message,
|
||||
sender_id=sender_id,
|
||||
sender_id=sender.sender_id,
|
||||
chat_type="p2p",
|
||||
message_id="om_text",
|
||||
)
|
||||
|
|
@ -1761,13 +1803,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
message_id="om_group_text",
|
||||
)
|
||||
sender_id = SimpleNamespace(open_id="ou_user", user_id=None, union_id=None)
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message))
|
||||
|
||||
asyncio.run(
|
||||
adapter._process_inbound_message(
|
||||
data=data,
|
||||
message=message,
|
||||
sender_id=sender_id,
|
||||
sender_id=sender.sender_id,
|
||||
chat_type="group",
|
||||
message_id="om_group_text",
|
||||
)
|
||||
|
|
@ -1805,6 +1848,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
data=SimpleNamespace(event=SimpleNamespace(message=message)),
|
||||
message=message,
|
||||
sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
|
||||
is_bot=False,
|
||||
chat_type="p2p",
|
||||
message_id="om_reply",
|
||||
)
|
||||
|
|
@ -2667,11 +2711,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
|||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestHydrateBotIdentity(unittest.TestCase):
|
||||
"""Hydration of bot identity via /open-apis/bot/v3/info and application info.
|
||||
"""Hydration of bot identity via ``/open-apis/bot/v3/info``.
|
||||
|
||||
Covers the manual-setup path where FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID
|
||||
are not configured. Hydration must populate _bot_open_id so that
|
||||
_is_self_sent_bot_message() can filter the adapter's own outbound echoes.
|
||||
Covers the manual-setup path where ``FEISHU_BOT_OPEN_ID`` /
|
||||
``FEISHU_BOT_NAME`` are not configured — hydration populates them so
|
||||
self-echo protection and group @mention gating both have something to
|
||||
match against.
|
||||
"""
|
||||
|
||||
def _make_adapter(self):
|
||||
|
|
@ -2700,11 +2745,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
|||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_hermes_hydrated")
|
||||
self.assertEqual(adapter._bot_name, "Hermes Bot")
|
||||
# Application-info fallback must NOT run when bot_name is already set.
|
||||
self.assertFalse(
|
||||
adapter._client.application.v6.application.get.called
|
||||
if hasattr(adapter._client, "application") else False
|
||||
)
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
|
|
@ -2721,7 +2761,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
|||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
# Neither probe should run — both fields are already populated.
|
||||
adapter._client.request.assert_not_called()
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env")
|
||||
self.assertEqual(adapter._bot_name, "Env Hermes")
|
||||
|
|
@ -2766,33 +2805,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
|||
self.assertEqual(adapter._bot_open_id, "")
|
||||
self.assertEqual(adapter._bot_name, "Fallback Bot")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydrated_open_id_enables_self_send_filter(self):
|
||||
"""E2E: after hydration, _is_self_sent_bot_message() rejects adapter's own id."""
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
{"code": 0, "bot": {"bot_name": "Hermes", "open_id": "ou_hermes"}}
|
||||
).encode("utf-8")
|
||||
adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload)))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id=""),
|
||||
)
|
||||
)
|
||||
peer_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_peer_bot", user_id=""),
|
||||
)
|
||||
)
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(self_event))
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(peer_event))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestPendingInboundQueue(unittest.TestCase):
|
||||
|
|
@ -3137,7 +3149,7 @@ class TestGroupMentionAtAll(unittest.TestCase):
|
|||
mentions=[],
|
||||
)
|
||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||
self.assertTrue(adapter._should_accept_group_message(message, sender_id, ""))
|
||||
self.assertTrue(_admits_group(adapter, message, sender_id, ""))
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed"}, clear=True)
|
||||
def test_at_all_still_requires_policy_gate(self):
|
||||
|
|
@ -3149,15 +3161,15 @@ class TestGroupMentionAtAll(unittest.TestCase):
|
|||
message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[])
|
||||
# Non-allowlisted user — should be blocked even with @_all.
|
||||
blocked_sender = SimpleNamespace(open_id="ou_blocked", user_id=None)
|
||||
self.assertFalse(adapter._should_accept_group_message(message, blocked_sender, ""))
|
||||
self.assertFalse(_admits_group(adapter, message, blocked_sender, ""))
|
||||
# Allowlisted user — should pass.
|
||||
allowed_sender = SimpleNamespace(open_id="ou_allowed", user_id=None)
|
||||
self.assertTrue(adapter._should_accept_group_message(message, allowed_sender, ""))
|
||||
self.assertTrue(_admits_group(adapter, message, allowed_sender, ""))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestSenderNameResolution(unittest.TestCase):
|
||||
"""Tests for _resolve_sender_name_from_api."""
|
||||
"""Tests for _resolve_sender_name_from_api (contact API + cache)."""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_returns_none_when_client_is_none(self):
|
||||
|
|
@ -3261,6 +3273,137 @@ class TestSenderNameResolution(unittest.TestCase):
|
|||
self.assertIsNone(result)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestBotNameResolution(unittest.TestCase):
|
||||
"""Tests for the bot branch of _resolve_sender_name_from_api (basic_batch API + shared cache)."""
|
||||
|
||||
@staticmethod
|
||||
def _batch_payload(bots: Dict[str, str]):
|
||||
import json as _json
|
||||
body = {
|
||||
oid: {"bot_id": oid, "name": name, "i18n_names": {"en_us": name}}
|
||||
for oid, name in bots.items()
|
||||
}
|
||||
return _json.dumps({"code": 0, "msg": "", "data": {"bots": body, "failed_bots": {}}}).encode()
|
||||
|
||||
def _build_adapter_with_bots(self, bots: Dict[str, str]):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
calls = []
|
||||
|
||||
def _fake_request(request):
|
||||
calls.append(request)
|
||||
return SimpleNamespace(raw=SimpleNamespace(content=self._batch_payload(bots)))
|
||||
|
||||
adapter._client = SimpleNamespace(request=_fake_request)
|
||||
return adapter, calls
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_returns_cached_bot_name_without_api_call(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._sender_name_cache["ou_peer"] = ("Peer Bot", time.time() + 600)
|
||||
adapter._client = SimpleNamespace(
|
||||
request=lambda _r: (_ for _ in ()).throw(RuntimeError("should not fetch"))
|
||||
)
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
self.assertEqual(result, "Peer Bot")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_fetches_and_caches_bot_name(self):
|
||||
adapter, calls = self._build_adapter_with_bots({"ou_peer": "Peer Bot"})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertEqual(result, "Peer Bot")
|
||||
self.assertEqual(adapter._sender_name_cache["ou_peer"][0], "Peer Bot")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("/open-apis/bot/v3/bots/basic_batch", calls[0].uri)
|
||||
# Feishu expects repeated ?bot_ids= params, not comma-joined.
|
||||
self.assertEqual(calls[0].queries, [("bot_ids", "ou_peer")])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_api_failure_returns_none_and_does_not_poison_cache(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
|
||||
def _broken_request(_req):
|
||||
raise RuntimeError("API down")
|
||||
|
||||
adapter._client = SimpleNamespace(request=_broken_request)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_peer", adapter._sender_name_cache)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_bot_absent_from_response_is_not_cached(self):
|
||||
"""Bot not in ``data.bots`` (e.g. landed in ``failed_bots``) → no
|
||||
cache entry, next lookup re-fetches."""
|
||||
adapter, _ = self._build_adapter_with_bots({"ou_other": "Other Bot"})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_ghost", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_ghost", adapter._sender_name_cache)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_empty_name_in_response_is_negative_cached(self):
|
||||
"""API returns name="" → cache "" so repeat lookups short-circuit."""
|
||||
adapter, calls = self._build_adapter_with_bots({"ou_nameless": ""})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
first = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True))
|
||||
second = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True))
|
||||
|
||||
self.assertIsNone(first)
|
||||
self.assertIsNone(second)
|
||||
self.assertEqual(adapter._sender_name_cache["ou_nameless"][0], "")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_non_zero_code_returns_none(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
error_payload = b'{"code":99991663,"msg":"permission denied"}'
|
||||
adapter._client = SimpleNamespace(
|
||||
request=lambda _r: SimpleNamespace(raw=SimpleNamespace(content=error_payload))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_peer", adapter._sender_name_cache)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestProcessingReactions(unittest.TestCase):
|
||||
"""Typing on start → removed on SUCCESS, swapped for CrossMark on FAILURE,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue