feishu-user-plugin 1.3.10 → 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 (61) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.cursor-plugin/plugin.json +27 -0
  3. package/.mcpb/manifest.json +91 -0
  4. package/CHANGELOG.md +118 -0
  5. package/PRIVACY.md +105 -0
  6. package/README.en.md +130 -413
  7. package/README.md +88 -258
  8. package/package.json +5 -3
  9. package/scripts/build-mcpb.js +119 -0
  10. package/scripts/check-description-drift.js +73 -0
  11. package/scripts/check-docs-sync.js +7 -16
  12. package/scripts/check-mcp-registry-version.js +43 -0
  13. package/scripts/check-mcpb-version.js +33 -0
  14. package/scripts/check-scopes.js +99 -0
  15. package/scripts/check-tool-count.js +4 -3
  16. package/scripts/check-version.js +5 -0
  17. package/scripts/sync-claude-md.sh +3 -4
  18. package/scripts/sync-team-skills.sh +72 -57
  19. package/scripts/verify-app-name.js +64 -0
  20. package/skills/feishu-user-plugin/SKILL.md +3 -3
  21. package/skills/feishu-user-plugin/references/search.md +3 -3
  22. package/src/auth/credentials-monitor.js +185 -0
  23. package/src/auth/credentials.js +49 -0
  24. package/src/auth/identity-state.js +204 -0
  25. package/src/auth/lark-desktop.js +135 -0
  26. package/src/auth/uat.js +49 -35
  27. package/src/cli.js +87 -0
  28. package/src/clients/official/base.js +145 -14
  29. package/src/clients/official/calendar.js +3 -1
  30. package/src/clients/official/im.js +76 -2
  31. package/src/clients/official/okr.js +2 -1
  32. package/src/error-codes.js +40 -0
  33. package/src/events/lockfile.js +40 -4
  34. package/src/events/owner.js +11 -2
  35. package/src/index.js +1 -1
  36. package/src/logger.js +11 -5
  37. package/src/oauth.js +46 -10
  38. package/src/server.js +102 -37
  39. package/src/setup.js +44 -0
  40. package/src/test-all.js +40 -0
  41. package/src/test-cli-tool.js +87 -0
  42. package/src/test-credentials-monitor.js +124 -0
  43. package/src/test-display-label.js +88 -0
  44. package/src/test-error-codes.js +85 -0
  45. package/src/test-identity-state.js +172 -0
  46. package/src/test-lark-desktop.js +300 -0
  47. package/src/test-lockfile-pid.js +90 -0
  48. package/src/test-lru-cache.js +145 -0
  49. package/src/test-negative-cache.js +85 -0
  50. package/src/test-populate-sender-names.js +98 -0
  51. package/src/test-search-messages.js +101 -0
  52. package/src/test-send-shape.js +115 -0
  53. package/src/test-via-user.js +94 -0
  54. package/src/test-with-uat-retry.js +135 -0
  55. package/src/tools/_registry.js +24 -1
  56. package/src/tools/calendar.js +5 -5
  57. package/src/tools/im-read.js +52 -4
  58. package/src/tools/messaging-user.js +1 -1
  59. package/src/utils.js +83 -0
  60. package/scripts/generate-og-image.js +0 -39
  61. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
@@ -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 };
@@ -360,6 +360,51 @@ function setProfileEvents(name, eventList) {
360
360
  return true;
361
361
  }
362
362
 
363
+ // --- Lark Desktop hash bindings (v1.3.11 §A) ---
364
+ //
365
+ // Each profile may carry an optional `larkHash` 32-char-hex field binding it
366
+ // to one of `~/Library/Containers/com.bytedance.macos.feishu/.../sdk_storage/<hash>/`.
367
+ // The owner heartbeat reactor uses this binding to decide which profile to
368
+ // switch to when the user changes account in Lark Desktop. See
369
+ // src/auth/lark-desktop.js for the detection / switch logic; this module
370
+ // just owns the persisted binding.
371
+
372
+ const _LARK_HASH_RE = /^[a-f0-9]{32}$/;
373
+
374
+ function getProfileLarkHash(name) {
375
+ const f = _readFile();
376
+ const target = name || (f ? f.active : 'default');
377
+ if (f && f.profiles[target] && typeof f.profiles[target].larkHash === 'string') {
378
+ return f.profiles[target].larkHash;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ function setProfileLarkHash(name, hash) {
384
+ if (hash !== null && (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash))) {
385
+ throw new Error('setProfileLarkHash: hash must be 32-char hex (a-f, 0-9) or null');
386
+ }
387
+ const f = _readFile();
388
+ if (!f) throw new Error('No credentials.json — cannot set profile larkHash. Run `npx feishu-user-plugin migrate --confirm` first.');
389
+ if (!f.profiles[name]) {
390
+ throw new Error(`setProfileLarkHash: profile "${name}" not found. Available: ${Object.keys(f.profiles).join(', ')}`);
391
+ }
392
+ if (hash === null) delete f.profiles[name].larkHash;
393
+ else f.profiles[name].larkHash = hash;
394
+ _atomicWriteJson(_credentialsPath(), f);
395
+ return true;
396
+ }
397
+
398
+ function findProfileByHash(hash) {
399
+ if (typeof hash !== 'string' || !_LARK_HASH_RE.test(hash)) return null;
400
+ const f = _readFile();
401
+ if (!f) return null;
402
+ for (const [name, profile] of Object.entries(f.profiles)) {
403
+ if (profile.larkHash === hash) return name;
404
+ }
405
+ return null;
406
+ }
407
+
363
408
  // --- Profile hints (v1.3.8) ---
364
409
  //
365
410
  // profileHints maps resourceKey → profileName, persisted in credentials.json.
@@ -419,6 +464,10 @@ module.exports = {
419
464
  // per-profile events list (v1.3.9 A.4)
420
465
  getProfileEvents,
421
466
  setProfileEvents,
467
+ // Lark Desktop hash bindings (v1.3.11 §A)
468
+ getProfileLarkHash,
469
+ setProfileLarkHash,
470
+ findProfileByHash,
422
471
  // profile hints (v1.3.8)
423
472
  getProfileHints,
424
473
  setProfileHint,
@@ -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
+ };
@@ -0,0 +1,135 @@
1
+ // src/auth/lark-desktop.js — Lark Desktop sdk_storage detection (v1.3.11 §A).
2
+ //
3
+ // macOS-only: Linux/Windows return null from getSdkStorageDir() and all
4
+ // callers no-op gracefully. We never read the encrypted cookie_store.db —
5
+ // only stat its mtime to detect account switches. Profile↔hash bindings
6
+ // live in credentials.json::profiles[*].larkHash.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const HASH_RE = /^[a-f0-9]{32}$/;
15
+
16
+ // Debounce + freshness windows for the heartbeat reactor.
17
+ const SWITCH_DEBOUNCE_MS = 5_000;
18
+ const UNBOUND_FRESH_WINDOW_MS = 60_000;
19
+
20
+ function _macSdkStorageDir() {
21
+ return path.join(
22
+ os.homedir(),
23
+ 'Library/Containers/com.bytedance.macos.feishu/Data/Library/Application Support/LarkShell/sdk_storage'
24
+ );
25
+ }
26
+
27
+ function getSdkStorageDir() {
28
+ if (process.platform !== 'darwin') return null;
29
+ const dir = _macSdkStorageDir();
30
+ try {
31
+ return fs.statSync(dir).isDirectory() ? dir : null;
32
+ } catch (_) {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // List Lark account hash directories under sdk_storage, sorted by
38
+ // cookie_store.db mtime descending. Hash dirs without a cookie_store.db
39
+ // are filtered (account never logged in / cleared).
40
+ //
41
+ // Returns: [{ hash, mtimeMs, dir }]
42
+ function listAccountHashes({ dir } = {}) {
43
+ const root = dir || getSdkStorageDir();
44
+ if (!root) return [];
45
+ let entries;
46
+ try { entries = fs.readdirSync(root); } catch (_) { return []; }
47
+ const out = [];
48
+ for (const name of entries) {
49
+ if (!HASH_RE.test(name)) continue;
50
+ const accountDir = path.join(root, name);
51
+ const dbPath = path.join(accountDir, 'cookie_store.db');
52
+ let mtimeMs;
53
+ try { mtimeMs = fs.statSync(dbPath).mtimeMs; } catch (_) { continue; }
54
+ out.push({ hash: name, mtimeMs, dir: accountDir });
55
+ }
56
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
57
+ return out;
58
+ }
59
+
60
+ function mostRecentHash(opts = {}) {
61
+ const list = listAccountHashes(opts);
62
+ return list.length > 0 ? list[0] : null;
63
+ }
64
+
65
+ // Pure-ish reactor logic, dependency-injected for unit tests.
66
+ //
67
+ // Inputs:
68
+ // prevSnapshot: { [hash]: mtimeMs } from the previous heartbeat
69
+ // lastSwitchAt: ms timestamp of the last auto-switch (debounce key)
70
+ // seenUnboundHashes: Set<hash> — emit hint once per hash per session
71
+ // credsApi: { getActiveProfileName, getProfileLarkHash, findProfileByHash }
72
+ // listFn: () => [...] — defaults to listAccountHashes() with auto-detected dir
73
+ // now: ms — defaults to Date.now()
74
+ // log: (msg) => void — defaults to console.error (the unbound-hash hint goes here)
75
+ //
76
+ // Returns:
77
+ // { switchTo: { hash, profile } | null, isUnbound: boolean, hash?: string }
78
+ //
79
+ // Mutates seenUnboundHashes (adds the hash when it emits a hint).
80
+ function detectSwitch({
81
+ prevSnapshot,
82
+ lastSwitchAt,
83
+ seenUnboundHashes,
84
+ credsApi,
85
+ listFn,
86
+ now,
87
+ log,
88
+ } = {}) {
89
+ if (!credsApi) credsApi = require('./credentials');
90
+ if (!listFn) listFn = () => listAccountHashes();
91
+ if (typeof now !== 'number') now = Date.now();
92
+ if (typeof log !== 'function') log = console.error;
93
+
94
+ if (now - lastSwitchAt < SWITCH_DEBOUNCE_MS) {
95
+ return { switchTo: null, isUnbound: false };
96
+ }
97
+
98
+ const list = listFn();
99
+ if (list.length === 0) return { switchTo: null, isUnbound: false };
100
+
101
+ const top = list[0];
102
+ const activeProfile = credsApi.getActiveProfileName();
103
+ const activeHash = credsApi.getProfileLarkHash(activeProfile);
104
+ if (top.hash === activeHash) return { switchTo: null, isUnbound: false };
105
+
106
+ // Only act on a true mtime advance — this prevents repeatedly switching
107
+ // when the snapshot baseline shows a stable older delta.
108
+ const prev = prevSnapshot[top.hash] || 0;
109
+ if (top.mtimeMs <= prev) return { switchTo: null, isUnbound: false };
110
+
111
+ const targetProfile = credsApi.findProfileByHash(top.hash);
112
+ if (!targetProfile) {
113
+ const isFresh = (now - top.mtimeMs) < UNBOUND_FRESH_WINDOW_MS;
114
+ if (isFresh && seenUnboundHashes && !seenUnboundHashes.has(top.hash)) {
115
+ seenUnboundHashes.add(top.hash);
116
+ log(
117
+ `[feishu-user-plugin] Lark Desktop active account hash ${top.hash} is not bound to any MCP profile. ` +
118
+ `Run: npx feishu-user-plugin setup --profile <name> --bind-hash ${top.hash}`
119
+ );
120
+ }
121
+ return { switchTo: null, isUnbound: true, hash: top.hash };
122
+ }
123
+
124
+ return { switchTo: { hash: top.hash, profile: targetProfile }, isUnbound: false };
125
+ }
126
+
127
+ module.exports = {
128
+ HASH_RE,
129
+ SWITCH_DEBOUNCE_MS,
130
+ UNBOUND_FRESH_WINDOW_MS,
131
+ getSdkStorageDir,
132
+ listAccountHashes,
133
+ mostRecentHash,
134
+ detectSwitch,
135
+ };