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
package/src/oauth.js CHANGED
@@ -33,11 +33,18 @@ const TARGET_PROFILE = _parseTargetProfile();
33
33
  const _hasCanonical = !!credentialsModule.readCanonical();
34
34
  let creds;
35
35
  let profileLabel;
36
+ // v1.3.12 (PR #45 P2): capture targetName at module init, NOT at OAuth
37
+ // callback time. If we re-read getActiveProfileName() inside saveToken (as
38
+ // the v1.3.6 code did), a concurrent `switch_profile` or Lark Desktop
39
+ // account flip between "open browser for authorize" and "callback fires"
40
+ // would write tokens to the WRONG profile silently. The whole oauth flow
41
+ // is anchored to the profile name resolved here.
42
+ let RESOLVED_PROFILE = null;
36
43
  if (_hasCanonical) {
37
- const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
44
+ RESOLVED_PROFILE = TARGET_PROFILE || credentialsModule.getActiveProfileName();
38
45
  try {
39
- creds = credentialsModule.getActiveProfileEnv(targetName);
40
- profileLabel = `credentials.json::profiles[${targetName}]`;
46
+ creds = credentialsModule.getActiveProfileEnv(RESOLVED_PROFILE);
47
+ profileLabel = `credentials.json::profiles[${RESOLVED_PROFILE}]`;
41
48
  } catch (e) {
42
49
  console.error(`OAuth target profile error: ${e.message}`);
43
50
  console.error(`Available: ${credentialsModule.listProfileNames().join(', ')}`);
@@ -67,10 +74,25 @@ const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
67
74
  // sheets:spreadsheet for sheet_image / sheet_file media uploads
68
75
  // drive:file:upload narrower scope for drive/v1/files/upload_all (independent of drive:drive)
69
76
  // v1.3.7 additions:
70
- // calendar:calendar.event:write create/update/delete/respond calendar events
77
+ // calendar:calendar.event:{create,update,delete,reply} calendar write — Feishu splits
78
+ // "write" into 4 verbs. Using the umbrella name
79
+ // `calendar:calendar.event:write` makes the
80
+ // OAuth authorize endpoint 422-reject the whole
81
+ // request. scripts/check-scopes.js bans it.
71
82
  // task:task full Task v2 read+write
72
- // okr:okr.content:write create/delete OKR progress records
73
- const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly contact:user.base:readonly contact:user.id:readonly docx:document drive:drive drive:file:upload bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly okr:okr.content:write calendar:calendar:readonly calendar:calendar.event:read calendar:calendar.event:write docs:document.media:download docs:document.media:upload sheets:spreadsheet task:task';
83
+ // okr:okr.content:writeonly create/delete OKR progress records.
84
+ // Note: Feishu uses `:writeonly` (one word),
85
+ // not `:write` (check-scopes.js banlist).
86
+ // v1.3.12 additions:
87
+ // contact:contact.base:readonly broader contact lookup (员工通讯录基本信息)
88
+ // im:resource user-side image/file download from messages
89
+ // search:message search_messages tool — search the user's IM
90
+ // history (POST /open-apis/search/v2/message,
91
+ // UAT-only; Feishu does NOT expose bot path).
92
+ //
93
+ // To add a scope: edit this line + add a row in docs/AUTH-SETUP.md scope table.
94
+ // scripts/check-scopes.js enforces both in CI.
95
+ const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly im:resource search:message contact:user.base:readonly contact:user.id:readonly contact:contact.base:readonly docx:document drive:drive drive:file:upload bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly okr:okr.content:writeonly calendar:calendar:readonly calendar:calendar.event:read calendar:calendar.event:create calendar:calendar.event:update calendar:calendar.event:delete calendar:calendar.event:reply docs:document.media:download docs:document.media:upload sheets:spreadsheet task:task';
74
96
 
75
97
  if (!APP_ID || !APP_SECRET) {
76
98
  console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
@@ -89,7 +111,10 @@ async function getAppInfo() {
89
111
  body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
90
112
  });
91
113
  const tokenData = await tokenRes.json();
92
- if (!tokenData.app_access_token) return null;
114
+ if (!tokenData.app_access_token) {
115
+ console.error(`[oauth] app_access_token request returned no token: ${JSON.stringify(tokenData)}`);
116
+ return null;
117
+ }
93
118
 
94
119
  // Get app info — try the direct app query first, fall back to underauditlist
95
120
  let appName = null;
@@ -100,6 +125,15 @@ async function getAppInfo() {
100
125
  appName = directData?.data?.app?.app_name;
101
126
 
102
127
  if (!appName) {
128
+ // v1.3.12: 99991672 specifically means "no permission to read application
129
+ // info" — caused by missing tenant-side scope `application:application:self_manage`
130
+ // (no admin review required, see docs/AUTH-SETUP.md). Without it the
131
+ // sender displayLabel for bot messages falls back to "[Bot] (cli_xxx)".
132
+ if (directData?.code === 99991672) {
133
+ console.error(`[oauth] App name resolve failed (code=99991672): need application:application:self_manage scope (tenant-side, 免审). displayLabel will fall back to '[Bot] (cli_xxx)'`);
134
+ } else if (directData?.code && directData.code !== 0) {
135
+ console.error(`[oauth] App name resolve failed: code=${directData.code} msg=${directData.msg}`);
136
+ }
103
137
  const listRes = await fetch('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
104
138
  headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
105
139
  });
@@ -108,7 +142,8 @@ async function getAppInfo() {
108
142
  }
109
143
 
110
144
  return { appName, tenantKey: tokenData.tenant_key };
111
- } catch {
145
+ } catch (e) {
146
+ console.error(`[oauth] App name lookup threw: ${e.message}`);
112
147
  return null;
113
148
  }
114
149
  }
@@ -155,8 +190,9 @@ function saveToken(tokenData) {
155
190
 
156
191
  let ok = false;
157
192
  if (_hasCanonical) {
158
- const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
159
- ok = credentialsModule.persistProfileUpdate(targetName, updates);
193
+ // Use the profile name captured at module init, not whatever
194
+ // credentials.json::active is *now*. See PR #45 race condition fix.
195
+ ok = credentialsModule.persistProfileUpdate(RESOLVED_PROFILE, updates);
160
196
  if (ok) console.log(`Tokens written to ${profileLabel}`);
161
197
  } else {
162
198
  ok = legacyConfig.persistToConfig(updates);
package/src/server.js CHANGED
@@ -29,6 +29,8 @@ const { resolveToken } = require('./resolver');
29
29
  const { listPrompts, getPrompt } = require('./prompts');
30
30
  const credentials = require('./auth/credentials');
31
31
  const profileRouter = require('./auth/profile-router');
32
+ const { createCredentialsMonitor } = require('./auth/credentials-monitor');
33
+ const identityState = require('./auth/identity-state');
32
34
 
33
35
  // --- Tool modules ---
34
36
  // Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
@@ -156,14 +158,22 @@ function getOfficialClient() {
156
158
  }
157
159
 
158
160
  // Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
159
- // instead of process.env, so credentials.json profiles work uniformly.
161
+ // instead of process.env, so credentials.json profiles work uniformly. Also
162
+ // the hot-reload entry point used by credMonitor.onUatChange: when `env` has
163
+ // no UAT (user nuked the token), clear the in-memory copy instead of
164
+ // silently leaving the stale token in place.
160
165
  function loadUATFromEnv(client, env) {
161
- const token = env.LARK_USER_ACCESS_TOKEN;
162
- const refresh = env.LARK_USER_REFRESH_TOKEN;
163
- const expires = parseInt(env.LARK_UAT_EXPIRES || '0');
164
- if (!token) return;
166
+ const token = env?.LARK_USER_ACCESS_TOKEN || null;
167
+ const refresh = env?.LARK_USER_REFRESH_TOKEN || null;
168
+ const expires = parseInt(env?.LARK_UAT_EXPIRES || '0') || 0;
169
+ if (!token) {
170
+ client._uat = null;
171
+ client._uatRefresh = null;
172
+ client._uatExpires = 0;
173
+ return;
174
+ }
165
175
  client._uat = token;
166
- client._uatRefresh = refresh || null;
176
+ client._uatRefresh = refresh;
167
177
  client._uatExpires = expires || client._decodeTokenExpiry(token);
168
178
  }
169
179
 
@@ -284,36 +294,47 @@ function _runLarkDesktopReactor() {
284
294
  .reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
285
295
  }
286
296
 
287
- // Cross-process active-profile sync (v1.3.9 A.2).
288
- // Each tool call: stat credentials.json; if mtime changed AND active differs
289
- // from in-memory currentProfile, do an in-process setActiveProfile().
290
- // _credMtimeBaseline starts null first call sets the baseline without syncing.
291
- let _credMtimeBaseline = null;
292
-
293
- function _syncActiveProfileFromDisk() {
294
- const m = _credMtime();
295
- if (m === null) return; // no credentials.json — nothing to sync
296
- if (_credMtimeBaseline === null) { _credMtimeBaseline = m; return; }
297
- if (m === _credMtimeBaseline) return; // unchanged
298
- _credMtimeBaseline = m;
299
- let newActive;
300
- try { newActive = credentials.getActiveProfileName(); } catch (_) { return; }
301
- if (newActive === currentProfile) return;
302
- console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} → ${newActive}`);
303
- // Use the same setActiveProfile flow that switch_profile uses, except don't
304
- // re-write credentials.json (which it already reflects the change).
297
+ // Cross-process credentials sync (v1.3.12 — CredentialsMonitor).
298
+ // One poller, multiple hooks. Each tool call entry runs `credMonitor.sync()`
299
+ // which:
300
+ // - active profile changed flips in-memory currentProfile + clears caches
301
+ // - UAT field changed → reloads officialClient._uat without restart
302
+ // - cookie field changed → (no-op for now — userClient already re-inits
303
+ // on next getUserClient call when nulled)
304
+ // - any change → invalidates the identity-state cache so the
305
+ // next call re-probes
306
+ //
307
+ // This replaces v1.3.9's _syncActiveProfileFromDisk (active-only) + the
308
+ // "restart Claude Code to pick up new UAT" hand-off pattern.
309
+ const credMonitor = createCredentialsMonitor();
310
+
311
+ credMonitor.onProfileSwitch(({ to }) => {
312
+ if (!to || to === currentProfile) return;
305
313
  try {
306
- credentials.getActiveProfileEnv(newActive); // validate profile exists
307
- currentProfile = newActive;
314
+ credentials.getActiveProfileEnv(to); // validate profile exists
315
+ console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} ${to}`);
316
+ currentProfile = to;
308
317
  userClient = null;
309
318
  officialClient = null;
310
- // resolver.js has a wiki-node resolution cache; clear it on profile switch
311
- // so wiki nodes belonging to the previous app-id don't cross-contaminate.
312
319
  require('./resolver').clearCache();
313
320
  } catch (e) {
314
- console.error(`[feishu-user-plugin] sync to "${newActive}" failed: ${e.message}; staying on "${currentProfile}"`);
321
+ console.error(`[feishu-user-plugin] sync to "${to}" failed: ${e.message}; staying on "${currentProfile}"`);
315
322
  }
316
- }
323
+ });
324
+
325
+ credMonitor.onUatChange((env) => {
326
+ // Hot-reload UAT into the running officialClient. No restart needed.
327
+ // Routing through loadUATFromEnv keeps the field-write logic in one
328
+ // place — same helper used at getOfficialClient() startup.
329
+ if (!officialClient) return; // next getOfficialClient() reads env directly
330
+ loadUATFromEnv(officialClient, env);
331
+ identityState.invalidateIdentity(officialClient);
332
+ console.error('[feishu-user-plugin] UAT reloaded from credentials.json (no restart needed)');
333
+ });
334
+
335
+ credMonitor.onCacheInvalidate(() => {
336
+ if (officialClient) identityState.invalidateIdentity(officialClient);
337
+ });
317
338
 
318
339
  async function _maybeReconfigure() {
319
340
  if (!ownerHandle || !wsServer) return;
@@ -393,9 +414,11 @@ function buildCtx() {
393
414
  console.error(`[feishu-user-plugin] WARN: setActiveProfile("${n}") failed to persist to credentials.json: ${e.message}. In-memory currentProfile updated anyway, but other MCP processes won't see the switch.`);
394
415
  }
395
416
  }
396
- // Re-baseline mtime so _syncActiveProfileFromDisk doesn't immediately
397
- // trigger a redundant sync on the next tool call after we just wrote.
398
- _credMtimeBaseline = _credMtime();
417
+ // Run a sync so credMonitor adopts the just-written file as its
418
+ // baseline. The onProfileSwitch hook will see `to === currentProfile`
419
+ // and short-circuit; UAT/cookie hooks fire only if those fields
420
+ // actually differ from the prior active profile, which is harmless.
421
+ credMonitor.sync();
399
422
  },
400
423
  resolveDocId,
401
424
  getWsServer: () => wsServer,
@@ -431,9 +454,9 @@ const server = new Server(
431
454
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
432
455
 
433
456
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
434
- // Cross-process active-profile sync (v1.3.9 A.2): if another MCP process
435
- // wrote a new `active` field to credentials.json, pick it up before dispatch.
436
- _syncActiveProfileFromDisk();
457
+ // Credentials hot-reload (v1.3.12): poll credentials.json for changes and
458
+ // fire registered hooks (profile / UAT / cookie / invalidate).
459
+ credMonitor.sync();
437
460
  const { name, arguments: args } = request.params;
438
461
  const handler = HANDLERS[name];
439
462
  if (!handler) {
@@ -559,7 +582,7 @@ async function main() {
559
582
  }
560
583
  }
561
584
 
562
- module.exports = { main, TOOLS, HANDLERS };
585
+ module.exports = { main, TOOLS, HANDLERS, buildCtx };
563
586
 
564
587
  if (require.main === module) {
565
588
  main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
package/src/test-all.js CHANGED
@@ -324,4 +324,44 @@ main().catch(console.error).finally(() => {
324
324
  require('./test-events-log').run();
325
325
  require('./test-events-cursor').run();
326
326
  require('./test-events-owner').run();
327
+ require('./test-error-codes').run();
328
+ require('./test-identity-state').run().catch(e => {
329
+ console.error('identity-state: FAIL', e);
330
+ process.exitCode = 1;
331
+ });
332
+ require('./test-with-uat-retry').run().catch(e => {
333
+ console.error('with-uat-retry: FAIL', e);
334
+ process.exitCode = 1;
335
+ });
336
+ require('./test-populate-sender-names').run().catch(e => {
337
+ console.error('populate-sender-names: FAIL', e);
338
+ process.exitCode = 1;
339
+ });
340
+ require('./test-credentials-monitor').run().catch(e => {
341
+ console.error('credentials-monitor: FAIL', e);
342
+ process.exitCode = 1;
343
+ });
344
+ require('./test-lru-cache').run().catch(e => {
345
+ console.error('lru-cache: FAIL', e);
346
+ process.exitCode = 1;
347
+ });
348
+ require('./test-lockfile-pid').run();
349
+ require('./test-negative-cache').run().catch(e => {
350
+ console.error('negative-cache: FAIL', e);
351
+ process.exitCode = 1;
352
+ });
353
+ require('./test-send-shape').run().catch(e => {
354
+ console.error('send-shape: FAIL', e);
355
+ process.exitCode = 1;
356
+ });
357
+ require('./test-via-user').run().catch(e => {
358
+ console.error('via-user: FAIL', e);
359
+ process.exitCode = 1;
360
+ });
361
+ require('./test-search-messages').run().catch(e => {
362
+ console.error('search-messages: FAIL', e);
363
+ process.exitCode = 1;
364
+ });
365
+ require('./test-cli-tool').run();
366
+ require('./test-display-label'); // standalone — runs on require, exits non-zero on fail
327
367
  });
@@ -0,0 +1,87 @@
1
+ // src/test-cli-tool.js — verify the v1.3.12 \`tool\` CLI subcommand.
2
+ //
3
+ // Spawns \`node src/cli.js tool <args>\` as child_process and asserts:
4
+ // - tool list prints 85 names, exit 0
5
+ // - tool help <known-name> prints the schema, exit 0
6
+ // - tool help <missing-name> prints error to stderr, exit 2
7
+ // - tool <unknown-name> '{}' fails with exit 2
8
+ // - tool help (no args) prints help to stderr, exit 2
9
+ //
10
+ // We don't actually invoke a tool here because handlers need credentials
11
+ // — that's covered by the integration scripts. This test just covers the
12
+ // CLI argv-parsing + dispatcher correctness.
13
+
14
+ 'use strict';
15
+
16
+ const { spawnSync } = require('child_process');
17
+ const path = require('path');
18
+ const assert = require('node:assert/strict');
19
+
20
+ const CLI = path.join(__dirname, 'cli.js');
21
+
22
+ function runCli(args) {
23
+ return spawnSync('node', [CLI, ...args], { encoding: 'utf8' });
24
+ }
25
+
26
+ function run() {
27
+ // --- 1. tool list — exit 0, 85 lines, all known tool names ---
28
+ {
29
+ const r = runCli(['tool', 'list']);
30
+ assert.equal(r.status, 0, 'tool list should exit 0');
31
+ const lines = r.stdout.trim().split('\n');
32
+ assert.equal(lines.length, 85, `tool list should print 85 names, got ${lines.length}`);
33
+ assert.ok(lines.includes('get_login_status'));
34
+ assert.ok(lines.includes('search_messages'));
35
+ assert.ok(lines.includes('send_as_user'));
36
+ }
37
+
38
+ // --- 2. tool help <known> — exit 0, contains schema ---
39
+ {
40
+ const r = runCli(['tool', 'help', 'get_login_status']);
41
+ assert.equal(r.status, 0, 'tool help <known> should exit 0');
42
+ assert.ok(r.stdout.includes('# get_login_status'));
43
+ assert.ok(r.stdout.includes('## inputSchema'));
44
+ assert.ok(r.stdout.includes('"type": "object"'));
45
+ }
46
+
47
+ // --- 3. tool help <unknown> — exit 2, stderr complains ---
48
+ {
49
+ const r = runCli(['tool', 'help', 'nonexistent_tool_xyz']);
50
+ assert.equal(r.status, 2, 'tool help <unknown> should exit 2');
51
+ assert.ok(r.stderr.includes('Unknown tool'));
52
+ }
53
+
54
+ // --- 4. tool <unknown-name> '{}' — exit 2 ---
55
+ {
56
+ const r = runCli(['tool', 'nonexistent_xyz', '{}']);
57
+ assert.equal(r.status, 2, 'tool <unknown> should exit 2');
58
+ assert.ok(r.stderr.includes('Unknown tool'));
59
+ }
60
+
61
+ // --- 5. tool <name> with malformed JSON args — exit 2 ---
62
+ {
63
+ const r = runCli(['tool', 'get_login_status', 'not a json']);
64
+ assert.equal(r.status, 2);
65
+ assert.ok(r.stderr.includes('failed to parse JSON'));
66
+ }
67
+
68
+ // --- 6. tool (no subcommand) — exit 2 with usage on stdout ---
69
+ {
70
+ const r = runCli(['tool']);
71
+ assert.equal(r.status, 2);
72
+ assert.ok(r.stdout.includes('npx feishu-user-plugin tool'));
73
+ }
74
+
75
+ // --- 7. tool --help — exit 0 ---
76
+ {
77
+ const r = runCli(['tool', '--help']);
78
+ assert.equal(r.status, 0);
79
+ assert.ok(r.stdout.includes('tool list'));
80
+ assert.ok(r.stdout.includes('tool help'));
81
+ }
82
+
83
+ console.log('cli-tool.js: PASS');
84
+ }
85
+
86
+ if (require.main === module) run();
87
+ module.exports = { run };
@@ -0,0 +1,124 @@
1
+ // src/test-credentials-monitor.js — unit test for src/auth/credentials-monitor.js.
2
+ //
3
+ // Pre-v1.3.12 hot-reload was partial: server.js stat-ed credentials.json mtime
4
+ // and dispatched setActiveProfile() on change, but the UAT in-memory token,
5
+ // _userNameCache, and lockfile heartbeat never observed the change. Users had
6
+ // to restart Claude Code after `npx oauth` for the new UAT to take effect.
7
+ //
8
+ // CredentialsMonitor unifies the mtime + content-hash diff into a single
9
+ // poll triggered per tool call. Owners register hooks for the parts they
10
+ // care about: onUatChange / onCookieChange / onProfileSwitch / onCacheInvalidate.
11
+ //
12
+ // We test against a temporary credentials.json in a tmpdir so the test is
13
+ // isolated from any real ~/.feishu-user-plugin state.
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const assert = require('node:assert/strict');
21
+ const { createCredentialsMonitor } = require('./auth/credentials-monitor');
22
+
23
+ function writeCreds(dir, obj) {
24
+ const p = path.join(dir, 'credentials.json');
25
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
26
+ return p;
27
+ }
28
+
29
+ async function run() {
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-monitor-'));
31
+ const credPath = path.join(dir, 'credentials.json');
32
+
33
+ const baseCreds = {
34
+ version: 1,
35
+ active: 'default',
36
+ profiles: {
37
+ default: {
38
+ LARK_APP_ID: 'cli_a',
39
+ LARK_USER_ACCESS_TOKEN: 'uat_v1',
40
+ LARK_USER_REFRESH_TOKEN: 'ref_v1',
41
+ LARK_COOKIE: 'cookie_v1',
42
+ },
43
+ },
44
+ };
45
+ writeCreds(dir, baseCreds);
46
+
47
+ // Inject path so monitor doesn't read real ~/.feishu-user-plugin
48
+ const monitor = createCredentialsMonitor({ path: credPath });
49
+
50
+ // --- 1. First sync establishes baseline; no hooks fire ---
51
+ let uatFired = 0, cookieFired = 0, profileFired = 0, cacheFired = 0;
52
+ monitor.onUatChange(() => uatFired++);
53
+ monitor.onCookieChange(() => cookieFired++);
54
+ monitor.onProfileSwitch(() => profileFired++);
55
+ monitor.onCacheInvalidate(() => cacheFired++);
56
+
57
+ monitor.sync();
58
+ assert.equal(uatFired, 0, 'baseline sync should not fire hooks');
59
+ assert.equal(cookieFired, 0);
60
+ assert.equal(profileFired, 0);
61
+ assert.equal(cacheFired, 0);
62
+
63
+ // --- 2. Change UAT field → onUatChange fires, others don't ---
64
+ // We must advance the mtime AFTER content change; on fast filesystems writing
65
+ // the same path repeatedly within the same ms gives identical mtime. Set
66
+ // explicit mtime so behaviour doesn't depend on FS clock granularity.
67
+ const after1 = { ...baseCreds, profiles: { default: { ...baseCreds.profiles.default, LARK_USER_ACCESS_TOKEN: 'uat_v2', LARK_USER_REFRESH_TOKEN: 'ref_v2' } } };
68
+ writeCreds(dir, after1);
69
+ fs.utimesSync(credPath, new Date(Date.now() + 1000), new Date(Date.now() + 1000));
70
+ monitor.sync();
71
+ assert.equal(uatFired, 1, 'onUatChange should fire on UAT diff');
72
+ assert.equal(cookieFired, 0, 'unchanged cookie → no cookie hook');
73
+ assert.equal(profileFired, 0, 'unchanged active → no profile hook');
74
+ assert.equal(cacheFired, 1, 'any change should fire onCacheInvalidate once');
75
+
76
+ // --- 3. Same content, just touch mtime → no hook fires (content hash guards) ---
77
+ fs.utimesSync(credPath, new Date(Date.now() + 2000), new Date(Date.now() + 2000));
78
+ monitor.sync();
79
+ assert.equal(uatFired, 1, 'touch (no content change) should not fire UAT');
80
+ assert.equal(cacheFired, 1);
81
+
82
+ // --- 4. Change cookie → onCookieChange fires ---
83
+ const after2 = { ...after1, profiles: { default: { ...after1.profiles.default, LARK_COOKIE: 'cookie_v2' } } };
84
+ writeCreds(dir, after2);
85
+ fs.utimesSync(credPath, new Date(Date.now() + 3000), new Date(Date.now() + 3000));
86
+ monitor.sync();
87
+ assert.equal(cookieFired, 1);
88
+ assert.equal(profileFired, 0);
89
+
90
+ // --- 5. Change active profile → onProfileSwitch fires (legacy parity) ---
91
+ const after3 = { ...after2, active: 'work', profiles: { default: after2.profiles.default, work: { LARK_APP_ID: 'cli_b' } } };
92
+ writeCreds(dir, after3);
93
+ fs.utimesSync(credPath, new Date(Date.now() + 4000), new Date(Date.now() + 4000));
94
+ monitor.sync();
95
+ assert.equal(profileFired, 1, 'active flip → onProfileSwitch fires');
96
+
97
+ // --- 6. Hook receives the new credentials snapshot as argument ---
98
+ let receivedToken = null;
99
+ monitor.onUatChange((snap) => { receivedToken = snap?.LARK_USER_ACCESS_TOKEN; });
100
+ const after4 = { ...after3, profiles: { ...after3.profiles, work: { LARK_APP_ID: 'cli_b', LARK_USER_ACCESS_TOKEN: 'uat_work_v1', LARK_USER_REFRESH_TOKEN: 'ref_work_v1' } } };
101
+ writeCreds(dir, after4);
102
+ fs.utimesSync(credPath, new Date(Date.now() + 5000), new Date(Date.now() + 5000));
103
+ monitor.sync();
104
+ assert.equal(receivedToken, 'uat_work_v1', 'UAT hook should receive the active profile env block');
105
+
106
+ // --- 7. Monitor handles missing file gracefully (no throw) ---
107
+ fs.unlinkSync(credPath);
108
+ monitor.sync(); // should not throw
109
+
110
+ // --- 8. File reappears later → next sync detects + fires ---
111
+ writeCreds(dir, baseCreds);
112
+ fs.utimesSync(credPath, new Date(Date.now() + 6000), new Date(Date.now() + 6000));
113
+ monitor.sync();
114
+ // baseCreds.active='default' diff from previous 'work' → profile change
115
+ // baseCreds.UAT='uat_v1' diff from previous 'uat_work_v1' → uat change
116
+ assert.ok(profileFired >= 2);
117
+ assert.ok(uatFired >= 2);
118
+
119
+ fs.rmSync(dir, { recursive: true, force: true });
120
+ console.log('credentials-monitor.js: PASS');
121
+ }
122
+
123
+ if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
124
+ module.exports = { run };
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // src/test-display-label.js — unit test for LarkOfficialClient._computeDisplayLabel
3
+ //
4
+ // _computeDisplayLabel maps a formatted message (with senderId/senderType/senderName/
5
+ // isRecalled fields) to a human-friendly string for LLM consumption. This covers the
6
+ // 6 sender shapes Feishu actually produces:
7
+ //
8
+ // 1. user with resolved name → name
9
+ // 2. user with senderName=null → "(open_id)" fallback
10
+ // 3. app (bot) with name in cache → "[Bot] AppName"
11
+ // 4. app (bot) without name → "[Bot] (cli_xxx)"
12
+ // 5. anonymous sender → "[匿名]"
13
+ // 6. system message (no sender) → "[系统]"
14
+ // 7. recalled-message prefix → "[已撤回] " + base label
15
+ //
16
+ // Without the implementation this script fails (which is what we want pre-fix).
17
+
18
+ const { LarkOfficialClient } = require('./clients/official/base');
19
+
20
+ const client = new LarkOfficialClient('cli_dummy', 'dummy_secret');
21
+ client._appNameCache.set('cli_named_bot', 'Claude聊天助手');
22
+
23
+ const tests = [
24
+ {
25
+ name: 'user with resolved name',
26
+ input: { senderType: 'user', senderId: 'ou_x', senderName: '周宇' },
27
+ expected: '周宇',
28
+ },
29
+ {
30
+ name: 'user with null senderName',
31
+ input: { senderType: 'user', senderId: 'ou_abc123', senderName: null },
32
+ expected: '(ou_abc123)',
33
+ },
34
+ {
35
+ name: 'app with name in cache',
36
+ input: { senderType: 'app', senderId: 'cli_named_bot' },
37
+ expected: '[Bot] Claude聊天助手',
38
+ },
39
+ {
40
+ name: 'app without name',
41
+ input: { senderType: 'app', senderId: 'cli_unknown' },
42
+ expected: '[Bot] (cli_unknown)',
43
+ },
44
+ {
45
+ name: 'anonymous',
46
+ input: { senderType: 'anonymous', senderId: 'ou_x' },
47
+ expected: '[匿名]',
48
+ },
49
+ {
50
+ name: 'system (no senderId)',
51
+ input: { senderId: undefined },
52
+ expected: '[系统]',
53
+ },
54
+ {
55
+ name: 'recalled user message',
56
+ input: { senderType: 'user', senderId: 'ou_x', senderName: '怪兽', isRecalled: true },
57
+ expected: '[已撤回] 怪兽',
58
+ },
59
+ {
60
+ name: 'recalled with null senderName',
61
+ input: { senderType: 'user', senderId: 'ou_y', senderName: null, isRecalled: true },
62
+ expected: '[已撤回] (ou_y)',
63
+ },
64
+ ];
65
+
66
+ let failures = 0;
67
+ for (const t of tests) {
68
+ let actual;
69
+ try {
70
+ actual = client._computeDisplayLabel(t.input);
71
+ } catch (e) {
72
+ console.error(`FAIL ${t.name}: threw ${e.message}`);
73
+ failures++;
74
+ continue;
75
+ }
76
+ if (actual !== t.expected) {
77
+ console.error(`FAIL ${t.name}: expected ${JSON.stringify(t.expected)}, got ${JSON.stringify(actual)}`);
78
+ failures++;
79
+ } else {
80
+ console.log(`OK ${t.name}`);
81
+ }
82
+ }
83
+
84
+ if (failures) {
85
+ console.error(`\n${failures} test(s) failed`);
86
+ process.exit(1);
87
+ }
88
+ console.log(`\nAll ${tests.length} display-label tests passed`);