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

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