feishu-user-plugin 1.3.11 → 1.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor-plugin/plugin.json +2 -2
  3. package/.mcpb/manifest.json +3 -3
  4. package/CHANGELOG.md +108 -8
  5. package/README.en.md +130 -413
  6. package/README.md +69 -259
  7. package/package.json +2 -2
  8. package/scripts/check-description-drift.js +73 -0
  9. package/scripts/check-docs-sync.js +7 -16
  10. package/scripts/check-scopes.js +99 -0
  11. package/scripts/check-tool-count.js +4 -3
  12. package/scripts/sync-claude-md.sh +3 -4
  13. package/scripts/verify-app-name.js +64 -0
  14. package/skills/feishu-user-plugin/SKILL.md +3 -3
  15. package/skills/feishu-user-plugin/references/search.md +3 -3
  16. package/src/auth/credentials-monitor.js +185 -0
  17. package/src/auth/identity-state.js +204 -0
  18. package/src/auth/uat.js +49 -35
  19. package/src/cli.js +87 -0
  20. package/src/clients/official/base.js +145 -14
  21. package/src/clients/official/calendar.js +3 -1
  22. package/src/clients/official/im.js +76 -2
  23. package/src/clients/official/okr.js +2 -1
  24. package/src/error-codes.js +40 -0
  25. package/src/events/lockfile.js +40 -4
  26. package/src/events/owner.js +11 -2
  27. package/src/index.js +1 -1
  28. package/src/logger.js +11 -5
  29. package/src/oauth.js +46 -10
  30. package/src/server.js +60 -37
  31. package/src/test-all.js +40 -0
  32. package/src/test-cli-tool.js +87 -0
  33. package/src/test-credentials-monitor.js +124 -0
  34. package/src/test-display-label.js +88 -0
  35. package/src/test-error-codes.js +85 -0
  36. package/src/test-identity-state.js +172 -0
  37. package/src/test-lockfile-pid.js +90 -0
  38. package/src/test-lru-cache.js +145 -0
  39. package/src/test-negative-cache.js +85 -0
  40. package/src/test-populate-sender-names.js +98 -0
  41. package/src/test-search-messages.js +101 -0
  42. package/src/test-send-shape.js +115 -0
  43. package/src/test-via-user.js +94 -0
  44. package/src/test-with-uat-retry.js +135 -0
  45. package/src/tools/_registry.js +24 -1
  46. package/src/tools/calendar.js +5 -5
  47. package/src/tools/im-read.js +52 -4
  48. package/src/tools/messaging-user.js +1 -1
  49. package/src/utils.js +83 -0
  50. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
@@ -6,11 +6,12 @@ const { TOOLS } = require(path.join(__dirname, '..', 'src', 'server'));
6
6
 
7
7
  const failures = [];
8
8
 
9
- // Source 1: README.md "N tools" badge
9
+ // Source 1: README.md tool count — accepts "N tools" (English) or "N 工具" (Chinese)
10
+ // since README.md is Chinese-primary while README.en.md mirrors in English.
10
11
  const readme = fs.readFileSync(path.join(__dirname, '..', 'README.md'), 'utf8');
11
- const readmeMatch = readme.match(/(\d+)\s+tools/);
12
+ const readmeMatch = readme.match(/(\d+)\s*(?:tools|工具)/);
12
13
  if (!readmeMatch) {
13
- failures.push('No "N tools" badge in README.md');
14
+ failures.push('No "N tools" / "N 工具" marker in README.md');
14
15
  } else if (parseInt(readmeMatch[1], 10) !== TOOLS.length) {
15
16
  failures.push(`README.md claims ${readmeMatch[1]} tools, src/server.js has ${TOOLS.length}`);
16
17
  }
@@ -4,9 +4,8 @@ ROOT="$(git rev-parse --show-toplevel)"
4
4
  cd "$ROOT"
5
5
  if git diff --cached --name-only | grep -qx "CLAUDE.md"; then
6
6
  tail -n +2 CLAUDE.md > /tmp/feishu-claude-body.$$
7
- { echo "# feishu-user-plugin — Codex Instructions"; cat /tmp/feishu-claude-body.$$; } > AGENTS.md
7
+ { echo "# feishu-user-plugin — Codex 指令"; cat /tmp/feishu-claude-body.$$; } > AGENTS.md
8
8
  rm -f /tmp/feishu-claude-body.$$
9
- cp CLAUDE.md skills/feishu-user-plugin/references/CLAUDE.md
10
- git add AGENTS.md skills/feishu-user-plugin/references/CLAUDE.md
11
- echo "[hook] CLAUDE.md → AGENTS.md + skill reference synced"
9
+ git add AGENTS.md
10
+ echo "[hook] CLAUDE.md → AGENTS.md synced"
12
11
  fi
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // scripts/verify-app-name.js — diagnostic: does the current app have the
3
+ // tenant-side `application:application:self_manage` scope?
4
+ //
5
+ // Hits Feishu's app-info endpoint with the current credentials' APP_ID/SECRET
6
+ // and prints either the resolved app name (scope is granted) or the error
7
+ // code (with remediation pointing at docs/AUTH-SETUP.md).
8
+ //
9
+ // Usage:
10
+ // node scripts/verify-app-name.js
11
+ //
12
+ // Exit codes:
13
+ // 0 scope works, displayLabel will say "[Bot] AppName"
14
+ // 1 99991672 — scope missing, displayLabel will fall back to "[Bot] (cli_xxx)"
15
+ // 2 other auth failure (wrong APP_ID/SECRET, network, etc.)
16
+
17
+ 'use strict';
18
+
19
+ const { readCredentials } = require('../src/auth/credentials');
20
+
21
+ async function main() {
22
+ const creds = readCredentials() || {};
23
+ const appId = creds.LARK_APP_ID;
24
+ const appSecret = creds.LARK_APP_SECRET;
25
+ if (!appId || !appSecret) {
26
+ console.error('No LARK_APP_ID/SECRET in credentials. Run `npx feishu-user-plugin setup` first.');
27
+ process.exit(2);
28
+ }
29
+ console.error(`Probing app info for APP_ID=${appId}…`);
30
+
31
+ const tokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
35
+ });
36
+ const tokenData = await tokenRes.json();
37
+ if (!tokenData.app_access_token) {
38
+ console.error(`app_access_token request failed: ${JSON.stringify(tokenData)}`);
39
+ process.exit(2);
40
+ }
41
+
42
+ const infoRes = await fetch(`https://open.feishu.cn/open-apis/application/v6/applications/${appId}?lang=zh_cn`, {
43
+ headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
44
+ });
45
+ const info = await infoRes.json();
46
+
47
+ if (info.code === 0 && info.data?.app?.app_name) {
48
+ console.error(`OK — app name resolves to "${info.data.app.app_name}". displayLabel will read "[Bot] ${info.data.app.app_name}".`);
49
+ process.exit(0);
50
+ }
51
+ if (info.code === 99991672) {
52
+ console.error('FAIL — code 99991672. The tenant-side scope `application:application:self_manage` is not granted.');
53
+ console.error('Fix:');
54
+ console.error(' 1. Open https://open.feishu.cn/app/<appId>/safe — "应用身份" tab');
55
+ console.error(' 2. Add scope `application:application:self_manage` (marked 免审权限 — no admin review needed)');
56
+ console.error(' 3. Save; no re-publish required');
57
+ console.error(' 4. Re-run this script to confirm');
58
+ process.exit(1);
59
+ }
60
+ console.error(`FAIL — unexpected response: code=${info.code} msg=${info.msg || JSON.stringify(info)}`);
61
+ process.exit(2);
62
+ }
63
+
64
+ main().catch((e) => { console.error(`Threw: ${e.message}`); process.exit(2); });
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: feishu-user-plugin
3
- version: "1.3.11"
4
- description: "All-in-one Feishu plugin — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.8: multi-profile auto-switch on read errors (B), WebSocket realtime im.message events via get_new_events (C), credential pointer-only mode (E), CI gates (F), auth/uat.js + auth/cookie.js extracts (D)."
5
- allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
3
+ version: "1.3.12"
4
+ description: "All-in-one Feishu MCP server + CLI tool — send messages as yourself (incl. batch_send), read group/P2P chats (auto-expands merge_forward), manage docs/tables/wiki (full CRUD)/drive, OKR (with progress writes), calendar (read+write), Tasks v2, multi-profile auto-switch, real-time WS events. v1.3.12: search_messages tool (Protobuf phase 2 B.5, UAT-only), CLI tool mode (`tool list` / `tool help <name>` / `tool <name> '<json>'`), IdentityState state machine + credentials hot-reload (no-restart UAT reload), displayLabel + sender semantics pack for LLM consumption, WS owner PID liveness check, gitleaks secret scan."
5
+ allowed-tools: send_to_user, send_to_group, send_as_user, send_image_as_user, send_file_as_user, send_post_as_user, batch_send, send_card_as_user, search_contacts, create_p2p_chat, get_chat_info, get_user_info, get_login_status, list_profiles, switch_profile, manage_profile_hints, read_p2p_messages, list_user_chats, list_chats, read_messages, search_messages, send_message_as_bot, reply_message, forward_message, delete_message, update_message, add_reaction, delete_reaction, pin_message, create_group, update_group, list_members, manage_members, search_docs, read_doc, get_doc_blocks, create_doc, manage_doc_block, read_doc_markdown, manage_bitable_app, manage_bitable_table, manage_bitable_field, manage_bitable_view, manage_bitable_record, upload_bitable_attachment, list_wiki_spaces, search_wiki, list_wiki_nodes, get_wiki_node, create_wiki_node, update_wiki_node, move_wiki_node, copy_wiki_node, delete_wiki_node, list_files, create_folder, upload_drive_file, manage_drive_file, upload_image, upload_file, download_message_resource, download_doc_image, list_user_okrs, get_okrs, list_okr_periods, create_okr_progress_record, list_okr_progress_records, delete_okr_progress_record, list_calendars, list_calendar_events, get_calendar_event, create_calendar_event, update_calendar_event, delete_calendar_event, respond_calendar_event, get_freebusy, list_tasks, get_task, create_task, update_task, complete_task, delete_task, manage_task_members, get_new_events, manage_ws_status
6
6
  user_invocable: true
7
7
  ---
8
8
 
@@ -15,8 +15,8 @@
15
15
  - `/digest 群名` 整理聊天摘要
16
16
 
17
17
  ## 通过邮箱或手机号查找
18
- 如果用户提供了邮箱或手机号,改用 `find_user`:
18
+ 邮箱、手机号、姓名都可以作为 `query` 直接传给 `search_contacts`,不需要单独的工具:
19
19
  ```
20
- find_user({ email: "xxx@xxx.com" })
21
- find_user({ mobile: "+86xxx" })
20
+ search_contacts({ query: "xxx@xxx.com" })
21
+ search_contacts({ query: "+86xxx" })
22
22
  ```
@@ -0,0 +1,185 @@
1
+ // src/auth/credentials-monitor.js — single poller for credentials.json changes.
2
+ //
3
+ // The MCP server is long-lived; users routinely re-run `npx feishu-user-plugin
4
+ // oauth` from another shell to refresh UAT, expecting the running server to
5
+ // pick up the new token. Pre-v1.3.12 only the active-profile field was
6
+ // observed (server.js::_syncActiveProfileFromDisk); UAT changes, cookie
7
+ // rotations, and cache invalidation all required a Claude Code restart.
8
+ //
9
+ // CredentialsMonitor unifies the polling: per-tool-call `sync()` stats the
10
+ // file, hashes its contents, and fires per-field hooks on diff. Owners
11
+ // (officialClient, userClient, _userNameCache) register hooks at boot.
12
+ //
13
+ // Hash strategy: we don't trust mtime alone because `touch` would falsely
14
+ // trigger a UAT reload across every dispatcher call. Content hash + mtime
15
+ // means: skip the read entirely when mtime unchanged (cheap path), and
16
+ // when mtime advanced compute SHA-256 of the active profile's fields and
17
+ // compare per-field hashes to decide which hooks fire.
18
+ //
19
+ // Design choices:
20
+ // - factory not singleton: `createCredentialsMonitor({ path? })` so tests
21
+ // can use a tmpdir and server.js wires one against the real path.
22
+ // - synchronous sync(): callers are the request dispatcher; we can't await
23
+ // before handling a tool call. fs.statSync + fs.readFileSync are fine —
24
+ // credentials.json is tiny (a few KiB max).
25
+ // - hooks fire synchronously in registration order; exceptions are logged
26
+ // to stderr and don't block other hooks.
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+ const crypto = require('crypto');
34
+
35
+ const DEFAULT_PATH = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
36
+
37
+ function _hash(s) {
38
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 16);
39
+ }
40
+
41
+ function _readSafely(p) {
42
+ try { return fs.readFileSync(p, 'utf8'); } catch (_) { return null; }
43
+ }
44
+
45
+ function _statSafely(p) {
46
+ try { return fs.statSync(p); } catch (_) { return null; }
47
+ }
48
+
49
+ function _parseSafely(s) {
50
+ try {
51
+ const o = JSON.parse(s);
52
+ if (o && typeof o === 'object' && o.profiles && o.active) return o;
53
+ } catch (_) {}
54
+ return null;
55
+ }
56
+
57
+ function _activeProfileEnv(canonical) {
58
+ if (!canonical) return null;
59
+ const p = canonical.profiles[canonical.active];
60
+ return p ? { ...p } : null;
61
+ }
62
+
63
+ function _fieldHash(env, key) {
64
+ if (!env || env[key] === undefined || env[key] === null) return null;
65
+ return _hash(String(env[key]));
66
+ }
67
+
68
+ function createCredentialsMonitor({ path: credPath = DEFAULT_PATH } = {}) {
69
+ const hooks = {
70
+ uat: [],
71
+ cookie: [],
72
+ profile: [],
73
+ invalidate: [],
74
+ };
75
+
76
+ // Baseline state: null until first successful sync().
77
+ let lastMtimeMs = null;
78
+ let lastActive = null;
79
+ let lastUatHash = null;
80
+ let lastCookieHash = null;
81
+ // refresh field is paired with UAT — refresh-only rotation also fires uat hook.
82
+ let lastRefreshHash = null;
83
+ // `_initialized` flips on first successful read. We DON'T reset on file
84
+ // disappearance — that way a user who briefly removes credentials.json
85
+ // (e.g. by hand-editing in vim with backup file shenanigans) doesn't see
86
+ // the next reappear treated as a silent rebaseline.
87
+ let _initialized = false;
88
+
89
+ function _fire(list, arg, label) {
90
+ for (const cb of list) {
91
+ try { cb(arg); } catch (e) {
92
+ console.error(`[feishu-user-plugin] credentials-monitor ${label} hook threw: ${e.message}`);
93
+ }
94
+ }
95
+ }
96
+
97
+ function sync() {
98
+ const stat = _statSafely(credPath);
99
+ if (!stat) {
100
+ // File missing — early return. We don't reset state because the file
101
+ // may reappear and we want to diff against what we last saw.
102
+ lastMtimeMs = null; // force re-read on reappear (mtime will differ)
103
+ return;
104
+ }
105
+ // Cheap exit when mtime hasn't advanced.
106
+ if (lastMtimeMs !== null && stat.mtimeMs === lastMtimeMs) return;
107
+
108
+ const raw = _readSafely(credPath);
109
+ if (raw === null) { lastMtimeMs = stat.mtimeMs; return; }
110
+ const canonical = _parseSafely(raw);
111
+ if (!canonical) { lastMtimeMs = stat.mtimeMs; return; }
112
+
113
+ const env = _activeProfileEnv(canonical);
114
+ const active = canonical.active;
115
+ const uatHash = _fieldHash(env, 'LARK_USER_ACCESS_TOKEN');
116
+ const cookieHash = _fieldHash(env, 'LARK_COOKIE');
117
+ const refreshHash = _fieldHash(env, 'LARK_USER_REFRESH_TOKEN');
118
+
119
+ // First observation establishes baseline silently.
120
+ const baselining = !_initialized;
121
+ lastMtimeMs = stat.mtimeMs;
122
+ _initialized = true;
123
+
124
+ if (baselining) {
125
+ lastActive = active;
126
+ lastUatHash = uatHash;
127
+ lastCookieHash = cookieHash;
128
+ lastRefreshHash = refreshHash;
129
+ return;
130
+ }
131
+
132
+ let anyChange = false;
133
+ if (active !== lastActive) {
134
+ anyChange = true;
135
+ const prev = lastActive;
136
+ lastActive = active;
137
+ _fire(hooks.profile, { from: prev, to: active, env }, 'profile');
138
+ }
139
+ if (uatHash !== lastUatHash || refreshHash !== lastRefreshHash) {
140
+ anyChange = true;
141
+ lastUatHash = uatHash;
142
+ lastRefreshHash = refreshHash;
143
+ _fire(hooks.uat, env, 'uat');
144
+ }
145
+ if (cookieHash !== lastCookieHash) {
146
+ anyChange = true;
147
+ lastCookieHash = cookieHash;
148
+ _fire(hooks.cookie, env, 'cookie');
149
+ }
150
+ if (anyChange) {
151
+ _fire(hooks.invalidate, env, 'invalidate');
152
+ }
153
+ }
154
+
155
+ // Force-fire all hooks as if everything changed. Used by switch_profile
156
+ // when it wants to short-circuit the mtime debounce.
157
+ function forceInvalidate() {
158
+ const stat = _statSafely(credPath);
159
+ const raw = stat ? _readSafely(credPath) : null;
160
+ const canonical = raw ? _parseSafely(raw) : null;
161
+ const env = _activeProfileEnv(canonical) || {};
162
+ _fire(hooks.profile, { from: lastActive, to: canonical?.active, env }, 'profile');
163
+ _fire(hooks.uat, env, 'uat');
164
+ _fire(hooks.cookie, env, 'cookie');
165
+ _fire(hooks.invalidate, env, 'invalidate');
166
+ if (canonical) {
167
+ lastActive = canonical.active;
168
+ lastUatHash = _fieldHash(env, 'LARK_USER_ACCESS_TOKEN');
169
+ lastCookieHash = _fieldHash(env, 'LARK_COOKIE');
170
+ lastRefreshHash = _fieldHash(env, 'LARK_USER_REFRESH_TOKEN');
171
+ lastMtimeMs = stat.mtimeMs;
172
+ }
173
+ }
174
+
175
+ return {
176
+ sync,
177
+ forceInvalidate,
178
+ onUatChange: (cb) => { hooks.uat.push(cb); },
179
+ onCookieChange: (cb) => { hooks.cookie.push(cb); },
180
+ onProfileSwitch: (cb) => { hooks.profile.push(cb); },
181
+ onCacheInvalidate: (cb) => { hooks.invalidate.push(cb); },
182
+ };
183
+ }
184
+
185
+ module.exports = { createCredentialsMonitor };
@@ -0,0 +1,204 @@
1
+ // src/auth/identity-state.js — identity state machine for UAT-first/bot-fallback flows.
2
+ //
3
+ // The v1.3.7 pattern was `asUserOrApp` in src/auth/uat.js: try UAT, catch any
4
+ // failure, retry with bot, and attach a string `_fallbackWarning`. That works
5
+ // but is opaque: the caller can't tell whether UAT was revoked (need to
6
+ // re-oauth), expired (will refresh on next call), missing scope (need admin),
7
+ // or just experienced a network blip. The 2026-05 incident showed how this
8
+ // hides "UAT revoked for weeks" because every tool silently retried bot.
9
+ //
10
+ // This module adds a first-class IdentityState enum + a reactive cache. Each
11
+ // call to withIdentityFallback returns `{ data, via, viaReason, identity }`;
12
+ // LLMs and the get_login_status diagnostic can read the identity directly
13
+ // instead of grepping warning strings.
14
+ //
15
+ // Cache: keyed by appId (one entry per LarkOfficialClient instance is enough
16
+ // in practice — clients aren't shared across appIds in this codebase, but
17
+ // keying by appId is the safe contract). 30 second TTL. Refined writes from
18
+ // withIdentityFallback bypass TTL and write through immediately.
19
+ //
20
+ // What it owns:
21
+ // - IdentityState enum
22
+ // - resolveIdentity(client) — reads in-memory state into a state value
23
+ // - withIdentityFallback({client, uatFn, botFn, label}) — composable wrapper
24
+ // - invalidateIdentity(client) — cache eviction (CredentialsMonitor hook)
25
+
26
+ 'use strict';
27
+
28
+ const IdentityState = Object.freeze({
29
+ VALID_USER: 'VALID_USER',
30
+ UAT_EXPIRED: 'UAT_EXPIRED',
31
+ UAT_REVOKED: 'UAT_REVOKED',
32
+ UAT_MISSING_SCOPE: 'UAT_MISSING_SCOPE',
33
+ BOT_ONLY: 'BOT_ONLY',
34
+ NO_CREDENTIALS: 'NO_CREDENTIALS',
35
+ });
36
+
37
+ const CACHE_TTL_MS = 30_000;
38
+ const _cache = new Map(); // key = client.appId — value = { state, expiresAt }
39
+
40
+ function _key(client) {
41
+ // Fall back to a sentinel for clients without appId so NO_CREDENTIALS is still cacheable.
42
+ return client?.appId || '__no_app__';
43
+ }
44
+
45
+ function _readInMemoryState(client) {
46
+ if (!client) return IdentityState.NO_CREDENTIALS;
47
+ const hasApp = !!client.appId;
48
+ const hasUAT = !!client.hasUAT;
49
+ if (!hasUAT && !hasApp) return IdentityState.NO_CREDENTIALS;
50
+ if (!hasUAT) return IdentityState.BOT_ONLY;
51
+ // hasUAT true — distinguish VALID_USER vs UAT_EXPIRED by expiry timestamp.
52
+ // expires=0 means "we never decoded — treat as valid and let the refresh
53
+ // path decide". Negative expiry never occurs but is treated as expired.
54
+ const now = Math.floor(Date.now() / 1000);
55
+ if (client._uatExpires && client._uatExpires > 0 && client._uatExpires <= now) {
56
+ return IdentityState.UAT_EXPIRED;
57
+ }
58
+ return IdentityState.VALID_USER;
59
+ }
60
+
61
+ async function resolveIdentity(client) {
62
+ const k = _key(client);
63
+ const cached = _cache.get(k);
64
+ if (cached && cached.expiresAt > Date.now()) return cached.state;
65
+ const state = _readInMemoryState(client);
66
+ _cache.set(k, { state, expiresAt: Date.now() + CACHE_TTL_MS });
67
+ return state;
68
+ }
69
+
70
+ function invalidateIdentity(client) {
71
+ _cache.delete(_key(client));
72
+ }
73
+
74
+ // Write a refined state through to the cache (bypasses TTL). Called when
75
+ // withIdentityFallback observes a definitive UAT failure (revoked, missing
76
+ // scope) — the cache should reflect what we just learned, not what was
77
+ // inferred from in-memory state alone.
78
+ function _refineIdentity(client, state) {
79
+ _cache.set(_key(client), { state, expiresAt: Date.now() + CACHE_TTL_MS });
80
+ }
81
+
82
+ // Classify a UAT-side failure into a refined IdentityState + a human-readable
83
+ // via_reason string. Returns null if the failure isn't auth-related (caller
84
+ // should keep the original VALID_USER state and just record the via_reason).
85
+ function _classifyUatFailure(uatResp, uatError) {
86
+ if (uatError) {
87
+ const msg = uatError.message || String(uatError);
88
+ // Network/JSON parse errors don't refine identity — UAT is still presumed
89
+ // valid, we just couldn't reach Feishu this call.
90
+ return { state: null, viaReason: `as user: ${msg}` };
91
+ }
92
+ if (!uatResp || typeof uatResp !== 'object') return null;
93
+ const code = uatResp.code;
94
+ const detail = `as user: code=${code} msg=${uatResp.msg}`;
95
+ switch (code) {
96
+ case 20064: return { state: IdentityState.UAT_REVOKED, viaReason: detail };
97
+ case 99991663: return { state: IdentityState.UAT_EXPIRED, viaReason: detail };
98
+ case 99991668: return { state: IdentityState.UAT_MISSING_SCOPE, viaReason: detail };
99
+ case 99991677: return { state: IdentityState.UAT_EXPIRED, viaReason: detail };
100
+ default: return { state: null, viaReason: detail };
101
+ }
102
+ }
103
+
104
+ function _buildFallbackWarning({ identity, viaReason, hadUAT }) {
105
+ if (!hadUAT) {
106
+ // BOT_ONLY — the caller never configured UAT. Strictly speaking this
107
+ // isn't a "fallback" (no UAT attempt was made), but we keep the
108
+ // informational warning because users frequently *think* they configured
109
+ // UAT and are surprised to find resources owned by the shared bot. Same
110
+ // wording as the legacy asUserOrApp path so existing get_login_status
111
+ // expectations don't shift.
112
+ return `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
113
+ }
114
+ let hint;
115
+ switch (identity) {
116
+ case IdentityState.UAT_REVOKED:
117
+ hint = 'UAT 已被撤销 (invalid_grant)';
118
+ break;
119
+ case IdentityState.UAT_MISSING_SCOPE:
120
+ hint = 'UAT 缺少所需 scope';
121
+ break;
122
+ case IdentityState.UAT_EXPIRED:
123
+ hint = 'UAT 已过期';
124
+ break;
125
+ default:
126
+ hint = 'UAT 不可用';
127
+ break;
128
+ }
129
+ return `⚠️ ${hint} (${viaReason}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
130
+ }
131
+
132
+ // withIdentityFallback({ client, uatFn, botFn, label })
133
+ //
134
+ // Tries uatFn first (when client has UAT), classifies any failure, then runs
135
+ // botFn. Returns `{ data, via, viaReason?, identity, fallbackWarning? }`.
136
+ // Throws an Error with `.uatSummary` and `.botError` when both sides fail.
137
+ async function withIdentityFallback({ client, uatFn, botFn, label }) {
138
+ if (!label) throw new Error('withIdentityFallback: label is required (for error messages)');
139
+
140
+ let identity = await resolveIdentity(client);
141
+ const hadUAT = !!client?.hasUAT;
142
+ let uatSummary = null;
143
+
144
+ if (hadUAT) {
145
+ let uatResp = null;
146
+ let uatErr = null;
147
+ try {
148
+ uatResp = await uatFn();
149
+ } catch (e) {
150
+ uatErr = e;
151
+ }
152
+ if (uatResp && uatResp.code === 0) {
153
+ const data = { ...uatResp };
154
+ return { data, via: 'uat', identity };
155
+ }
156
+ const cls = _classifyUatFailure(uatResp, uatErr);
157
+ uatSummary = cls?.viaReason || `as user: unknown failure`;
158
+ if (cls?.state) {
159
+ identity = cls.state;
160
+ _refineIdentity(client, cls.state);
161
+ }
162
+ // fall through to bot
163
+ }
164
+
165
+ // Bot path
166
+ let botData;
167
+ let botError;
168
+ try {
169
+ botData = await botFn();
170
+ } catch (e) {
171
+ botError = e;
172
+ }
173
+
174
+ if (botError) {
175
+ if (uatSummary) {
176
+ const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${botError.message}`);
177
+ err.uatSummary = uatSummary;
178
+ err.botError = botError;
179
+ err.identity = identity;
180
+ throw err;
181
+ }
182
+ throw botError;
183
+ }
184
+
185
+ // Decorate the bot response with via metadata + (when UAT was attempted) a
186
+ // fallback warning the caller can surface to the LLM.
187
+ const data = { ...botData };
188
+ data._viaUser = false;
189
+ const fallbackWarning = _buildFallbackWarning({ identity, viaReason: uatSummary, hadUAT });
190
+ if (fallbackWarning) data._fallbackWarning = fallbackWarning;
191
+ const out = { data, via: 'bot', identity };
192
+ if (uatSummary) out.viaReason = uatSummary;
193
+ if (fallbackWarning) out.fallbackWarning = fallbackWarning;
194
+ return out;
195
+ }
196
+
197
+ module.exports = {
198
+ IdentityState,
199
+ resolveIdentity,
200
+ withIdentityFallback,
201
+ invalidateIdentity,
202
+ _refineIdentity, // exported for D's CredentialsMonitor hook (private API)
203
+ _readInMemoryState, // exported for testing edge cases (private API)
204
+ };
package/src/auth/uat.js CHANGED
@@ -9,9 +9,16 @@
9
9
  // - decodeTokenExpiry(token) — JWT exp parsing
10
10
  // - getValidUAT(client) — returns current UAT, refreshes if expiring
11
11
  // - refreshUAT(client) — full refresh dance with file lock + persist
12
- // - withUAT(client, fn) — wrapper that retries fn once on auth-error codes
12
+ // - withUAT(client, fn) — wrapper that retries fn once on auth codes + on
13
+ // transient throws (classifyError action='retry'); v1.3.12 widening
13
14
  // - uatREST(client, method, path, opts) — generic UAT REST helper
14
- // - asUserOrApp(client, opts) — UAT-first, bot-fallback wrapper
15
+ // - asUserOrApp(client, opts) — legacy UAT-first / bot-fallback signature.
16
+ // v1.3.12: the body is now a thin shape adapter around
17
+ // withIdentityFallback (src/auth/identity-state.js). The public contract
18
+ // — return data with _viaUser ∈ {true,false} + optional _fallbackWarning,
19
+ // throw Error with .uatSummary + .appError on dual failure — is
20
+ // preserved so 15+ existing callsites in calendar/docs/bitable/wiki/okr/
21
+ // tasks/drive/im keep compiling.
15
22
  // - persistUAT(client) — writes through auth/credentials
16
23
  // - adoptPersistedUATIfNewer(client) — peer-rotation adoption
17
24
  // - acquireRefreshLock / releaseRefreshLock — cross-process advisory lock
@@ -144,8 +151,24 @@ function persistUAT(client) {
144
151
  }
145
152
 
146
153
  async function withUAT(client, fn) {
154
+ const { classifyError } = require('../error-codes');
147
155
  let uat = await getValidUAT(client);
148
- const data = await fn(uat);
156
+
157
+ // First attempt. If fn() throws an upstream flake (network reset, response
158
+ // body truncated mid-JSON, gateway 5xx), classifyError says action='retry'
159
+ // and we re-run once with the same UAT — the token is still valid, the
160
+ // call just lost. Auth-related codes are the existing refresh path below.
161
+ let data;
162
+ try {
163
+ data = await fn(uat);
164
+ } catch (err) {
165
+ const cls = classifyError(err);
166
+ if (cls.action === 'retry') {
167
+ return fn(uat);
168
+ }
169
+ throw err;
170
+ }
171
+
149
172
  if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
150
173
  if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
151
174
  return data;
@@ -182,40 +205,31 @@ async function uatREST(client, method, urlPath, { body, query } = {}) {
182
205
  }
183
206
 
184
207
  async function asUserOrApp(client, { uatPath, method = 'GET', body, query, sdkFn, label }) {
185
- let uatSummary = null;
186
- if (client.hasUAT) {
187
- try {
188
- const data = await uatREST(client, method, uatPath, { body, query });
189
- if (data.code === 0) {
190
- data._viaUser = true;
191
- return data;
192
- }
193
- uatSummary = `as user: code=${data.code} msg=${data.msg}`;
194
- console.error(`[feishu-user-plugin] ${label} ${uatSummary}, retrying as app`);
195
- } catch (err) {
196
- uatSummary = `as user: ${err.message}`;
197
- console.error(`[feishu-user-plugin] ${label} as user threw (${err.message}), retrying as app`);
198
- }
199
- }
208
+ // v1.3.12: internal implementation routes through withIdentityFallback in
209
+ // src/auth/identity-state.js. The public shape — return data with _viaUser
210
+ // and optional _fallbackWarning, throw Error with .uatSummary + .appError
211
+ // when both sides fail is preserved for 15+ existing callsites.
212
+ const { withIdentityFallback } = require('./identity-state');
200
213
  try {
201
- const appData = await client._safeSDKCall(sdkFn, label);
202
- if (appData && typeof appData === 'object') {
203
- appData._viaUser = false;
204
- if (uatSummary) {
205
- appData._fallbackWarning = `⚠️ UAT 不可用 (${uatSummary}),本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。恢复方法:运行 \`npx feishu-user-plugin oauth\` 后重启 Claude Code / Codex。`;
206
- } else if (!client.hasUAT) {
207
- appData._fallbackWarning = `⚠️ 未配置 UAT,本次操作以 bot 身份执行。资源归属于共享 bot「Claude聊天助手」,不是你。想让资源归你所有,先跑 \`npx feishu-user-plugin oauth\` 然后重启 Claude Code / Codex。`;
208
- }
209
- }
210
- return appData;
211
- } catch (appErr) {
212
- if (uatSummary) {
213
- const err = new Error(`${label} failed on both identities. ${uatSummary}. as app: ${appErr.message}`);
214
- err.uatSummary = uatSummary;
215
- err.appError = appErr;
216
- throw err;
214
+ const result = await withIdentityFallback({
215
+ client,
216
+ uatFn: () => uatREST(client, method, uatPath, { body, query }),
217
+ botFn: () => client._safeSDKCall(sdkFn, label),
218
+ label,
219
+ });
220
+ // Surface state machine breadcrumbs in stderr so long-running servers can
221
+ // be diagnosed without grepping the LLM transcript. (Pre-v1.3.12 already
222
+ // logged on fallback; we keep parity but only when fallback actually
223
+ // happened, not on every BOT_ONLY call.)
224
+ if (result.via === 'bot' && result.viaReason) {
225
+ console.error(`[feishu-user-plugin] ${label} fell back to bot (${result.identity}): ${result.viaReason}`);
217
226
  }
218
- throw appErr;
227
+ return result.data;
228
+ } catch (e) {
229
+ // Legacy callers expect err.appError — keep the alias alongside the new
230
+ // err.botError that withIdentityFallback sets.
231
+ if (e && e.botError && !e.appError) e.appError = e.botError;
232
+ throw e;
219
233
  }
220
234
  }
221
235