From 80b386a472fdb37113e137360da3cc60e796d782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JC=E7=9A=84AI=E5=88=86=E8=BA=AB?= Date: Tue, 28 Apr 2026 20:22:33 +0800 Subject: [PATCH] fix(feishu): refresh bot identity during hydration --- gateway/platforms/feishu.py | 71 +++++++++++++++++++----------------- tests/gateway/test_feishu.py | 46 +++++++++++++++++++---- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index ac920bab69..0c362a400c 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -3862,47 +3862,50 @@ class FeishuAdapter(BasePlatformAdapter): 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. + (no extra scopes required beyond the tenant access token). The probe + always runs when a client is available so stale env vars from app/bot + migrations do not break group @mention gating. Falls back to the + application info endpoint for ``_bot_name`` only when the first probe + doesn't return it. If the probe fails, env-provided values are preserved. """ if not self._client: return - 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: - req = ( - BaseRequest.builder() - .http_method(HttpMethod.GET) - .uri("/open-apis/bot/v3/info") - .token_types({AccessTokenType.TENANT}) - .build() - ) - resp = await asyncio.to_thread(self._client.request, req) - content = getattr(getattr(resp, "raw", None), "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, - ) + try: + req = ( + BaseRequest.builder() + .http_method(HttpMethod.GET) + .uri("/open-apis/bot/v3/info") + .token_types({AccessTokenType.TENANT}) + .build() + ) + resp = await asyncio.to_thread(self._client.request, req) + content = getattr(getattr(resp, "raw", None), "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: + if self._bot_open_id and self._bot_open_id != open_id: + logger.warning( + "[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating." + ) + self._bot_open_id = open_id + if bot_name: + if self._bot_name and self._bot_name != bot_name: + logger.info( + "[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating." + ) + 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, diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 8042d38e3f..0444261b18 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -2817,20 +2817,32 @@ class TestHydrateBotIdentity(unittest.TestCase): }, clear=True, ) - def test_hydration_skipped_when_env_vars_supply_both_fields(self): + def test_hydration_refreshes_env_values_when_bot_info_available(self): adapter = self._make_adapter() adapter._client = Mock() - adapter._client.request = Mock() + payload = json.dumps( + { + "code": 0, + "bot": { + "bot_name": "Hydrated Hermes", + "open_id": "ou_hydrated", + }, + } + ).encode("utf-8") + adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload))) asyncio.run(adapter._hydrate_bot_identity()) - adapter._client.request.assert_not_called() - self.assertEqual(adapter._bot_open_id, "ou_env") - self.assertEqual(adapter._bot_name, "Env Hermes") + # PR #16993 semantics: /bot/v3/info probe runs unconditionally + # and hydrated values win over env vars so a stale FEISHU_BOT_* + # from an old app registration doesn't break @mention gating. + adapter._client.request.assert_called_once() + self.assertEqual(adapter._bot_open_id, "ou_hydrated") + self.assertEqual(adapter._bot_name, "Hydrated 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.""" + def test_hydration_overwrites_stale_env_open_id(self): + """A stale env open_id should not break group mention gating after app migration.""" adapter = self._make_adapter() adapter._client = Mock() payload = json.dumps( @@ -2846,9 +2858,27 @@ class TestHydrateBotIdentity(unittest.TestCase): asyncio.run(adapter._hydrate_bot_identity()) - self.assertEqual(adapter._bot_open_id, "ou_env") # preserved + self.assertEqual(adapter._bot_open_id, "ou_probe_DIFFERENT") self.assertEqual(adapter._bot_name, "Hermes Bot") # filled in + @patch.dict( + os.environ, + { + "FEISHU_BOT_OPEN_ID": "ou_env", + "FEISHU_BOT_NAME": "Env Hermes", + }, + clear=True, + ) + def test_hydration_preserves_env_values_when_bot_info_probe_fails(self): + adapter = self._make_adapter() + adapter._client = Mock() + adapter._client.request = Mock(side_effect=RuntimeError("network down")) + + asyncio.run(adapter._hydrate_bot_identity()) + + self.assertEqual(adapter._bot_open_id, "ou_env") + self.assertEqual(adapter._bot_name, "Env Hermes") + @patch.dict(os.environ, {}, clear=True) def test_hydration_tolerates_probe_failure_and_falls_back_to_app_info(self): adapter = self._make_adapter()