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:
Roy-oss1
2026-04-26 15:03:00 +08:00
committed by Teknium
parent fa9fd26acb
commit b94cb8e2c4
10 changed files with 1478 additions and 182 deletions

View File

@@ -0,0 +1,65 @@
"""Shared fixtures for Feishu adapter tests (admission, group policy, dispatch)."""
from __future__ import annotations
import threading
from types import SimpleNamespace
from typing import Any, Optional
def make_sender(sender_type: str = "user", open_id: str = "ou_human",
user_id: Optional[str] = None, union_id: Optional[str] = None) -> Any:
return SimpleNamespace(
sender_type=sender_type,
sender_id=SimpleNamespace(open_id=open_id, user_id=user_id, union_id=union_id),
)
def make_message(message_id: str = "om_xxx", chat_type: str = "p2p",
chat_id: str = "oc_1", mentions: Optional[list] = None) -> Any:
return SimpleNamespace(
message_id=message_id,
chat_type=chat_type,
chat_id=chat_id,
mentions=mentions,
content="",
message_type="text",
)
def make_adapter_skeleton(
*,
bot_open_id: str = "ou_me",
bot_user_id: str = "",
allow_bots: str = "none",
require_mention: bool = True,
group_policy: str = "allowlist",
) -> Any:
from gateway.platforms.feishu import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._bot_open_id = bot_open_id
adapter._bot_user_id = bot_user_id
adapter._bot_name = ""
adapter._app_id = ""
adapter._admins = set()
adapter._group_rules = {}
adapter._group_policy = group_policy
adapter._default_group_policy = group_policy
adapter._allowed_group_users = frozenset()
adapter._allow_bots = allow_bots
adapter._require_mention = require_mention
return adapter
def install_dedup_state(adapter: Any, seen: Optional[dict] = None) -> None:
adapter._seen_message_ids = dict(seen) if seen else {}
adapter._seen_message_order = list((seen or {}).keys())
adapter._dedup_cache_size = 100
adapter._dedup_lock = threading.Lock()
adapter._dedup_state_path = None
adapter._persist_seen_message_ids = lambda: None
def stub_mention(adapter: Any, mentions_self: bool) -> None:
adapter._mentions_self = lambda _message: mentions_self

View File

@@ -360,6 +360,38 @@ class TestLoadGatewayConfig:
"C01ABC": "Code review mode",
}
def test_bridges_feishu_allow_bots_from_config_yaml_to_env(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"feishu:\n allow_bots: mentions\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
load_gateway_config()
assert os.environ.get("FEISHU_ALLOW_BOTS") == "mentions"
def test_feishu_allow_bots_env_takes_precedence_over_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
config_path = hermes_home / "config.yaml"
config_path.write_text(
"feishu:\n allow_bots: all\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "none")
load_gateway_config()
assert os.environ.get("FEISHU_ALLOW_BOTS") == "none"
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()

View File

@@ -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,

View File

@@ -0,0 +1,745 @@
"""Adapter-layer tests for Feishu bot-sender admission (``FeishuAdapter._admit``)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from tests.gateway.feishu_helpers import (
install_dedup_state,
make_adapter_skeleton,
make_message,
make_sender,
stub_mention,
)
# --- FeishuAdapterSettings wiring ------------------------------------------
@pytest.mark.parametrize(
"env_value, expected",
[
("none", "none"),
("mentions", "mentions"),
("all", "all"),
(" Mentions ", "mentions"),
],
)
def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected):
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.setenv("FEISHU_ALLOW_BOTS", env_value)
settings = FeishuAdapter._load_settings(extra={})
assert settings.allow_bots == expected
def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
settings = FeishuAdapter._load_settings(extra={})
assert settings.allow_bots == "none"
def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
# extra is ignored — env is single source of truth (yaml is bridged to env).
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
settings = FeishuAdapter._load_settings(extra={"allow_bots": "all"})
assert settings.allow_bots == "none"
def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "mentions")
settings = FeishuAdapter._load_settings(extra={})
assert settings.allow_bots == "mentions"
def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
import logging
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "menton") # typo
with caplog.at_level(logging.WARNING, logger="gateway.platforms.feishu"):
settings = FeishuAdapter._load_settings(extra={})
assert settings.allow_bots == "none"
assert any("allow_bots" in r.message and "menton" in r.message for r in caplog.records)
@pytest.mark.parametrize(
"env_value, extra, expected",
[
(None, {}, True),
("false", {}, False),
("true", {}, True),
("true", {"require_mention": False}, False),
],
)
def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected):
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
if env_value is None:
monkeypatch.delenv("FEISHU_REQUIRE_MENTION", raising=False)
else:
monkeypatch.setenv("FEISHU_REQUIRE_MENTION", env_value)
settings = FeishuAdapter._load_settings(extra=extra)
assert settings.require_mention is expected
def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
settings = FeishuAdapter._load_settings(extra={
"group_rules": {
"oc_free": {"policy": "open", "require_mention": False},
"oc_strict": {"policy": "open", "require_mention": True},
"oc_inherit": {"policy": "open"},
},
})
assert settings.group_rules["oc_free"].require_mention is False
assert settings.group_rules["oc_strict"].require_mention is True
assert settings.group_rules["oc_inherit"].require_mention is None
# --- Module-level helpers --------------------------------------------------
def test_sender_identity_collects_every_non_empty_id_variant():
from gateway.platforms.feishu import _sender_identity
sender = SimpleNamespace(
sender_id=SimpleNamespace(open_id="ou_x", user_id="", union_id="un_x"),
)
assert _sender_identity(sender) == frozenset({"ou_x", "un_x"})
def test_sender_identity_handles_missing_sender_id():
from gateway.platforms.feishu import _sender_identity
assert _sender_identity(SimpleNamespace()) == frozenset()
@pytest.mark.parametrize("sender_type", ["bot", "app"])
def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type):
from gateway.platforms.feishu import _is_bot_sender
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True
@pytest.mark.parametrize("sender_type", ["user", "", None])
def test_is_bot_sender_rejects_non_bot_origin(sender_type):
from gateway.platforms.feishu import _is_bot_sender
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False
# --- _admit pipeline matrix ------------------------------------------------
#
# Covers the four-step admission pipeline (self_echo → bot_policy →
# DM bypass → group_policy + mention) as a single result-only matrix.
# Each row pins one decision in the pipeline; tests asserting call-count
# semantics live below in their own functions.
def _admit_case(
*,
adapter: dict | None = None,
sender: dict | None = None,
message: dict | None = None,
mentions_self: bool | None = None,
expected: str | None = None,
):
return {
"adapter": adapter or {},
"sender": sender or {},
"message": message or {},
"mentions_self": mentions_self,
"expected": expected,
}
_ADMIT_CASES = [
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_me", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": "ou_me"},
expected="self_echo",
),
id="self_echo:open_id_under_all_mode",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "", "bot_user_id": "u_me", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": None, "user_id": "u_me"},
expected="self_echo",
),
id="self_echo:user_id_only",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_me", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": "ou_me", "user_id": "u_me", "union_id": "un_me"},
expected="self_echo",
),
id="self_echo:mixed_ids",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "bot_user_id": "u_self", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": None, "user_id": "u_self"},
expected="self_echo",
),
id="self_echo:user_id_when_bot_user_id_set",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "none"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
expected="bots_disabled",
),
id="bots_disabled:mode_none",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": ""},
sender={"sender_type": "bot", "open_id": "ou_peer"},
expected="bots_disabled",
),
id="bots_disabled:mode_empty",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "loose"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
expected="bots_disabled",
),
id="bots_disabled:mode_unknown_value",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "", "allow_bots": "none"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
expected="bots_disabled",
),
id="bots_disabled:wins_over_self_ids_unknown",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
expected="self_ids_unknown",
),
id="self_ids_unknown:bot_sender_no_self_ids",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "", "allow_bots": "all"},
sender={"sender_type": "app", "open_id": "ou_peer"},
expected="self_ids_unknown",
),
id="self_ids_unknown:app_sender_no_self_ids",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
sender={"sender_type": "app", "open_id": None},
expected="self_ids_unknown",
),
id="self_ids_unknown:no_sender_ids",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "mentions"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
mentions_self=False,
expected="bot_not_mentioned",
),
id="mentions_mode:not_mentioned_dm",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "mentions"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
mentions_self=True,
expected=None,
),
id="mentions_mode:mentioned_dm",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
mentions_self=False,
expected=None,
),
id="all_mode:not_mentioned_dm",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
mentions_self=True,
expected=None,
),
id="all_mode:mentioned_dm",
),
pytest.param(
_admit_case(
adapter={"bot_open_id": "", "allow_bots": "none"},
sender={"sender_type": "user", "open_id": "ou_human"},
expected=None,
),
id="human:dm_admitted_regardless_of_allow_bots",
),
pytest.param(
_admit_case(
adapter={"allow_bots": "all"},
sender={"sender_type": "user", "open_id": "ou_human"},
message={"message_id": "om_ok", "chat_type": "p2p"},
expected=None,
),
id="human:p2p_admitted",
),
pytest.param(
_admit_case(
adapter={
"bot_open_id": "ou_self",
"require_mention": False,
"group_policy": "open",
},
sender={"sender_type": "user", "open_id": "ou_human"},
message={"chat_type": "group"},
mentions_self=False,
expected=None,
),
id="require_mention_false:group_human_no_mention_admitted",
),
pytest.param(
_admit_case(
adapter={
"bot_open_id": "ou_self",
"allow_bots": "all",
"require_mention": False,
"group_policy": "open",
},
sender={"sender_type": "bot", "open_id": "ou_peer"},
message={"chat_type": "group"},
mentions_self=False,
expected=None,
),
id="require_mention_false:group_bot_all_mode_admitted",
),
pytest.param(
_admit_case(
adapter={
"bot_open_id": "ou_self",
"allow_bots": "mentions",
"require_mention": False,
"group_policy": "open",
},
sender={"sender_type": "bot", "open_id": "ou_peer"},
message={"chat_type": "group"},
mentions_self=False,
expected="bot_not_mentioned",
),
id="require_mention_false:group_bot_mentions_mode_still_gated",
),
]
@pytest.mark.parametrize("case", _ADMIT_CASES)
def test_admit_pipeline(case):
adapter = make_adapter_skeleton(**case["adapter"])
if case["mentions_self"] is not None:
stub_mention(adapter, case["mentions_self"])
sender = make_sender(**case["sender"])
message = make_message(**case["message"])
assert adapter._admit(sender, message) == case["expected"]
# --- Mention call-count semantics ------------------------------------------
def test_admit_skips_mention_check_under_all_mode():
# Tripwire: under allow_bots=all the mention path must not be probed.
adapter = make_adapter_skeleton(bot_open_id="ou_self", allow_bots="all")
calls = 0
def _tripwire(_message):
nonlocal calls
calls += 1
return False
adapter._mentions_self = _tripwire
sender = make_sender(sender_type="bot", open_id="ou_peer")
assert adapter._admit(sender, make_message()) is None
assert calls == 0
def test_admit_group_mention_checked_once_per_call():
# Stage 2 (mentions mode) and stage 4 (group require_mention) must not
# double-evaluate _mentions_self for the same admit call.
adapter = make_adapter_skeleton(
bot_open_id="ou_self", allow_bots="mentions", require_mention=True,
group_policy="open",
)
calls = 0
def _counting(_message):
nonlocal calls
calls += 1
return True
adapter._mentions_self = _counting
sender = make_sender(sender_type="bot", open_id="ou_peer")
assert adapter._admit(sender, make_message(chat_type="group")) is None
assert calls == 1
# --- Per-group require_mention override ------------------------------------
def test_admit_per_group_require_mention_overrides_global():
from gateway.platforms.feishu import FeishuGroupRule
adapter = make_adapter_skeleton(
bot_open_id="ou_self", require_mention=True, group_policy="open",
)
adapter._group_rules = {
"oc_free": FeishuGroupRule(policy="open", require_mention=False),
}
stub_mention(adapter, False)
sender = make_sender(sender_type="user", open_id="ou_human")
assert adapter._admit(sender, make_message(chat_id="oc_free", chat_type="group")) is None
assert (
adapter._admit(sender, make_message(chat_id="oc_other", chat_type="group"))
== "group_policy_rejected"
)
# --- Hydration -------------------------------------------------------------
def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
import asyncio
from gateway.platforms.feishu import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._bot_open_id = ""
adapter._bot_user_id = ""
adapter._bot_name = ""
adapter._allow_bots = "all"
captured = {}
def _fake_request(request):
captured["uri"] = getattr(request, "uri", None)
captured["http_method"] = getattr(request, "http_method", None)
return SimpleNamespace(raw=SimpleNamespace(
content=b'{"code":0,"bot":{"app_name":"Hermes","open_id":"ou_hydrated"}}'
))
adapter._client = SimpleNamespace(request=_fake_request)
asyncio.run(adapter._hydrate_bot_identity())
assert captured["uri"] == "/open-apis/bot/v3/info"
assert str(captured["http_method"]).endswith("GET")
assert adapter._bot_open_id == "ou_hydrated"
assert adapter._bot_name == "Hermes"
# /bot/v3/info doesn't surface user_id, so _bot_user_id stays empty.
assert adapter._bot_user_id == ""
def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup():
import asyncio
from gateway.platforms.feishu import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._client = object()
adapter._sender_name_cache = {}
seen_ids = []
async def _fake_fetch_bot_names(bot_ids):
seen_ids.extend(bot_ids)
return {"ou_peer": "Peer Bot"}
adapter._fetch_bot_names = _fake_fetch_bot_names
profile = asyncio.run(
adapter._resolve_sender_profile(
SimpleNamespace(open_id="ou_peer", user_id="u_peer", union_id="on_peer"),
is_bot=True,
)
)
assert seen_ids == ["ou_peer"]
assert profile["user_id"] == "u_peer"
assert profile["user_name"] == "Peer Bot"
# --- _allow_group_message matrix -------------------------------------------
#
# Bot-bypass semantics: admitted bots skip allowlist/blacklist (parallel
# human-scope filters), but channel-level locks (disabled, admin_only) and
# admin short-circuits still apply.
def _group_case(
*,
adapter: dict | None = None,
admins: set | None = None,
group_rules: dict | None = None,
sender: dict | None = None,
chat_id: str = "oc_1",
is_bot: bool = False,
expected: bool = False,
):
return {
"adapter": adapter or {},
"admins": admins or set(),
"group_rules": group_rules or {},
"sender": sender or {},
"chat_id": chat_id,
"is_bot": is_bot,
"expected": expected,
}
def _group_rule(policy: str, **kwargs):
from gateway.platforms.feishu import FeishuGroupRule
return FeishuGroupRule(policy=policy, **kwargs)
_GROUP_CASES = [
pytest.param(
_group_case(
sender={"sender_type": "bot", "open_id": "ou_peer"},
is_bot=True,
expected=True,
),
id="bot:bypasses_default_allowlist",
),
pytest.param(
_group_case(
sender={"sender_type": "user", "open_id": "ou_stranger"},
is_bot=False,
expected=False,
),
id="human:gated_by_default_allowlist",
),
pytest.param(
_group_case(
admins={"ou_peer"},
sender={"sender_type": "bot", "open_id": "ou_peer"},
is_bot=True,
expected=True,
),
id="bot:admin_short_circuit",
),
pytest.param(
_group_case(
admins={"u_admin"},
sender={"sender_type": "user", "open_id": None, "user_id": "u_admin"},
is_bot=False,
expected=True,
),
id="human:admin_via_user_id",
),
pytest.param(
_group_case(
sender={"sender_type": "bot", "open_id": "ou_peer"},
is_bot=True,
expected=True,
),
id="bot:allowlist_skipped",
),
pytest.param(
_group_case(
sender={"sender_type": "app", "open_id": "ou_peer"},
is_bot=True,
expected=True,
),
id="app:allowlist_skipped",
),
]
# Channel-lock cases need group_rules construction; keep them in a separate
# parametrize so we can use _group_rule() (FeishuGroupRule import).
_GROUP_RULE_CASES = [
pytest.param(
"disabled", "bot", False,
id="bot:disabled_policy_blocks_even_with_bypass",
),
pytest.param(
"disabled", "app", False,
id="app:disabled_policy_blocks_even_with_bypass",
),
pytest.param(
"admin_only", "bot", False,
id="bot:admin_only_policy_blocks_non_admin",
),
pytest.param(
"admin_only", "app", False,
id="app:admin_only_policy_blocks_non_admin",
),
]
@pytest.mark.parametrize("case", _GROUP_CASES)
def test_allow_group_message_matrix(case):
adapter = make_adapter_skeleton(**case["adapter"])
adapter._admins = case["admins"]
adapter._group_rules = case["group_rules"]
sender = make_sender(**case["sender"])
assert adapter._allow_group_message(
sender_id=sender.sender_id,
chat_id=case["chat_id"],
is_bot=case["is_bot"],
) is case["expected"]
@pytest.mark.parametrize("policy, sender_type, expected", _GROUP_RULE_CASES)
def test_allow_group_message_channel_locks_apply_to_bots(policy, sender_type, expected):
adapter = make_adapter_skeleton()
adapter._group_rules = {"oc_locked": _group_rule(policy)}
sender = make_sender(sender_type=sender_type, open_id="ou_peer")
assert adapter._allow_group_message(
sender_id=sender.sender_id,
chat_id="oc_locked",
is_bot=True,
) is expected
@pytest.mark.parametrize("sender_type", ["bot", "app"])
def test_allow_group_message_blacklist_is_human_scope_only(sender_type):
# blacklist is parallel to allowlist (human-scope); admitted bots bypass
# it. To block a specific bot, gate upstream via FEISHU_ALLOW_BOTS.
adapter = make_adapter_skeleton()
adapter._group_rules = {
"oc_1": _group_rule("blacklist", blacklist={"ou_peer"})
}
sender = make_sender(sender_type=sender_type, open_id="ou_peer")
assert adapter._allow_group_message(
sender_id=sender.sender_id,
chat_id="oc_1",
is_bot=True,
) is True
# --- Realistic payload smoke -----------------------------------------------
def test_admit_accepts_realistic_bot_at_bot_group_event():
# Locks in the real im.message.receive_v1 payload shape under mode=mentions.
adapter = make_adapter_skeleton(bot_open_id="ou_self", allow_bots="mentions")
mention = SimpleNamespace(
key="@_user_1",
id=SimpleNamespace(union_id="on_mentionUnion", user_id="", open_id="ou_self"),
name="Hermes",
mentioned_type="bot",
tenant_key="tenant_ab",
)
message = SimpleNamespace(
message_id="om_realistic_bot_at_bot",
chat_id="oc_real",
chat_type="group",
message_type="text",
content='{"text":"@_user_1 hello"}',
mentions=[mention],
)
sender = SimpleNamespace(
sender_type="bot",
sender_id=SimpleNamespace(union_id="on_peerUnion", user_id="u_peer", open_id="ou_peer_bot"),
tenant_key="tenant_ab",
)
assert adapter._admit(sender, message) is None
# --- Event-dispatch plumbing -----------------------------------------------
def test_handle_message_event_data_drops_bot_sender_by_default():
import asyncio
adapter = make_adapter_skeleton()
install_dedup_state(adapter)
processed = []
async def _fake_process_inbound_message(**kwargs):
processed.append(kwargs)
adapter._process_inbound_message = _fake_process_inbound_message
data = SimpleNamespace(
event=SimpleNamespace(
sender=make_sender(sender_type="bot", open_id="ou_peer"),
message=make_message(message_id="om_bot_default", chat_type="p2p"),
)
)
asyncio.run(adapter._handle_message_event_data(data))
assert processed == []
def test_handle_message_event_data_forwards_sender_when_admitted():
import asyncio
adapter = make_adapter_skeleton(allow_bots="all")
install_dedup_state(adapter)
captured = {}
async def _fake_process_inbound_message(**kwargs):
captured.update(kwargs)
adapter._process_inbound_message = _fake_process_inbound_message
sender = make_sender(sender_type="bot", open_id="ou_peer")
data = SimpleNamespace(
event=SimpleNamespace(
sender=sender,
message=make_message(message_id="om_bot_ok", chat_type="p2p"),
)
)
asyncio.run(adapter._handle_message_event_data(data))
assert captured.get("sender_id") is sender.sender_id
assert captured.get("is_bot") is True
assert captured.get("message_id") == "om_bot_ok"

View File

@@ -0,0 +1,113 @@
"""Regression guard for Feishu bot-sender authorization bypass.
Mirrors tests/gateway/test_discord_bot_auth_bypass.py for Platform.FEISHU.
Without the bypass in gateway/run.py, Feishu bot senders admitted by the
adapter would be rejected at _is_user_authorized with "Unauthorized user"
— same class of bug as Discord #4466.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from gateway.session import Platform, SessionSource
@pytest.fixture(autouse=True)
def _isolate_feishu_env(monkeypatch):
for var in (
"FEISHU_ALLOW_BOTS",
"FEISHU_ALLOWED_USERS",
"FEISHU_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
):
monkeypatch.delenv(var, raising=False)
def _make_bare_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
return runner
def _make_feishu_bot_source(open_id: str = "ou_peer"):
return SessionSource(
platform=Platform.FEISHU,
chat_id="oc_1",
chat_type="group",
user_id=open_id,
user_name="PeerBot",
is_bot=True,
)
def _make_feishu_human_source(open_id: str = "ou_human"):
return SessionSource(
platform=Platform.FEISHU,
chat_id="oc_1",
chat_type="group",
user_id=open_id,
user_name="Human",
is_bot=False,
)
def test_feishu_bot_authorized_when_allow_bots_mentions(monkeypatch):
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "mentions")
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is True
def test_feishu_bot_authorized_when_allow_bots_all(monkeypatch):
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
assert runner._is_user_authorized(_make_feishu_bot_source()) is True
def test_feishu_bot_NOT_authorized_when_allow_bots_none(monkeypatch):
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "none")
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is False
def test_feishu_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is False
def test_feishu_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):
"""FEISHU_ALLOW_BOTS=all must NOT open the gate for humans."""
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
assert runner._is_user_authorized(_make_feishu_human_source("ou_stranger")) is False
assert runner._is_user_authorized(_make_feishu_human_source("ou_human")) is True
def test_feishu_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
"""FEISHU_ALLOW_BOTS=all must not authorize Telegram/Discord bot sources."""
runner = _make_bare_runner()
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
telegram_bot = SessionSource(
platform=Platform.TELEGRAM,
chat_id="123",
chat_type="channel",
user_id="999",
is_bot=True,
)
assert runner._is_user_authorized(telegram_bot) is False