fix(feishu): hydrate bot open_id for manual-setup users

Extends _hydrate_bot_identity() to also populate _bot_open_id (not just
_bot_name) by probing /open-apis/bot/v3/info — the same endpoint the
scan-to-create wizard uses. No extra scopes required beyond the tenant
access token.

Closes the manual-setup gap in #12450: users who configured Feishu
without running the wizard, and never set FEISHU_BOT_OPEN_ID, now get
a bot identity that _is_self_sent_bot_message() can actually use to
filter the adapter's own bot-sent events.

Each field is hydrated independently:
  - Env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID / FEISHU_BOT_NAME)
    still take precedence and skip their respective probe.
  - /bot/v3/info provides open_id + name.
  - Application-info endpoint remains as a best-effort fallback for
    bot_name only (needs admin:app.info:readonly scope).

Tests: 5 new cases covering env-var precedence, probe success, probe
failure fallback, and the end-to-end self-send filter gate after
hydration.
This commit is contained in:
Teknium
2026-04-19 11:22:25 -07:00
committed by Teknium
parent 2d54e17b82
commit 014248567b
2 changed files with 179 additions and 5 deletions

View File

@@ -2684,6 +2684,135 @@ 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.
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.
"""
def _make_adapter(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
return FeishuAdapter(PlatformConfig())
@patch.dict(os.environ, {}, clear=True)
def test_hydration_populates_open_id_from_bot_info(self):
adapter = self._make_adapter()
adapter._client = Mock()
payload = json.dumps(
{
"code": 0,
"bot": {
"bot_name": "Hermes Bot",
"open_id": "ou_hermes_hydrated",
},
}
).encode("utf-8")
response = SimpleNamespace(content=payload)
adapter._client.request = Mock(return_value=response)
asyncio.run(adapter._hydrate_bot_identity())
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,
{
"FEISHU_BOT_OPEN_ID": "ou_env",
"FEISHU_BOT_NAME": "Env Hermes",
},
clear=True,
)
def test_hydration_skipped_when_env_vars_supply_both_fields(self):
adapter = self._make_adapter()
adapter._client = Mock()
adapter._client.request = Mock()
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")
@patch.dict(os.environ, {"FEISHU_BOT_OPEN_ID": "ou_env"}, clear=True)
def test_hydration_fills_only_missing_fields(self):
"""Env-var open_id must NOT be overwritten by a different probe value."""
adapter = self._make_adapter()
adapter._client = Mock()
payload = json.dumps(
{
"code": 0,
"bot": {
"bot_name": "Hermes Bot",
"open_id": "ou_probe_DIFFERENT",
},
}
).encode("utf-8")
adapter._client.request = Mock(return_value=SimpleNamespace(content=payload))
asyncio.run(adapter._hydrate_bot_identity())
self.assertEqual(adapter._bot_open_id, "ou_env") # preserved
self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in
@patch.dict(os.environ, {}, clear=True)
def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self):
adapter = self._make_adapter()
adapter._client = Mock()
adapter._client.request = Mock(side_effect=RuntimeError("network down"))
# Make the application-info fallback succeed for _bot_name.
app_response = Mock()
app_response.success = Mock(return_value=True)
app_response.data = SimpleNamespace(app=SimpleNamespace(app_name="Fallback Bot"))
adapter._client.application.v6.application.get = Mock(return_value=app_response)
adapter._build_get_application_request = Mock(return_value=object())
asyncio.run(adapter._hydrate_bot_identity())
# Primary probe failed — open_id stays empty, but bot_name came from app-info.
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(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):
"""Tests for the loop-not-ready race (#5499): inbound events arriving