diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 126a3b7a09..0531bff487 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -3338,10 +3338,55 @@ class FeishuAdapter(BasePlatformAdapter): return False async def _hydrate_bot_identity(self) -> None: - """Best-effort discovery of bot identity for precise group mention gating.""" + """Best-effort discovery of bot identity for precise group mention gating + and self-sent bot event filtering. + + Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info + (no extra scopes required beyond the tenant access token). Falls back to + the application info endpoint for ``_bot_name`` only when the first probe + doesn't return it. Each field is hydrated independently — a value already + supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID / + FEISHU_BOT_NAME) is preserved and skips its probe. + """ if not self._client: return - if any((self._bot_open_id, self._bot_user_id, self._bot_name)): + if self._bot_open_id and self._bot_name: + # Everything the self-send filter and precise mention gate need is + # already in place; nothing to probe. + return + + # Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no + # extra scopes required. This is the same endpoint the onboarding wizard + # uses via probe_bot(). + if not self._bot_open_id or not self._bot_name: + try: + resp = await asyncio.to_thread( + self._client.request, + method="GET", + url="/open-apis/bot/v3/info", + body=None, + raw_response=True, + ) + content = getattr(resp, "content", None) + if content: + payload = json.loads(content) + parsed = _parse_bot_response(payload) or {} + open_id = (parsed.get("bot_open_id") or "").strip() + bot_name = (parsed.get("bot_name") or "").strip() + if open_id and not self._bot_open_id: + self._bot_open_id = open_id + if bot_name and not self._bot_name: + self._bot_name = bot_name + except Exception: + logger.debug( + "[Feishu] /bot/v3/info probe failed during hydration", + exc_info=True, + ) + + # Fallback probe for _bot_name only: application info endpoint. Needs + # admin:app.info:readonly or application:application:self_manage scope, + # so it's best-effort. + if self._bot_name: return try: request = self._build_get_application_request(app_id=self._app_id, lang="en_us") @@ -3350,17 +3395,17 @@ class FeishuAdapter(BasePlatformAdapter): code = getattr(response, "code", None) if code == 99991672: logger.warning( - "[Feishu] Unable to hydrate bot identity from application info. " + "[Feishu] Unable to hydrate bot name from application info. " "Grant admin:app.info:readonly or application:application:self_manage " "so group @mention gating can resolve the bot name precisely." ) return app = getattr(getattr(response, "data", None), "app", None) app_name = (getattr(app, "app_name", None) or "").strip() - if app_name: + if app_name and not self._bot_name: self._bot_name = app_name except Exception: - logger.debug("[Feishu] Failed to hydrate bot identity", exc_info=True) + logger.debug("[Feishu] Failed to hydrate bot name from application info", exc_info=True) # ========================================================================= # Deduplication — seen message ID cache (persistent) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index cfb5d12fa1..21ef6a4276 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -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