feishu-user-plugin 1.3.7 → 1.3.9

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 (54) hide show
  1. package/.claude-plugin/plugin.json +13 -3
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +20 -4
  4. package/package.json +10 -6
  5. package/proto/lark.proto +10 -0
  6. package/scripts/capture-feishu-protobuf.js +86 -0
  7. package/scripts/check-changelog.js +31 -0
  8. package/scripts/check-docs-sync.js +41 -0
  9. package/scripts/check-tool-count.js +32 -7
  10. package/scripts/decode-feishu-protobuf.js +115 -0
  11. package/scripts/explore-card-protobuf.js +144 -0
  12. package/scripts/explore-image-minimize.js +163 -0
  13. package/scripts/generate-release-artifacts.js +318 -0
  14. package/scripts/probe-feishu-docx.js +203 -0
  15. package/scripts/sync-server-json.js +71 -0
  16. package/scripts/sync-team-skills.sh +109 -7
  17. package/scripts/test-wiki-attach-fallback.js +71 -0
  18. package/scripts/test-ws-events.js +84 -0
  19. package/skills/feishu-user-plugin/SKILL.md +77 -5
  20. package/skills/feishu-user-plugin/references/CLAUDE.md +208 -297
  21. package/src/auth/cookie.js +30 -0
  22. package/src/auth/credentials.js +85 -0
  23. package/src/auth/profile-router.js +248 -0
  24. package/src/auth/uat.js +231 -0
  25. package/src/cli.js +86 -42
  26. package/src/clients/official/base.js +12 -248
  27. package/src/clients/user.js +19 -31
  28. package/src/config.js +13 -8
  29. package/src/events/cursor.js +103 -0
  30. package/src/events/event-buffer.js +103 -0
  31. package/src/events/event-log.js +151 -0
  32. package/src/events/index.js +12 -0
  33. package/src/events/lockfile.js +126 -0
  34. package/src/events/owner.js +73 -0
  35. package/src/events/ws-server.js +156 -0
  36. package/src/oauth.js +48 -7
  37. package/src/resolver.js +10 -0
  38. package/src/server.js +285 -3
  39. package/src/setup.js +100 -11
  40. package/src/test-all.js +12 -9
  41. package/src/test-events-cursor.js +56 -0
  42. package/src/test-events-lockfile.js +36 -0
  43. package/src/test-events-log.js +67 -0
  44. package/src/test-events-owner.js +64 -0
  45. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  46. package/src/test-read-doc-markdown.js +61 -0
  47. package/src/test-switch-profile.js +171 -0
  48. package/src/tools/_registry.js +1 -0
  49. package/src/tools/diagnostics.js +10 -3
  50. package/src/tools/docs.js +93 -3
  51. package/src/tools/events.js +174 -0
  52. package/src/tools/messaging-bot.js +2 -3
  53. package/src/tools/messaging-user.js +23 -14
  54. package/src/tools/profile.js +43 -7
@@ -0,0 +1,30 @@
1
+ // src/auth/cookie.js — cookie heartbeat scheduler.
2
+ //
3
+ // State lives on the LarkUserClient instance (this.cookieStr, this._heartbeatTimer).
4
+ // We expose start/stop functions that take `client` and mutate the timer field.
5
+ // Lifted out of clients/user.js for clarity; called only from there.
6
+
7
+ const HEARTBEAT_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours — sl_session has 12h max
8
+
9
+ function startHeartbeat(client) {
10
+ client._heartbeatTimer = setInterval(async () => {
11
+ try {
12
+ await client._getCsrfToken();
13
+ const { persistToConfig } = require('./credentials');
14
+ persistToConfig({ LARK_COOKIE: client.cookieStr });
15
+ console.error('[feishu-user-plugin] Cookie heartbeat: session refreshed and persisted');
16
+ } catch (e) {
17
+ console.error('[feishu-user-plugin] Cookie heartbeat failed:', e.message);
18
+ }
19
+ }, HEARTBEAT_INTERVAL_MS);
20
+ if (client._heartbeatTimer.unref) client._heartbeatTimer.unref();
21
+ }
22
+
23
+ function stopHeartbeat(client) {
24
+ if (client._heartbeatTimer) {
25
+ clearInterval(client._heartbeatTimer);
26
+ client._heartbeatTimer = null;
27
+ }
28
+ }
29
+
30
+ module.exports = { startHeartbeat, stopHeartbeat, HEARTBEAT_INTERVAL_MS };
@@ -327,6 +327,84 @@ function migrate({ dryRun = true } = {}) {
327
327
  return { ok: true, credentials };
328
328
  }
329
329
 
330
+ // --- Per-profile events list (v1.3.9 A.4) ---
331
+ //
332
+ // Each profile may optionally declare an `events` array listing the Feishu
333
+ // real-time event types to subscribe to. When absent the default
334
+ // `["im.message.receive_v1"]` is used. This array is read by
335
+ // `_getProfileEventsList` in server.js to configure the WebSocket client.
336
+ //
337
+ // Example credentials.json profile entry with events:
338
+ // "default": {
339
+ // "LARK_APP_ID": "...",
340
+ // ...,
341
+ // "events": ["im.message.receive_v1", "approval.instance.created_v4"]
342
+ // }
343
+
344
+ function getProfileEvents(name) {
345
+ const f = _readFile();
346
+ const target = name || (f ? f.active : 'default');
347
+ if (f && f.profiles[target] && Array.isArray(f.profiles[target].events)) {
348
+ return f.profiles[target].events.slice();
349
+ }
350
+ return ['im.message.receive_v1'];
351
+ }
352
+
353
+ function setProfileEvents(name, eventList) {
354
+ if (!Array.isArray(eventList)) throw new Error('setProfileEvents: eventList must be an array');
355
+ const f = _readFile();
356
+ if (!f) throw new Error('No credentials.json — cannot set profile events.');
357
+ if (!f.profiles[name]) throw new Error(`Profile "${name}" not found.`);
358
+ f.profiles[name].events = eventList.slice();
359
+ _atomicWriteJson(_credentialsPath(), f);
360
+ return true;
361
+ }
362
+
363
+ // --- Profile hints (v1.3.8) ---
364
+ //
365
+ // profileHints maps resourceKey → profileName, persisted in credentials.json.
366
+ // Used by src/auth/profile-router.js to remember which profile owns / has
367
+ // access to a given resource. Reads pass through; writes are atomic.
368
+ //
369
+ // resourceKey format: "<kind>:<token>", e.g. "doc:doccnXXX" or "chat:oc_zzz".
370
+ // Caller is responsible for canonicalising tokens (resolveDocId etc).
371
+
372
+ function getProfileHints() {
373
+ const f = _readFile();
374
+ if (!f) return {};
375
+ return { ...f.profileHints };
376
+ }
377
+
378
+ function setProfileHint(resourceKey, profileName) {
379
+ if (typeof resourceKey !== 'string' || !resourceKey) {
380
+ throw new Error('setProfileHint: resourceKey must be a non-empty string');
381
+ }
382
+ const f = _readFile();
383
+ if (!f) return false;
384
+ if (!f.profiles[profileName]) {
385
+ throw new Error(`setProfileHint: profile "${profileName}" not in credentials.json. Known: ${Object.keys(f.profiles).join(', ')}`);
386
+ }
387
+ if (f.profileHints[resourceKey] === profileName) return true;
388
+ f.profileHints[resourceKey] = profileName;
389
+ _atomicWriteJson(_credentialsPath(), f);
390
+ return true;
391
+ }
392
+
393
+ function clearProfileHint(resourceKey) {
394
+ const f = _readFile();
395
+ if (!f) return false;
396
+ if (resourceKey === undefined) {
397
+ if (Object.keys(f.profileHints).length === 0) return true;
398
+ f.profileHints = {};
399
+ _atomicWriteJson(_credentialsPath(), f);
400
+ return true;
401
+ }
402
+ if (!(resourceKey in f.profileHints)) return false;
403
+ delete f.profileHints[resourceKey];
404
+ _atomicWriteJson(_credentialsPath(), f);
405
+ return true;
406
+ }
407
+
330
408
  // --- Re-exports for back-compat ---
331
409
 
332
410
  module.exports = {
@@ -338,6 +416,13 @@ module.exports = {
338
416
  setActiveProfile,
339
417
  persistProfileUpdate,
340
418
  migrate,
419
+ // per-profile events list (v1.3.9 A.4)
420
+ getProfileEvents,
421
+ setProfileEvents,
422
+ // profile hints (v1.3.8)
423
+ getProfileHints,
424
+ setProfileHint,
425
+ clearProfileHint,
341
426
  // back-compat with src/config
342
427
  readCredentials,
343
428
  persistToConfig,
@@ -0,0 +1,248 @@
1
+ // src/auth/profile-router.js — multi-profile auto-switch middleware (v1.3.8).
2
+ //
3
+ // Wraps the MCP CallToolRequestSchema dispatcher. For READ-ONLY tools that
4
+ // fail with a permission-denied / forbidden error, we transparently retry the
5
+ // call with another profile (if more than one is configured) and remember the
6
+ // winner via credentials.profileHints.
7
+ //
8
+ // Intentionally DOES NOT touch write paths. A user wanting auto-switch on a
9
+ // write must opt in explicitly: pass `via_profile: "auto"` in the tool args.
10
+ //
11
+ // What this owns:
12
+ // - READ_ONLY_TOOLS whitelist (name prefix + manage_bitable_* read-action override)
13
+ // - SWITCH_TRIGGERING_PATTERNS (error code / message regex set)
14
+ // - extractResourceKey(name, args) — resourceKey for hinting
15
+ // - profileOrder(name, args, hints, ctx) — ordered list of profiles to try
16
+ // - withProfileRouting(ctx, name, args, callHandler) — the main wrapper
17
+ //
18
+ // What it does NOT own:
19
+ // - The actual setActiveProfile / cache-invalidate (delegated to ctx).
20
+ // - Persistence of hints (delegates to auth/credentials).
21
+
22
+ const credentials = require('./credentials');
23
+
24
+ // --- Whitelist ---
25
+
26
+ const READ_ONLY_PREFIXES = ['read_', 'list_', 'get_', 'search_', 'download_'];
27
+
28
+ // Explicit allowlist for manage_*/check_* tools whose action arg is read-only.
29
+ // Each entry: tool name → predicate(args) returns true if THIS call is read-only.
30
+ const READ_ONLY_OVERRIDES = {
31
+ manage_bitable_app: (a) => a?.action === 'get_meta',
32
+ manage_bitable_table: (a) => a?.action === 'list',
33
+ manage_bitable_field: (a) => a?.action === 'list',
34
+ manage_bitable_view: (a) => a?.action === 'list',
35
+ manage_bitable_record: (a) => a?.action === 'search',
36
+ };
37
+
38
+ function isReadOnlyCall(name, args) {
39
+ if (READ_ONLY_PREFIXES.some(p => name.startsWith(p))) return true;
40
+ const override = READ_ONLY_OVERRIDES[name];
41
+ if (override && override(args)) return true;
42
+ return false;
43
+ }
44
+
45
+ // --- Error classification ---
46
+
47
+ const SWITCH_CODES = new Set([
48
+ 91403, // wiki / docx no permission
49
+ 1254301, // bitable no permission
50
+ 1254000, // bitable access denied
51
+ 99991672, // OAuth scope not granted (user-side)
52
+ 403, // generic HTTP forbidden
53
+ ]);
54
+
55
+ const SWITCH_PATTERNS = [
56
+ /access[_ ]?denied/i,
57
+ /permission[_ ]?denied/i,
58
+ /docx_no_permission/i,
59
+ /no permission/i,
60
+ /not authorized/i,
61
+ /forbidden/i,
62
+ /HTTP 403/i,
63
+ ];
64
+
65
+ function shouldSwitchOnError(err) {
66
+ const msg = (err?.message || String(err) || '').toLowerCase();
67
+ // Code-form: "(91403)", "code=1254301", "(HTTP 403, ...)"
68
+ const codeMatch = msg.match(/\b(?:code[=(]|http\s+)(\d+)/i) || msg.match(/\((\d{3,7})(?:,|\))/);
69
+ if (codeMatch) {
70
+ const code = parseInt(codeMatch[1], 10);
71
+ if (SWITCH_CODES.has(code)) return { yes: true, reason: `code=${code}` };
72
+ }
73
+ for (const re of SWITCH_PATTERNS) {
74
+ if (re.test(msg)) return { yes: true, reason: re.source };
75
+ }
76
+ return { yes: false, reason: null };
77
+ }
78
+
79
+ // --- Resource key extraction ---
80
+
81
+ // Mapping: arg field name → resourceKey kind. Order matters — first match wins.
82
+ const RESOURCE_FIELDS = [
83
+ { field: 'document_id', kind: 'doc' },
84
+ { field: 'doc_token', kind: 'doc' },
85
+ { field: 'app_token', kind: 'app' },
86
+ { field: 'space_id', kind: 'wiki' },
87
+ { field: 'node_token', kind: 'wiki' },
88
+ { field: 'wiki_token', kind: 'wiki' },
89
+ { field: 'chat_id', kind: 'chat' },
90
+ { field: 'user_id', kind: 'user' },
91
+ { field: 'open_id', kind: 'user' },
92
+ { field: 'file_token', kind: 'file' },
93
+ { field: 'image_token', kind: 'file' },
94
+ { field: 'message_id', kind: 'msg' },
95
+ ];
96
+
97
+ function extractResourceKey(args) {
98
+ if (!args || typeof args !== 'object') return null;
99
+ for (const { field, kind } of RESOURCE_FIELDS) {
100
+ const v = args[field];
101
+ if (typeof v === 'string' && v) return `${kind}:${v}`;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // --- Profile order ---
107
+
108
+ function profileOrder(name, args, ctx) {
109
+ const all = ctx.listProfiles();
110
+ if (all.length <= 1) return all;
111
+ const active = ctx.getActiveProfile();
112
+ const resourceKey = extractResourceKey(args);
113
+ const hints = credentials.getProfileHints();
114
+ const hinted = resourceKey ? hints[resourceKey] : null;
115
+
116
+ // Order: [hinted (if !active && exists), active, ...rest]
117
+ // We always try active first when there's no hint or the hint matches active —
118
+ // this preserves "your current profile is the default attempt" UX. When the
119
+ // hint differs from active, we skip active for the FIRST attempt because the
120
+ // hint is empirically known to work (or used to).
121
+ const order = [];
122
+ const seen = new Set();
123
+ const push = (p) => { if (p && all.includes(p) && !seen.has(p)) { order.push(p); seen.add(p); } };
124
+
125
+ if (hinted && hinted !== active) push(hinted);
126
+ push(active);
127
+ for (const p of [...all].sort()) push(p);
128
+ return order;
129
+ }
130
+
131
+ // --- Main wrapper ---
132
+
133
+ async function withProfileRouting(ctx, name, args, callHandler) {
134
+ // Bail out early when there's nothing to switch to or this is a write.
135
+ const all = ctx.listProfiles();
136
+ const isMultiProfile = all.length > 1;
137
+
138
+ // Explicit override: via_profile arg pins (or unlocks auto-switch on writes).
139
+ let viaProfileArg = null;
140
+ if (args && typeof args === 'object' && typeof args.via_profile === 'string') {
141
+ viaProfileArg = args.via_profile;
142
+ }
143
+
144
+ const allowAuto = isMultiProfile && (
145
+ isReadOnlyCall(name, args) || viaProfileArg === 'auto'
146
+ );
147
+
148
+ if (!allowAuto) {
149
+ // Single-profile or write: just call once.
150
+ if (viaProfileArg && viaProfileArg !== 'auto') {
151
+ // Explicit pin
152
+ if (!all.includes(viaProfileArg)) {
153
+ return { content: [{ type: 'text', text: `via_profile: "${viaProfileArg}" not found. Available: ${all.join(', ')}.` }], isError: true };
154
+ }
155
+ const wasActive = ctx.getActiveProfile();
156
+ if (viaProfileArg !== wasActive) ctx.setActiveProfile(viaProfileArg);
157
+ try {
158
+ return await callHandler();
159
+ } finally {
160
+ // Restore active for subsequent calls in same session — explicit pin
161
+ // is per-call, not a session toggle.
162
+ if (viaProfileArg !== wasActive) ctx.setActiveProfile(wasActive);
163
+ }
164
+ }
165
+ return callHandler();
166
+ }
167
+
168
+ // Auto-switch loop.
169
+ const order = profileOrder(name, args, ctx);
170
+ const wasActive = ctx.getActiveProfile();
171
+ const failures = [];
172
+ let switchedFrom = null;
173
+ let switchedReason = null;
174
+
175
+ for (let i = 0; i < order.length; i++) {
176
+ const profile = order[i];
177
+ if (i > 0) {
178
+ // Switching away from previous profile.
179
+ if (!switchedFrom) switchedFrom = wasActive;
180
+ ctx.setActiveProfile(profile);
181
+ console.error(`[feishu-user-plugin] profile-router: ${order[i - 1]} → ${profile} on ${name} (${switchedReason || 'first attempt failed'})`);
182
+ } else if (profile !== wasActive) {
183
+ // Hinted profile differs from active — switch on first attempt too.
184
+ switchedFrom = wasActive;
185
+ ctx.setActiveProfile(profile);
186
+ console.error(`[feishu-user-plugin] profile-router: ${wasActive} → ${profile} on ${name} (hint match)`);
187
+ }
188
+
189
+ try {
190
+ const res = await callHandler();
191
+ // Detect handler-level isError responses (not all errors throw).
192
+ if (res?.isError && res.content?.[0]?.text) {
193
+ const decision = shouldSwitchOnError({ message: res.content[0].text });
194
+ if (decision.yes && i + 1 < order.length) {
195
+ failures.push({ profile, error: res.content[0].text.slice(0, 200) });
196
+ switchedReason = decision.reason;
197
+ continue;
198
+ }
199
+ }
200
+ // Success — cache hint if we switched, then return.
201
+ if (switchedFrom && profile !== switchedFrom) {
202
+ const rk = extractResourceKey(args);
203
+ if (rk) {
204
+ try { credentials.setProfileHint(rk, profile); }
205
+ catch (e) { console.error(`[feishu-user-plugin] profile-router: hint persist failed (${e.message})`); }
206
+ }
207
+ // Annotate response.
208
+ if (res?.content?.[0]?.type === 'text' && typeof res.content[0].text === 'string') {
209
+ res.content[0].text = `[autoSwitched: ${switchedFrom} → ${profile} on ${rk || 'no-key'}]\n` + res.content[0].text;
210
+ }
211
+ }
212
+ // Restore active to whatever the user had — auto-switch is per-call.
213
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
214
+ return res;
215
+ } catch (err) {
216
+ const decision = shouldSwitchOnError(err);
217
+ failures.push({ profile, error: err.message });
218
+ if (!decision.yes || i + 1 >= order.length) {
219
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
220
+ // Compose a comprehensive error if all profiles failed.
221
+ if (failures.length > 1) {
222
+ const lines = failures.map(f => ` ${f.profile}: ${f.error}`).join('\n');
223
+ throw new Error(`All ${failures.length} profiles failed on ${name}:\n${lines}`);
224
+ }
225
+ throw err;
226
+ }
227
+ switchedReason = decision.reason;
228
+ // Loop to next profile.
229
+ }
230
+ }
231
+
232
+ if (switchedFrom) ctx.setActiveProfile(wasActive);
233
+ // Should not reach: loop returns on success or throws.
234
+ throw new Error(`profile-router: exhausted ${order.length} profiles on ${name}`);
235
+ }
236
+
237
+ module.exports = {
238
+ withProfileRouting,
239
+ isReadOnlyCall,
240
+ shouldSwitchOnError,
241
+ extractResourceKey,
242
+ profileOrder,
243
+ // Constants exposed for tests.
244
+ READ_ONLY_PREFIXES,
245
+ READ_ONLY_OVERRIDES,
246
+ SWITCH_CODES,
247
+ SWITCH_PATTERNS,
248
+ };
@@ -0,0 +1,231 @@
1
+ // src/auth/uat.js — UAT lifecycle: refresh, cross-process file lock, persist.
2
+ //
3
+ // State lives on the LarkOfficialClient instance (this._uat, this._uatRefresh,
4
+ // this._uatExpires). These functions take `client` as first arg and mutate its
5
+ // fields. Lifted out of clients/official/base.js for clarity; called only from
6
+ // there.
7
+ //
8
+ // What this owns:
9
+ // - decodeTokenExpiry(token) — JWT exp parsing
10
+ // - getValidUAT(client) — returns current UAT, refreshes if expiring
11
+ // - refreshUAT(client) — full refresh dance with file lock + persist
12
+ // - withUAT(client, fn) — wrapper that retries fn once on auth-error codes
13
+ // - uatREST(client, method, path, opts) — generic UAT REST helper
14
+ // - asUserOrApp(client, opts) — UAT-first, bot-fallback wrapper
15
+ // - persistUAT(client) — writes through auth/credentials
16
+ // - adoptPersistedUATIfNewer(client) — peer-rotation adoption
17
+ // - acquireRefreshLock / releaseRefreshLock — cross-process advisory lock
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const os = require('os');
22
+ const { fetchWithTimeout } = require('../utils');
23
+
24
+ function decodeTokenExpiry(token) {
25
+ try {
26
+ const payload = token?.split('.')?.[1];
27
+ if (!payload) return 0;
28
+ const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
29
+ return typeof data.exp === 'number' ? data.exp : 0;
30
+ } catch (_) {
31
+ return 0;
32
+ }
33
+ }
34
+
35
+ async function getValidUAT(client) {
36
+ if (!client._uat) throw new Error('No user_access_token. Run: npx feishu-user-plugin oauth');
37
+ const now = Math.floor(Date.now() / 1000);
38
+ if (!client._uatExpires) client._uatExpires = decodeTokenExpiry(client._uat);
39
+ if (client._uatExpires > 0 && client._uatExpires <= now + 300) {
40
+ return refreshUAT(client);
41
+ }
42
+ return client._uat;
43
+ }
44
+
45
+ function adoptPersistedUATIfNewer(client) {
46
+ try {
47
+ const { readCredentials } = require('./credentials');
48
+ const creds = readCredentials();
49
+ const token = creds.LARK_USER_ACCESS_TOKEN;
50
+ const refresh = creds.LARK_USER_REFRESH_TOKEN;
51
+ if (!token && !refresh) return false;
52
+ const expires = parseInt(creds.LARK_UAT_EXPIRES || '0') || decodeTokenExpiry(token);
53
+ const changed = (token && token !== client._uat)
54
+ || (refresh && refresh !== client._uatRefresh)
55
+ || (expires && expires !== client._uatExpires);
56
+ if (!changed) return false;
57
+ if (token) client._uat = token;
58
+ if (refresh) client._uatRefresh = refresh;
59
+ client._uatExpires = expires || 0;
60
+ console.error('[feishu-user-plugin] UAT adopted latest persisted token before refresh');
61
+ return true;
62
+ } catch (e) {
63
+ console.error(`[feishu-user-plugin] UAT persisted-token check failed: ${e.message}`);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ function uatLockPath() {
69
+ return path.join(os.homedir(), '.claude', 'feishu-uat-refresh.lock');
70
+ }
71
+
72
+ async function acquireRefreshLock(lockPath, { staleMs = 30000, pollMs = 200, timeoutMs = 20000 } = {}) {
73
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch (_) {}
74
+ const start = Date.now();
75
+ while (Date.now() - start < timeoutMs) {
76
+ try {
77
+ const fd = fs.openSync(lockPath, 'wx');
78
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
79
+ fs.closeSync(fd);
80
+ return true;
81
+ } catch (e) {
82
+ if (e.code !== 'EEXIST') throw e;
83
+ try {
84
+ const stat = fs.statSync(lockPath);
85
+ if (Date.now() - stat.mtimeMs > staleMs) {
86
+ try { fs.unlinkSync(lockPath); } catch (_) {}
87
+ continue;
88
+ }
89
+ } catch (_) { /* lock vanished — retry */ }
90
+ await new Promise(r => setTimeout(r, pollMs));
91
+ }
92
+ }
93
+ return false;
94
+ }
95
+
96
+ function releaseRefreshLock(lockPath) {
97
+ try { fs.unlinkSync(lockPath); } catch (_) {}
98
+ }
99
+
100
+ async function refreshUAT(client) {
101
+ const lockPath = uatLockPath();
102
+ const acquired = await acquireRefreshLock(lockPath);
103
+ if (!acquired) {
104
+ console.error('[feishu-user-plugin] UAT refresh lock timed out; proceeding without mutual exclusion');
105
+ }
106
+ try {
107
+ const now = Math.floor(Date.now() / 1000);
108
+ if (adoptPersistedUATIfNewer(client) && client._uatExpires > now + 300) {
109
+ return client._uat;
110
+ }
111
+ if (!client._uatRefresh) throw new Error('UAT expired and no refresh token. Run: npx feishu-user-plugin oauth');
112
+ const res = await fetchWithTimeout('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({
116
+ grant_type: 'refresh_token',
117
+ client_id: client.appId,
118
+ client_secret: client.appSecret,
119
+ refresh_token: client._uatRefresh,
120
+ }),
121
+ });
122
+ const data = await res.json();
123
+ const tokenData = data.access_token ? data : data.data;
124
+ if (!tokenData?.access_token) throw new Error(`UAT refresh failed: ${JSON.stringify(data)}. Run: npx feishu-user-plugin oauth`);
125
+ client._uat = tokenData.access_token;
126
+ client._uatRefresh = tokenData.refresh_token || client._uatRefresh;
127
+ const expiresIn = typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200;
128
+ client._uatExpires = Math.floor(Date.now() / 1000) + expiresIn;
129
+ persistUAT(client);
130
+ console.error('[feishu-user-plugin] UAT refreshed successfully');
131
+ return client._uat;
132
+ } finally {
133
+ if (acquired) releaseRefreshLock(lockPath);
134
+ }
135
+ }
136
+
137
+ function persistUAT(client) {
138
+ const { persistToConfig } = require('./credentials');
139
+ persistToConfig({
140
+ LARK_USER_ACCESS_TOKEN: client._uat,
141
+ LARK_USER_REFRESH_TOKEN: client._uatRefresh,
142
+ LARK_UAT_EXPIRES: String(client._uatExpires),
143
+ });
144
+ }
145
+
146
+ async function withUAT(client, fn) {
147
+ let uat = await getValidUAT(client);
148
+ const data = await fn(uat);
149
+ if (data.code === 99991668 || data.code === 99991663 || data.code === 99991677) {
150
+ if (data.code === 99991668 && typeof data.msg === 'string' && /not support/i.test(data.msg)) {
151
+ return data;
152
+ }
153
+ uat = await refreshUAT(client);
154
+ return fn(uat);
155
+ }
156
+ return data;
157
+ }
158
+
159
+ async function uatREST(client, method, urlPath, { body, query } = {}) {
160
+ let qs = '';
161
+ if (query) {
162
+ const sp = new URLSearchParams();
163
+ for (const [k, v] of Object.entries(query)) {
164
+ if (v === undefined || v === null) continue;
165
+ if (Array.isArray(v)) { for (const item of v) sp.append(k, String(item)); }
166
+ else sp.append(k, String(v));
167
+ }
168
+ const str = sp.toString();
169
+ if (str) qs = '?' + str;
170
+ }
171
+ const url = 'https://open.feishu.cn' + urlPath + qs;
172
+ return withUAT(client, async (uat) => {
173
+ const headers = { 'Authorization': `Bearer ${uat}` };
174
+ const init = { method, headers };
175
+ if (body !== undefined) {
176
+ headers['content-type'] = 'application/json';
177
+ init.body = JSON.stringify(body);
178
+ }
179
+ const res = await fetchWithTimeout(url, init);
180
+ return res.json();
181
+ });
182
+ }
183
+
184
+ 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
+ }
200
+ 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;
217
+ }
218
+ throw appErr;
219
+ }
220
+ }
221
+
222
+ module.exports = {
223
+ decodeTokenExpiry,
224
+ getValidUAT,
225
+ refreshUAT,
226
+ withUAT,
227
+ uatREST,
228
+ asUserOrApp,
229
+ persistUAT,
230
+ adoptPersistedUATIfNewer,
231
+ };