feishu-user-plugin 1.3.11 → 1.3.13

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 (51) 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 +159 -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 +209 -0
  18. package/src/auth/uat.js +49 -35
  19. package/src/cli.js +87 -0
  20. package/src/clients/official/base.js +170 -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 +65 -14
  30. package/src/server.js +76 -37
  31. package/src/test-all.js +41 -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 +177 -0
  37. package/src/test-lark-desktop.js +1 -0
  38. package/src/test-lockfile-pid.js +90 -0
  39. package/src/test-lru-cache.js +145 -0
  40. package/src/test-negative-cache.js +85 -0
  41. package/src/test-populate-sender-names.js +98 -0
  42. package/src/test-search-messages.js +101 -0
  43. package/src/test-send-shape.js +115 -0
  44. package/src/test-via-user.js +94 -0
  45. package/src/test-with-uat-retry.js +135 -0
  46. package/src/tools/_registry.js +24 -1
  47. package/src/tools/calendar.js +5 -5
  48. package/src/tools/im-read.js +52 -4
  49. package/src/tools/messaging-user.js +1 -1
  50. package/src/utils.js +83 -0
  51. package/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
@@ -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`);
@@ -0,0 +1,85 @@
1
+ // src/test-error-codes.js — unit test for src/error-codes.js classifyError.
2
+ //
3
+ // Covers the v1.3.12 widening:
4
+ // - 20064 (invalid_grant — UAT revoked, permanent)
5
+ // - 91403 (cross-tenant bot — bot can never access, route to UAT)
6
+ // - 1254xxx upload errors (transient — retry once)
7
+ // - res.json() parse failure messages → 'retry' (transient)
8
+ //
9
+ // Each case maps an error code (or Error object) to { action, reason } the
10
+ // classifier should output. Run via `node src/test-error-codes.js` or as part
11
+ // of `npm test` (imported from src/test-all.js).
12
+
13
+ 'use strict';
14
+
15
+ const assert = require('node:assert/strict');
16
+ const { classifyError, FAILURE_MAP, TRANSIENT_PATTERNS } = require('./error-codes');
17
+
18
+ function run() {
19
+ // --- Existing classifications (regression guard) ---
20
+ assert.deepEqual(
21
+ classifyError(240001),
22
+ { action: 'uat', reason: 'bot_external_tenant', code: 240001 },
23
+ '240001 → bot_external_tenant',
24
+ );
25
+ assert.deepEqual(
26
+ classifyError(70009),
27
+ { action: 'uat', reason: 'bot_no_permission', code: 70009 },
28
+ );
29
+ assert.deepEqual(
30
+ classifyError(42101),
31
+ { action: 'retry', reason: 'bot_rate_limited', code: 42101 },
32
+ );
33
+
34
+ // --- New v1.3.12 entries ---
35
+ const m20064 = classifyError(20064);
36
+ assert.equal(m20064.action, 'uat', '20064 should escalate to UAT path (revoked permanent)');
37
+ assert.equal(m20064.reason, 'uat_revoked', '20064 reason should be uat_revoked');
38
+
39
+ const m91403 = classifyError(91403);
40
+ assert.equal(m91403.action, 'uat', '91403 cross-tenant bot');
41
+ assert.equal(m91403.reason, 'bot_cross_tenant');
42
+
43
+ // 1254xxx — a sample of upload failures observed in production
44
+ for (const code of [1254000, 1254001, 1254301, 1254400]) {
45
+ const c = classifyError(code);
46
+ assert.equal(c.action, 'retry', `${code} should be transient (retry)`);
47
+ assert.equal(c.reason, 'upload_transient', `${code} reason`);
48
+ }
49
+
50
+ // --- res.json() parse failures should retry once ---
51
+ // Real-world: feishu's gateway occasionally returns truncated bodies that
52
+ // make response.json() throw SyntaxError; one retry usually clears it.
53
+ const parseErr = new Error('Unexpected end of JSON input');
54
+ parseErr.name = 'SyntaxError';
55
+ const parseClass = classifyError(parseErr);
56
+ assert.equal(parseClass.action, 'retry', 'JSON parse error → retry');
57
+ assert.equal(parseClass.reason, 'response_parse_error');
58
+
59
+ // --- Existing transient patterns still work ---
60
+ const networkErr = new Error('fetch timeout after 10000ms');
61
+ assert.equal(classifyError(networkErr).action, 'retry');
62
+
63
+ const httpFive = new Error('readMessages failed (HTTP 503, code=99991400): rate limited');
64
+ // Note: code=99991400 hits FAILURE_MAP before the pattern. Either way action=retry.
65
+ assert.equal(classifyError(httpFive).action, 'retry');
66
+
67
+ // --- Unknown codes preserve fallback behavior ---
68
+ assert.deepEqual(
69
+ classifyError(99999),
70
+ { action: 'unknown', reason: 'bot_unknown_error', code: 99999 },
71
+ );
72
+
73
+ // --- TRANSIENT_PATTERNS still exported ---
74
+ assert.ok(Array.isArray(TRANSIENT_PATTERNS) && TRANSIENT_PATTERNS.length >= 5);
75
+
76
+ // --- FAILURE_MAP coverage: all new codes are registered ---
77
+ assert.ok(FAILURE_MAP[20064], 'FAILURE_MAP must register 20064');
78
+ assert.ok(FAILURE_MAP[91403], 'FAILURE_MAP must register 91403');
79
+ assert.ok(FAILURE_MAP[1254000], 'FAILURE_MAP must register 1254000');
80
+
81
+ console.log('error-codes.js: PASS');
82
+ }
83
+
84
+ if (require.main === module) run();
85
+ module.exports = { run };
@@ -0,0 +1,177 @@
1
+ // src/test-identity-state.js — unit test for src/auth/identity-state.js.
2
+ //
3
+ // resolveIdentity + withIdentityFallback are the v1.3.12 replacement for
4
+ // asUserOrApp's silent fallback. Behaviour we test:
5
+ //
6
+ // 1. resolveIdentity reads in-memory client state (no API call by default)
7
+ // and returns one of: VALID_USER / UAT_EXPIRED / BOT_ONLY / NO_CREDENTIALS.
8
+ // 2. withIdentityFallback wraps a UAT-first / bot-fallback flow, attaches
9
+ // via / via_reason / identity to the response, refines the cached
10
+ // identity on UAT failure (e.g. 20064 → UAT_REVOKED), and returns a
11
+ // well-formed combined error when both sides fail.
12
+ // 3. invalidateIdentity clears the 30s cache so a new probe is taken next
13
+ // time (CredentialsMonitor will call this on UAT change).
14
+
15
+ 'use strict';
16
+
17
+ const assert = require('node:assert/strict');
18
+ const {
19
+ IdentityState,
20
+ resolveIdentity,
21
+ withIdentityFallback,
22
+ invalidateIdentity,
23
+ } = require('./auth/identity-state');
24
+
25
+ // Minimal fake client. Real LarkOfficialClient exposes hasUAT (getter), appId,
26
+ // _uat, _uatExpires; tests only need those.
27
+ function fakeClient({ hasUAT = false, appId = 'cli_test', expires = 0 } = {}) {
28
+ return {
29
+ appId,
30
+ appSecret: 'secret',
31
+ _uat: hasUAT ? 'token' : null,
32
+ _uatRefresh: hasUAT ? 'refresh' : null,
33
+ _uatExpires: expires,
34
+ get hasUAT() { return !!this._uat; },
35
+ };
36
+ }
37
+
38
+ async function run() {
39
+ // --- 1. enum values are exported ---
40
+ for (const k of ['VALID_USER', 'UAT_EXPIRED', 'UAT_REVOKED', 'UAT_MISSING_SCOPE', 'BOT_ONLY', 'NO_CREDENTIALS']) {
41
+ assert.ok(IdentityState[k], `IdentityState.${k} should be defined`);
42
+ }
43
+
44
+ // --- 2. resolveIdentity, no UAT, has app ---
45
+ const c1 = fakeClient({ hasUAT: false, appId: 'cli_x' });
46
+ invalidateIdentity(c1);
47
+ assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY);
48
+
49
+ // --- 3. resolveIdentity, no UAT, no app ---
50
+ const c2 = fakeClient({ hasUAT: false, appId: null });
51
+ invalidateIdentity(c2);
52
+ assert.equal(await resolveIdentity(c2), IdentityState.NO_CREDENTIALS);
53
+
54
+ // --- 4. resolveIdentity, valid UAT (future expiry) ---
55
+ const c3 = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
56
+ invalidateIdentity(c3);
57
+ assert.equal(await resolveIdentity(c3), IdentityState.VALID_USER);
58
+
59
+ // --- 5. resolveIdentity, expired UAT (past expiry) ---
60
+ const c4 = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) - 100 });
61
+ invalidateIdentity(c4);
62
+ assert.equal(await resolveIdentity(c4), IdentityState.UAT_EXPIRED);
63
+
64
+ // --- 6. resolveIdentity cache: change underlying state, get cached value ---
65
+ invalidateIdentity(c1);
66
+ assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY);
67
+ c1._uat = 'token'; // simulate adopt-persisted-uat happened mid-flight
68
+ c1._uatExpires = Math.floor(Date.now() / 1000) + 3600;
69
+ // Still cached — 30s window not elapsed.
70
+ assert.equal(await resolveIdentity(c1), IdentityState.BOT_ONLY, 'cache should hold within 30s');
71
+ invalidateIdentity(c1);
72
+ assert.equal(await resolveIdentity(c1), IdentityState.VALID_USER, 'invalidate reads fresh state');
73
+
74
+ // --- 7. withIdentityFallback: UAT path succeeds ---
75
+ const cOk = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
76
+ invalidateIdentity(cOk);
77
+ const r1 = await withIdentityFallback({
78
+ client: cOk,
79
+ uatFn: async () => ({ code: 0, data: { ok: true } }),
80
+ botFn: async () => { throw new Error('should not run'); },
81
+ label: 'test_op',
82
+ });
83
+ assert.equal(r1.via, 'uat');
84
+ assert.equal(r1.identity, IdentityState.VALID_USER);
85
+ assert.equal(r1.data.code, 0);
86
+ assert.equal(r1.data.ok, undefined, 'should pass through fields, not double-wrap');
87
+ assert.equal(r1.data.data.ok, true);
88
+ assert.equal(r1.viaReason, undefined, 'no fallback → no via_reason');
89
+ // PR #103 Codex P1 followup: UAT success must set the legacy _viaUser=true
90
+ // marker so 15+ _asUserOrApp callsites (calendar/docs/bitable/wiki/okr/tasks
91
+ // /drive) report viaUser:true. Without this flag downstream code thinks the
92
+ // resource was created by the bot.
93
+ assert.equal(r1.data._viaUser, true, 'UAT success path must mark _viaUser=true on response');
94
+
95
+ // --- 8. withIdentityFallback: UAT returns 20064 → bot fallback, identity refined ---
96
+ let botRan = false;
97
+ const cRevoked = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
98
+ invalidateIdentity(cRevoked);
99
+ const r2 = await withIdentityFallback({
100
+ client: cRevoked,
101
+ uatFn: async () => ({ code: 20064, msg: 'invalid_grant' }),
102
+ botFn: async () => { botRan = true; return { code: 0, data: { from: 'bot' } }; },
103
+ label: 'test_revoked',
104
+ });
105
+ assert.equal(botRan, true);
106
+ assert.equal(r2.via, 'bot');
107
+ assert.equal(r2.identity, IdentityState.UAT_REVOKED, 'identity refined on 20064');
108
+ assert.ok(typeof r2.viaReason === 'string' && r2.viaReason.includes('20064'));
109
+ assert.ok(r2.fallbackWarning && r2.fallbackWarning.includes('UAT'));
110
+
111
+ // Subsequent resolveIdentity sees the refined cached state without re-probe.
112
+ assert.equal(await resolveIdentity(cRevoked), IdentityState.UAT_REVOKED);
113
+
114
+ // --- 9. withIdentityFallback: UAT 99991668 → UAT_MISSING_SCOPE ---
115
+ const cScope = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
116
+ invalidateIdentity(cScope);
117
+ const r3 = await withIdentityFallback({
118
+ client: cScope,
119
+ uatFn: async () => ({ code: 99991668, msg: 'scope not granted' }),
120
+ botFn: async () => ({ code: 0, data: { ok: true } }),
121
+ label: 'test_scope',
122
+ });
123
+ assert.equal(r3.identity, IdentityState.UAT_MISSING_SCOPE);
124
+ assert.equal(r3.via, 'bot');
125
+
126
+ // --- 10. withIdentityFallback: UAT throws → bot fallback ---
127
+ const cThrow = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
128
+ invalidateIdentity(cThrow);
129
+ const r4 = await withIdentityFallback({
130
+ client: cThrow,
131
+ uatFn: async () => { throw new Error('network blew up'); },
132
+ botFn: async () => ({ code: 0, data: { ok: 'bot' } }),
133
+ label: 'test_throw',
134
+ });
135
+ assert.equal(r4.via, 'bot');
136
+ assert.ok(r4.viaReason.includes('network blew up'));
137
+
138
+ // --- 11. withIdentityFallback: BOT_ONLY → no uat attempted, informational warning ---
139
+ const cBotOnly = fakeClient({ hasUAT: false, appId: 'cli_x' });
140
+ invalidateIdentity(cBotOnly);
141
+ let uatRan = false;
142
+ const r5 = await withIdentityFallback({
143
+ client: cBotOnly,
144
+ uatFn: async () => { uatRan = true; return { code: 0 }; },
145
+ botFn: async () => ({ code: 0, data: { ok: 'bot' } }),
146
+ label: 'test_botonly',
147
+ });
148
+ assert.equal(uatRan, false, 'BOT_ONLY must not invoke uatFn');
149
+ assert.equal(r5.via, 'bot');
150
+ assert.equal(r5.identity, IdentityState.BOT_ONLY);
151
+ // BOT_ONLY still attaches the legacy "未配置 UAT" informational warning so
152
+ // users notice that resources will be owned by the shared bot.
153
+ assert.ok(r5.fallbackWarning && r5.fallbackWarning.includes('未配置 UAT'));
154
+
155
+ // --- 12. withIdentityFallback: both sides fail → throws combined error ---
156
+ const cBoth = fakeClient({ hasUAT: true, expires: Math.floor(Date.now() / 1000) + 3600 });
157
+ invalidateIdentity(cBoth);
158
+ let caught;
159
+ try {
160
+ await withIdentityFallback({
161
+ client: cBoth,
162
+ uatFn: async () => ({ code: 99991668, msg: 'no scope' }),
163
+ botFn: async () => { throw new Error('bot not in chat'); },
164
+ label: 'test_both_fail',
165
+ });
166
+ } catch (e) { caught = e; }
167
+ assert.ok(caught, 'both-fail should throw');
168
+ assert.ok(caught.message.includes('test_both_fail'));
169
+ assert.ok(caught.message.includes('bot not in chat'));
170
+ assert.ok(caught.uatSummary, 'combined error carries uatSummary');
171
+ assert.ok(caught.botError, 'combined error carries botError');
172
+
173
+ console.log('identity-state.js: PASS');
174
+ }
175
+
176
+ if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
177
+ module.exports = { run };
@@ -298,3 +298,4 @@ function run() {
298
298
  if (require.main === module) {
299
299
  run();
300
300
  }
301
+ module.exports = { run };
@@ -0,0 +1,90 @@
1
+ // src/test-lockfile-pid.js — verify acquireLongLived's v1.3.12 PID
2
+ // liveness check.
3
+ //
4
+ // Pre-v1.3.12 the lock was judged "alive" purely by mtime: heartbeat every
5
+ // 15s, stale after 60s. If the owner process got SIGKILL'd (or crashed
6
+ // mid-heartbeat), the lock looked alive for up to 60s. With the WS event
7
+ // subscription tied to the lock, a hung owner blocked event ingestion for
8
+ // the entire window.
9
+ //
10
+ // New behaviour: when stat says mtime is fresh, ALSO read the lock body and
11
+ // `process.kill(pid, 0)`. If ESRCH → process is gone, lock is steal-eligible
12
+ // immediately regardless of mtime.
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const assert = require('node:assert/strict');
20
+ const { acquireLongLived } = require('./events/lockfile');
21
+
22
+ function run() {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-pid-lock-'));
24
+ const lockPath = path.join(dir, 'test.lock');
25
+
26
+ // --- 1. Write a lock body for a pid that definitely doesn't exist.
27
+ // PID 1 is init/launchd — always exists; we need a pid that's
28
+ // certainly gone. Use a huge integer well outside the kernel's
29
+ // normal range; the most portable check is process.kill(pid, 0).
30
+ const fakePid = 999_999_999;
31
+ const body = JSON.stringify({ version: 1, pid: fakePid, start_time: Math.floor(Date.now() / 1000), role: 'test_dead_owner' });
32
+ fs.writeFileSync(lockPath, body, { mode: 0o600 });
33
+ // Fresh mtime — pre-v1.3.12 this would block acquisition for 60s.
34
+ fs.utimesSync(lockPath, new Date(), new Date());
35
+
36
+ const handle = acquireLongLived(lockPath, { info: { role: 'new_owner' }, staleMs: 60_000 });
37
+ assert.ok(handle, 'should be able to steal lock when holder pid is dead, even when mtime is fresh');
38
+
39
+ // Read body — should now contain THIS process's pid.
40
+ const newBody = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
41
+ assert.equal(newBody.pid, process.pid);
42
+
43
+ handle.release();
44
+
45
+ // --- 2. Live pid (this process) prevents acquisition even past staleMs
46
+ // when content shows we're still alive — but for safety we treat
47
+ // EPERM (different user, can't probe) as alive too. Skip EPERM
48
+ // case since it requires multi-user setup.
49
+ const livePid = process.pid;
50
+ const liveBody = JSON.stringify({ version: 1, pid: livePid, start_time: Math.floor(Date.now() / 1000), role: 'live_owner' });
51
+ fs.writeFileSync(lockPath, liveBody, { mode: 0o600 });
52
+ fs.utimesSync(lockPath, new Date(), new Date());
53
+ const blocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
54
+ assert.equal(blocked, null, 'live pid + fresh mtime → cannot steal');
55
+ fs.unlinkSync(lockPath); // cleanup
56
+
57
+ // --- 3. Stale mtime + live pid: previously would have stolen; new behaviour
58
+ // keeps the steal because mtime says heartbeat is dead.
59
+ // (We don't try to second-guess a stuck process — mtime is the
60
+ // primary signal; PID check only adds the ability to reclaim
61
+ // FASTER when process is definitively dead.)
62
+ fs.writeFileSync(lockPath, JSON.stringify({ version: 1, pid: livePid, start_time: Math.floor(Date.now() / 1000) - 999 }), { mode: 0o600 });
63
+ // Backdate mtime well past staleMs.
64
+ const backdate = new Date(Date.now() - 120_000);
65
+ fs.utimesSync(lockPath, backdate, backdate);
66
+ const stolenLive = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
67
+ assert.ok(stolenLive, 'stale mtime should still allow takeover (back-compat)');
68
+ stolenLive.release();
69
+
70
+ // --- 4. Body missing pid field (legacy locks from older versions) — fall
71
+ // back to mtime-only check (existing behaviour, no regression).
72
+ fs.writeFileSync(lockPath, JSON.stringify({ version: 1 }), { mode: 0o600 });
73
+ fs.utimesSync(lockPath, new Date(), new Date());
74
+ const noPidBlocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
75
+ assert.equal(noPidBlocked, null, 'no pid in body → fall back to mtime, fresh mtime blocks');
76
+ fs.unlinkSync(lockPath);
77
+
78
+ // --- 5. Malformed lock body (not JSON) — mtime-only fallback.
79
+ fs.writeFileSync(lockPath, 'not json at all', { mode: 0o600 });
80
+ fs.utimesSync(lockPath, new Date(), new Date());
81
+ const malformedBlocked = acquireLongLived(lockPath, { info: {}, staleMs: 60_000 });
82
+ assert.equal(malformedBlocked, null, 'malformed body → fall back to mtime, fresh mtime blocks');
83
+ fs.unlinkSync(lockPath);
84
+
85
+ fs.rmSync(dir, { recursive: true, force: true });
86
+ console.log('lockfile-pid.js: PASS');
87
+ }
88
+
89
+ if (require.main === module) run();
90
+ module.exports = { run };
@@ -0,0 +1,145 @@
1
+ // src/test-lru-cache.js — unit test for the LRUCache class exported by src/utils.js.
2
+ //
3
+ // Replaces the v1.3.12 `new Map()` _userNameCache / _appNameCache. Pre-fix the
4
+ // caches grew unboundedly across the server's lifetime (one entry per unique
5
+ // open_id ever seen) and never expired — a 1-week-uptime MCP would carry
6
+ // stale display names from messages of users who renamed themselves days ago.
7
+ //
8
+ // LRU with TTL solves both:
9
+ // - max=500 caps the per-process memory at O(KiB) regardless of message volume
10
+ // - ttlMs=10min ensures rename / leave-tenant changes get re-resolved
11
+ //
12
+ // We test the basic operations + interactions between TTL and LRU.
13
+
14
+ 'use strict';
15
+
16
+ const assert = require('node:assert/strict');
17
+ const { LRUCache } = require('./utils');
18
+
19
+ async function run() {
20
+ // --- 1. set/get/has roundtrip ---
21
+ {
22
+ const c = new LRUCache({ max: 5, ttlMs: 1000 });
23
+ c.set('a', 1);
24
+ assert.equal(c.get('a'), 1);
25
+ assert.equal(c.has('a'), true);
26
+ assert.equal(c.get('missing'), undefined);
27
+ assert.equal(c.has('missing'), false);
28
+ assert.equal(c.size, 1);
29
+ }
30
+
31
+ // --- 2. LRU eviction: oldest dropped when over max ---
32
+ {
33
+ const c = new LRUCache({ max: 3, ttlMs: 60_000 });
34
+ c.set('a', 1);
35
+ c.set('b', 2);
36
+ c.set('c', 3);
37
+ c.set('d', 4); // evicts 'a'
38
+ assert.equal(c.has('a'), false);
39
+ assert.equal(c.get('b'), 2);
40
+ assert.equal(c.get('c'), 3);
41
+ assert.equal(c.get('d'), 4);
42
+ assert.equal(c.size, 3);
43
+ }
44
+
45
+ // --- 3. Access promotes recency: get prevents eviction ---
46
+ {
47
+ const c = new LRUCache({ max: 3, ttlMs: 60_000 });
48
+ c.set('a', 1);
49
+ c.set('b', 2);
50
+ c.set('c', 3);
51
+ c.get('a'); // 'a' becomes most-recently-used; 'b' is now LRU
52
+ c.set('d', 4); // evicts 'b'
53
+ assert.equal(c.has('a'), true);
54
+ assert.equal(c.has('b'), false);
55
+ assert.equal(c.has('c'), true);
56
+ assert.equal(c.has('d'), true);
57
+ }
58
+
59
+ // --- 4. TTL expiry: get returns undefined for expired ---
60
+ {
61
+ const c = new LRUCache({ max: 10, ttlMs: 50 });
62
+ c.set('a', 1);
63
+ assert.equal(c.get('a'), 1);
64
+ await new Promise(r => setTimeout(r, 80));
65
+ assert.equal(c.get('a'), undefined, 'expired entries return undefined');
66
+ assert.equal(c.has('a'), false);
67
+ // Expired entry is purged: size drops.
68
+ assert.equal(c.size, 0);
69
+ }
70
+
71
+ // --- 5. Setting an existing key refreshes TTL ---
72
+ {
73
+ const c = new LRUCache({ max: 10, ttlMs: 100 });
74
+ c.set('a', 1);
75
+ await new Promise(r => setTimeout(r, 60));
76
+ c.set('a', 1); // refresh
77
+ await new Promise(r => setTimeout(r, 60));
78
+ // 120ms total since first set, only 60ms since refresh — still valid.
79
+ assert.equal(c.get('a'), 1);
80
+ }
81
+
82
+ // --- 6. delete + clear ---
83
+ {
84
+ const c = new LRUCache({ max: 5, ttlMs: 1000 });
85
+ c.set('a', 1);
86
+ c.set('b', 2);
87
+ c.delete('a');
88
+ assert.equal(c.has('a'), false);
89
+ assert.equal(c.size, 1);
90
+ c.clear();
91
+ assert.equal(c.size, 0);
92
+ assert.equal(c.has('b'), false);
93
+ }
94
+
95
+ // --- 7. Map-compatible shim (we replace `new Map()` in base.js — the
96
+ // existing call sites use .has / .get / .set / .clear, which the class
97
+ // implements identically. Smoke-check parity here.) ---
98
+ {
99
+ const c = new LRUCache({ max: 5, ttlMs: 1000 });
100
+ c.set('open_x', 'Alice');
101
+ assert.equal(c.has('open_x'), true);
102
+ assert.equal(c.get('open_x'), 'Alice');
103
+ }
104
+
105
+ // --- 8. Iteration support: the comment claims "API-compatible with the
106
+ // old Map", so spread / for-of / entries / keys / values must all work.
107
+ // Map is iterable via Symbol.iterator yielding [key, value] tuples.
108
+ {
109
+ const c = new LRUCache({ max: 5, ttlMs: 60_000 });
110
+ c.set('a', 1);
111
+ c.set('b', 2);
112
+ c.set('c', 3);
113
+ const collected = [...c];
114
+ assert.equal(collected.length, 3);
115
+ // Map insertion order, [key, value] tuples.
116
+ assert.deepEqual(collected[0], ['a', 1]);
117
+ assert.deepEqual(collected[2], ['c', 3]);
118
+
119
+ const forOfKeys = [];
120
+ for (const [k] of c) forOfKeys.push(k);
121
+ assert.deepEqual(forOfKeys, ['a', 'b', 'c']);
122
+
123
+ assert.deepEqual([...c.keys()], ['a', 'b', 'c']);
124
+ assert.deepEqual([...c.values()], [1, 2, 3]);
125
+ assert.deepEqual([...c.entries()], [['a', 1], ['b', 2], ['c', 3]]);
126
+ }
127
+
128
+ // --- 9. Iteration skips expired entries (TTL gate is consistent across
129
+ // get/has and iteration so callers don't see stale data via spread).
130
+ {
131
+ const c = new LRUCache({ max: 5, ttlMs: 50 });
132
+ c.set('a', 1);
133
+ c.set('b', 2);
134
+ await new Promise(r => setTimeout(r, 80));
135
+ c.set('c', 3); // fresh after expiry of a/b
136
+ const collected = [...c];
137
+ assert.equal(collected.length, 1);
138
+ assert.deepEqual(collected[0], ['c', 3]);
139
+ }
140
+
141
+ console.log('lru-cache.js: PASS');
142
+ }
143
+
144
+ if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
145
+ module.exports = { run };
@@ -0,0 +1,85 @@
1
+ // src/test-negative-cache.js — verify _populateSenderNames writes a null
2
+ // sentinel for un-resolvable open_ids so repeated read_messages calls
3
+ // don't re-fire the same contact API request.
4
+ //
5
+ // Pre-fix bug: contacts.js::getUserById returned null on failure without
6
+ // writing to _userNameCache, so every subsequent _populateSenderNames
7
+ // invocation re-added the same id to unknownUserIds and dispatched another
8
+ // API call. In a hot chat with N un-resolvable senders that's N redundant
9
+ // API calls per read_messages — observed in the 2026-05 incident's stderr.
10
+ //
11
+ // Fix lives in _populateSenderNames itself (not in contacts.js): after each
12
+ // Promise.allSettled batch, ids still absent from _userNameCache get a null
13
+ // sentinel written. has(id)==true / get(id)==null on next call → the loop
14
+ // skips the id entirely, _computeDisplayLabel falls back to "(open_id)"
15
+ // the same way it would have without a cache.
16
+
17
+ 'use strict';
18
+
19
+ const assert = require('node:assert/strict');
20
+ const { LarkOfficialClient } = require('./clients/official');
21
+
22
+ async function run() {
23
+ const c = new LarkOfficialClient('cli_test', 'fake_secret');
24
+ // Stub self tenant probe so the real one doesn't hit Feishu.
25
+ c._resolveSelfTenantKey = async () => 'tenant_self';
26
+ c._selfTenantKey = 'tenant_self';
27
+
28
+ // Mock getUserById to simulate a un-resolvable user: cache nothing,
29
+ // return null. The fix in _populateSenderNames is what should write the
30
+ // null sentinel afterwards.
31
+ let userCallCount = 0;
32
+ c.getUserById = async (userId) => {
33
+ if (c._userNameCache.has(userId)) return c._userNameCache.get(userId);
34
+ userCallCount++;
35
+ return null;
36
+ };
37
+
38
+ // Same for getAppName.
39
+ let appCallCount = 0;
40
+ c.getAppName = async (appId) => {
41
+ if (c._appNameCache.has(appId)) return c._appNameCache.get(appId);
42
+ appCallCount++;
43
+ return null;
44
+ };
45
+
46
+ // --- 1. User negative-cache: un-resolvable ou_bad ---
47
+ const items1 = [{ senderId: 'ou_bad', senderType: 'user' }];
48
+ await c._populateSenderNames(items1, null);
49
+ assert.equal(userCallCount, 1, 'first populate dispatches one API call');
50
+ assert.equal(items1[0].senderName, null);
51
+ assert.equal(items1[0].displayLabel, '(ou_bad)');
52
+
53
+ // Cache must now hold a null sentinel.
54
+ assert.equal(c._userNameCache.has('ou_bad'), true, 'null sentinel written for un-resolvable id');
55
+ assert.equal(c._userNameCache.get('ou_bad'), null);
56
+
57
+ // --- 2. Same id, second populate → cache hit, no new API call ---
58
+ const items2 = [{ senderId: 'ou_bad', senderType: 'user' }];
59
+ await c._populateSenderNames(items2, null);
60
+ assert.equal(userCallCount, 1, 'cached null skips dispatch');
61
+ assert.equal(items2[0].displayLabel, '(ou_bad)');
62
+
63
+ // --- 3. Mixed batch: new id triggers exactly one new call ---
64
+ const items3 = [
65
+ { senderId: 'ou_bad', senderType: 'user' },
66
+ { senderId: 'ou_new', senderType: 'user' },
67
+ ];
68
+ await c._populateSenderNames(items3, null);
69
+ assert.equal(userCallCount, 2, 'new id alone dispatches one new call');
70
+
71
+ // --- 4. App negative-cache: un-resolvable cli_bad ---
72
+ const items4 = [{ senderId: 'cli_bad', senderType: 'app' }];
73
+ await c._populateSenderNames(items4, null);
74
+ assert.equal(appCallCount, 1);
75
+ assert.equal(items4[0].displayLabel, '[Bot] (cli_bad)');
76
+
77
+ const items5 = [{ senderId: 'cli_bad', senderType: 'app' }];
78
+ await c._populateSenderNames(items5, null);
79
+ assert.equal(appCallCount, 1, 'cached null app skips dispatch');
80
+
81
+ console.log('negative-cache.js: PASS');
82
+ }
83
+
84
+ if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
85
+ module.exports = { run };