From 7e9ea9ba05c42df1021a067f823f95f58d31eb12 Mon Sep 17 00:00:00 2001 From: Teknium Date: Mon, 13 Apr 2026 03:30:38 -0700 Subject: [PATCH] fix(feishu): correct identity model docs and prefer tenant-scoped user_id Feishu's open_id is app-scoped (same user gets different open_ids per bot app), not a canonical identity. Functionally correct for single-bot mode but semantically misleading. - Add comprehensive Feishu identity model documentation to module docstring - Prefer user_id (tenant-scoped) over open_id (app-scoped) in _resolve_sender_profile when both are available - Document bot_open_id usage for @mention matching - Update user_id_alt comment in SessionSource to be platform-generic Ref: closes analysis from PR #8388 (closed as over-scoped) --- gateway/platforms/feishu.py | 68 +++++++++++++++++++++++++++++++++--- gateway/session.py | 2 +- tests/gateway/test_feishu.py | 2 +- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 7fce74def..6a4b35813 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -13,6 +13,35 @@ Supports: - Interactive card button-click events routed as synthetic COMMAND events - Webhook anomaly tracking (matches openclaw createWebhookAnomalyTracker) - Verification token validation as second auth layer (matches openclaw) + +Feishu identity model +--------------------- +Feishu uses three user-ID tiers (official docs: +https://open.feishu.cn/document/home/user-identity-introduction/introduction): + + open_id (ou_xxx) — **App-scoped**. The same person gets a different + open_id under each Feishu app. Always available in + event payloads without extra permissions. + user_id (u_xxx) — **Tenant-scoped**. Stable within a company but + requires the ``contact:user.employee_id:readonly`` + scope. May not be present. + union_id (on_xxx) — **Developer-scoped**. Same across all apps owned by + one developer/ISV. Best cross-app stable ID. + +For bots specifically: + + app_id — The application's canonical credential identifier. + bot open_id — Returned by ``/bot/v3/info``. This is the bot's own + open_id *within its app context* and is what Feishu + puts in ``mentions[].id.open_id`` when someone + @-mentions the bot. Used for mention gating only. + +In single-bot mode (what Hermes currently supports), open_id works as a +de-facto unique user identifier since there is only one app context. + +Session-key participant isolation prefers ``union_id`` (via user_id_alt) +over ``open_id`` (via user_id) so that sessions stay stable if the same +user is seen through different apps in the future. """ from __future__ import annotations @@ -267,7 +296,7 @@ class FeishuNormalizedMessage: @dataclass(frozen=True) class FeishuAdapterSettings: - app_id: str + app_id: str # Canonical bot/app identifier (credential, not from event payloads) app_secret: str domain_name: str connection_mode: str @@ -275,7 +304,11 @@ class FeishuAdapterSettings: verification_token: str group_policy: str allowed_group_users: frozenset[str] + # Bot's own open_id (app-scoped) — returned by /bot/v3/info. Used only for + # @mention matching: Feishu puts this value in mentions[].id.open_id when + # a user @-mentions the bot in a group chat. bot_open_id: str + # Bot's user_id (tenant-scoped) — optional, used as fallback mention match. bot_user_id: str bot_name: str dedup_cache_size: int @@ -2900,10 +2933,22 @@ class FeishuAdapter(BasePlatformAdapter): return "group" async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[str]]: + """Map Feishu's three-tier user IDs onto Hermes' SessionSource fields. + + Preference order for the primary ``user_id`` field: + 1. user_id (tenant-scoped, most stable — requires permission scope) + 2. open_id (app-scoped, always available — different per bot app) + + ``user_id_alt`` carries the union_id (developer-scoped, stable across + all apps by the same developer). Session-key generation prefers + user_id_alt when present, so participant isolation stays stable even + if the primary ID is the app-scoped open_id. + """ open_id = getattr(sender_id, "open_id", None) or None user_id = getattr(sender_id, "user_id", None) or None union_id = getattr(sender_id, "union_id", None) or None - primary_id = open_id or user_id + # Prefer tenant-scoped user_id; fall back to app-scoped open_id. + primary_id = user_id or open_id display_name = await self._resolve_sender_name_from_api(primary_id or union_id) return { "user_id": primary_id, @@ -3058,7 +3103,13 @@ class FeishuAdapter(BasePlatformAdapter): return False def _message_mentions_bot(self, mentions: List[Any]) -> bool: - """Check whether any mention targets the configured or inferred bot identity.""" + """Check whether any mention targets the configured or inferred bot identity. + + Feishu @mention payloads carry the mentioned entity's open_id (app-scoped). + Since the bot's own open_id and the mention open_id share the same app + context, a direct comparison works correctly here. The user_id and + bot_name checks are fallbacks for rare cases where open_id is absent. + """ for mention in mentions: mention_id = getattr(mention, "id", None) mention_open_id = getattr(mention_id, "open_id", None) @@ -3084,7 +3135,13 @@ 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. + + Fetches the app's display name from the application info API. The bot's + open_id (used for @mention matching) is typically set from FEISHU_BOT_OPEN_ID + env var or from the /bot/v3/info probe during onboarding. This method only + fills in bot_name as a last-resort mention match when open_id is unavailable. + """ if not self._client: return if any((self._bot_open_id, self._bot_user_id, self._bot_name)): @@ -3816,6 +3873,9 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: Uses lark_oapi SDK when available, falls back to raw HTTP otherwise. Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure. + + Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID + that Feishu puts in @mention payloads. It is NOT the app_id. """ if FEISHU_AVAILABLE: return _probe_bot_sdk(app_id, app_secret, domain) diff --git a/gateway/session.py b/gateway/session.py index a11ade898..fa0e7dcde 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -81,7 +81,7 @@ class SessionSource: user_name: Optional[str] = None thread_id: Optional[str] = None # For forum topics, Discord threads, etc. chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack) - user_id_alt: Optional[str] = None # Signal UUID (alternative to phone number) + user_id_alt: Optional[str] = None # Platform-specific stable alt ID (Signal UUID, Feishu union_id) chat_id_alt: Optional[str] = None # Signal group internal ID @property diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 47f274d1b..1907e7df9 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -1603,7 +1603,7 @@ class TestAdapterBehavior(unittest.TestCase): adapter._dispatch_inbound_event.assert_awaited_once() event = adapter._dispatch_inbound_event.await_args.args[0] self.assertEqual(event.message_type, MessageType.TEXT) - self.assertEqual(event.source.user_id, "ou_user") + self.assertEqual(event.source.user_id, "u_user") # tenant-scoped user_id preferred over app-scoped open_id self.assertEqual(event.source.user_name, "张三") self.assertEqual(event.source.user_id_alt, "on_union") self.assertEqual(event.source.chat_name, "Feishu DM")