fix(feishu): refresh bot identity during hydration

This commit is contained in:
JC的AI分身 2026-04-28 20:22:33 +08:00 committed by Teknium
parent 314361733f
commit 80b386a472
2 changed files with 75 additions and 42 deletions

View file

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

View file

@ -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()