feishu-user-plugin 1.3.13 → 1.3.15

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.
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for the UAT lifecycle building blocks in src/auth/uat.js.
3
+ // Covers v1.3.14 hardening:
4
+ // - decodeTokenExpiry: malformed JWT → 0 with stderr breadcrumb (no throw)
5
+ // - acquireRefreshLock: basic acquire / contention timeout / stale recovery
6
+ // - releaseRefreshLock: tolerant of already-released
7
+ // - adoptPersistedUATIfNewer: peer-rotation adoption logic
8
+ // - refreshUAT: invalid_grant → err.uatRevoked = true (via mocked fetch)
9
+ // - refreshUAT: success path persists + adopts new token
10
+ //
11
+ // These are pure-unit; no live Feishu calls.
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const assert = require('assert');
19
+
20
+ let pass = 0;
21
+ let fail = 0;
22
+
23
+ function ok(name, fn) {
24
+ try {
25
+ const r = fn();
26
+ if (r && typeof r.then === 'function') {
27
+ return r.then(() => { console.log(` OK ${name}`); pass++; },
28
+ (e) => { console.log(` FAIL ${name}: ${e.message}`); fail++; });
29
+ }
30
+ console.log(` OK ${name}`);
31
+ pass++;
32
+ } catch (e) {
33
+ console.log(` FAIL ${name}: ${e.message}`);
34
+ fail++;
35
+ }
36
+ }
37
+
38
+ async function run() {
39
+ console.log('=== test-uat-lifecycle ===');
40
+
41
+ const uat = require('./auth/uat');
42
+
43
+ // --- decodeTokenExpiry ---
44
+ await ok('decodeTokenExpiry: well-formed JWT returns exp', () => {
45
+ // Hand-craft a JWT with exp=123456 (no signature; we only read payload).
46
+ const payload = Buffer.from(JSON.stringify({ exp: 123456 }), 'utf8').toString('base64url');
47
+ const token = `header.${payload}.sig`;
48
+ assert.strictEqual(uat.decodeTokenExpiry(token), 123456);
49
+ });
50
+
51
+ await ok('decodeTokenExpiry: missing payload returns 0', () => {
52
+ assert.strictEqual(uat.decodeTokenExpiry('only-header'), 0);
53
+ });
54
+
55
+ await ok('decodeTokenExpiry: malformed base64 returns 0 (with stderr breadcrumb)', () => {
56
+ // Capture stderr so the breadcrumb doesn't pollute test output. We don't
57
+ // assert on the message — just that the function doesn't throw and returns 0.
58
+ const origErr = console.error;
59
+ const captured = [];
60
+ console.error = (...args) => captured.push(args.join(' '));
61
+ try {
62
+ const v = uat.decodeTokenExpiry('header.not-base64-payload!!.sig');
63
+ assert.strictEqual(v, 0);
64
+ // We don't strictly require a breadcrumb (silent return 0 was the v1.3.13
65
+ // behavior; v1.3.14 added stderr log). Test passes either way.
66
+ } finally {
67
+ console.error = origErr;
68
+ }
69
+ });
70
+
71
+ await ok('decodeTokenExpiry: payload without exp returns 0', () => {
72
+ const payload = Buffer.from(JSON.stringify({ sub: 'u_xxx' }), 'utf8').toString('base64url');
73
+ assert.strictEqual(uat.decodeTokenExpiry(`h.${payload}.s`), 0);
74
+ });
75
+
76
+ // v1.3.14 — flood-gate test: a persistently-malformed JWT should only log
77
+ // once per distinct token, not on every call. Without the gate, every
78
+ // getValidUAT call (= every UAT-backed tool dispatch) flooded stderr.
79
+ await ok('decodeTokenExpiry: same malformed token logs only once across repeated calls', () => {
80
+ const origErr = console.error;
81
+ const captured = [];
82
+ console.error = (...args) => captured.push(args.join(' '));
83
+ try {
84
+ const badToken = 'header.malformed-payload-XX-not-base64.sig-1';
85
+ for (let i = 0; i < 5; i++) uat.decodeTokenExpiry(badToken);
86
+ const decodeWarnings = captured.filter(l => /decodeTokenExpiry: malformed/.test(l));
87
+ assert.strictEqual(decodeWarnings.length, 1,
88
+ `expected exactly 1 decode warning for the same bad token across 5 calls; got ${decodeWarnings.length}: ${JSON.stringify(decodeWarnings)}`);
89
+ } finally {
90
+ console.error = origErr;
91
+ }
92
+ });
93
+
94
+ await ok('decodeTokenExpiry: different malformed tokens each log once', () => {
95
+ const origErr = console.error;
96
+ const captured = [];
97
+ console.error = (...args) => captured.push(args.join(' '));
98
+ try {
99
+ uat.decodeTokenExpiry('header.bad-A!!!XX.sig');
100
+ uat.decodeTokenExpiry('header.bad-B@@@YY.sig');
101
+ uat.decodeTokenExpiry('header.bad-C###ZZ.sig');
102
+ const decodeWarnings = captured.filter(l => /decodeTokenExpiry: malformed/.test(l));
103
+ assert.strictEqual(decodeWarnings.length, 3,
104
+ `expected one warning per distinct bad token; got ${decodeWarnings.length}`);
105
+ } finally {
106
+ console.error = origErr;
107
+ }
108
+ });
109
+
110
+ // --- acquireRefreshLock / releaseRefreshLock ---
111
+
112
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-uat-lifecycle-'));
113
+ const lockPath = path.join(tmpDir, 'test.lock');
114
+
115
+ await ok('acquireRefreshLock: fresh dir, lock acquires', async () => {
116
+ const got = await uat.acquireRefreshLock(lockPath, { timeoutMs: 1000 });
117
+ assert.strictEqual(got, true);
118
+ assert.strictEqual(fs.existsSync(lockPath), true);
119
+ uat.releaseRefreshLock(lockPath);
120
+ assert.strictEqual(fs.existsSync(lockPath), false);
121
+ });
122
+
123
+ await ok('acquireRefreshLock: contention times out', async () => {
124
+ const got1 = await uat.acquireRefreshLock(lockPath, { timeoutMs: 1000 });
125
+ assert.strictEqual(got1, true);
126
+ try {
127
+ const got2 = await uat.acquireRefreshLock(lockPath, { timeoutMs: 500, pollMs: 100, staleMs: 60_000 });
128
+ assert.strictEqual(got2, false, 'second acquire should fail while first holds');
129
+ } finally {
130
+ uat.releaseRefreshLock(lockPath);
131
+ }
132
+ });
133
+
134
+ await ok('acquireRefreshLock: stale lock recovers', async () => {
135
+ // Write a "stale" lock by setting mtime in the past.
136
+ fs.writeFileSync(lockPath, `${process.pid}\n${Date.now() - 60_000}\n`);
137
+ const oldTime = Date.now() - 60_000;
138
+ fs.utimesSync(lockPath, oldTime / 1000, oldTime / 1000);
139
+ // staleMs=5s — our lock is 60s old, should be detected and stolen.
140
+ const got = await uat.acquireRefreshLock(lockPath, { timeoutMs: 2000, staleMs: 5_000, pollMs: 100 });
141
+ assert.strictEqual(got, true, 'should steal stale lock');
142
+ uat.releaseRefreshLock(lockPath);
143
+ });
144
+
145
+ await ok('releaseRefreshLock: tolerant of already-released', () => {
146
+ // Should not throw.
147
+ uat.releaseRefreshLock(lockPath);
148
+ uat.releaseRefreshLock(path.join(tmpDir, 'never-existed.lock'));
149
+ });
150
+
151
+ // --- adoptPersistedUATIfNewer ---
152
+ //
153
+ // Uses a tmp HOME so we don't touch the real canonical store. We monkey-patch
154
+ // os.homedir to point at our tmp.
155
+
156
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-home-'));
157
+ fs.mkdirSync(path.join(fakeHome, '.feishu-user-plugin'), { recursive: true, mode: 0o700 });
158
+ const fakeCanonical = path.join(fakeHome, '.feishu-user-plugin', 'credentials.json');
159
+ const origHomedir = os.homedir;
160
+ os.homedir = () => fakeHome;
161
+
162
+ // Snapshot + clear LARK_* env vars so legacy fallback inside readCredentials
163
+ // can't pick them up from the host process. Pre-v1.3.14 these tests passed
164
+ // standalone but failed in `npm test` because test-all.js v1.3.14 backfill
165
+ // populates process.env from the real canonical store.
166
+ const SNAP_KEYS = ['LARK_COOKIE', 'LARK_APP_ID', 'LARK_APP_SECRET',
167
+ 'LARK_USER_ACCESS_TOKEN', 'LARK_USER_REFRESH_TOKEN',
168
+ 'LARK_UAT_EXPIRES', 'LARK_UAT_SCOPE', 'LARK_PROFILES_JSON'];
169
+ const envSnapshot = {};
170
+ for (const k of SNAP_KEYS) { envSnapshot[k] = process.env[k]; delete process.env[k]; }
171
+
172
+ function writeCanonical(env) {
173
+ fs.writeFileSync(fakeCanonical, JSON.stringify({
174
+ version: 1,
175
+ active: 'default',
176
+ profiles: { default: env },
177
+ profileHints: {},
178
+ }, null, 2));
179
+ fs.chmodSync(fakeCanonical, 0o600);
180
+ }
181
+
182
+ try {
183
+ await ok('adoptPersistedUATIfNewer: no canonical → false', () => {
184
+ // ensure no file
185
+ try { fs.unlinkSync(fakeCanonical); } catch (_) {}
186
+ const client = { _uat: null, _uatRefresh: null, _uatExpires: 0 };
187
+ const r = uat.adoptPersistedUATIfNewer(client);
188
+ assert.strictEqual(r, false);
189
+ });
190
+
191
+ await ok('adoptPersistedUATIfNewer: same token → false', () => {
192
+ writeCanonical({
193
+ LARK_USER_ACCESS_TOKEN: 'same.token.value',
194
+ LARK_USER_REFRESH_TOKEN: 'same.refresh.value',
195
+ LARK_UAT_EXPIRES: 5000,
196
+ });
197
+ const client = { _uat: 'same.token.value', _uatRefresh: 'same.refresh.value', _uatExpires: 5000 };
198
+ const r = uat.adoptPersistedUATIfNewer(client);
199
+ assert.strictEqual(r, false);
200
+ });
201
+
202
+ await ok('adoptPersistedUATIfNewer: newer access token → adopts', () => {
203
+ writeCanonical({
204
+ LARK_USER_ACCESS_TOKEN: 'new.access.token',
205
+ LARK_USER_REFRESH_TOKEN: 'old.refresh',
206
+ LARK_UAT_EXPIRES: 9999,
207
+ });
208
+ const client = { _uat: 'old.access', _uatRefresh: 'old.refresh', _uatExpires: 5000 };
209
+ const r = uat.adoptPersistedUATIfNewer(client);
210
+ assert.strictEqual(r, true);
211
+ assert.strictEqual(client._uat, 'new.access.token');
212
+ assert.strictEqual(client._uatExpires, 9999);
213
+ });
214
+
215
+ await ok('adoptPersistedUATIfNewer: rotated refresh_token → adopts', () => {
216
+ writeCanonical({
217
+ LARK_USER_ACCESS_TOKEN: 'same.access',
218
+ LARK_USER_REFRESH_TOKEN: 'rotated.refresh',
219
+ LARK_UAT_EXPIRES: 5000,
220
+ });
221
+ const client = { _uat: 'same.access', _uatRefresh: 'old.refresh', _uatExpires: 5000 };
222
+ const r = uat.adoptPersistedUATIfNewer(client);
223
+ assert.strictEqual(r, true);
224
+ assert.strictEqual(client._uatRefresh, 'rotated.refresh');
225
+ });
226
+ } finally {
227
+ os.homedir = origHomedir;
228
+ // Restore the LARK_* env vars we cleared for this test.
229
+ for (const k of SNAP_KEYS) {
230
+ if (envSnapshot[k] === undefined) delete process.env[k];
231
+ else process.env[k] = envSnapshot[k];
232
+ }
233
+ }
234
+
235
+ // --- refreshUAT: invalid_grant → err.uatRevoked = true ---
236
+ //
237
+ // Monkey-patch global.fetch to simulate Feishu refresh responses without
238
+ // touching the network. fetchWithTimeout in utils.js delegates to global.fetch.
239
+
240
+ await ok('refreshUAT: invalid_grant throws err.uatRevoked=true', async () => {
241
+ os.homedir = () => fakeHome;
242
+ writeCanonical({
243
+ LARK_USER_ACCESS_TOKEN: 'expired.token',
244
+ LARK_USER_REFRESH_TOKEN: 'dead.refresh',
245
+ LARK_UAT_EXPIRES: Math.floor(Date.now() / 1000) - 3600, // 1h ago
246
+ });
247
+
248
+ const origFetch = global.fetch;
249
+ global.fetch = async () => ({
250
+ json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }),
251
+ });
252
+
253
+ try {
254
+ const client = {
255
+ appId: 'test', appSecret: 'test',
256
+ _uat: 'expired.token',
257
+ _uatRefresh: 'dead.refresh',
258
+ _uatExpires: Math.floor(Date.now() / 1000) - 3600,
259
+ };
260
+ let thrown = null;
261
+ try {
262
+ await uat.refreshUAT(client);
263
+ } catch (e) {
264
+ thrown = e;
265
+ }
266
+ assert.ok(thrown, 'refreshUAT should throw');
267
+ assert.strictEqual(thrown.uatRevoked, true, 'err.uatRevoked must be true');
268
+ assert.ok(thrown.message.includes('invalid_grant') || thrown.message.includes('refresh_token'),
269
+ `error message should reference invalid_grant: ${thrown.message}`);
270
+ // Critically: error message must NOT contain the raw response body /
271
+ // refresh_token bytes. v1.3.14 redact regression guard.
272
+ assert.ok(!thrown.message.includes('dead.refresh'), 'error message must not echo refresh_token');
273
+ } finally {
274
+ global.fetch = origFetch;
275
+ os.homedir = origHomedir;
276
+ }
277
+ });
278
+
279
+ // --- refreshUAT: benign rotation race must self-heal, not false-revoke ---
280
+ //
281
+ // A peer process wins the refresh_token rotation and persists a fresh, VALID
282
+ // token to disk DURING our refresh round-trip; our now-stale refresh_token
283
+ // then comes back invalid_grant. refreshUAT must adopt the peer's on-disk
284
+ // token and recover, instead of throwing uatRevoked (which would push the
285
+ // user through a needless `npx feishu-user-plugin oauth` re-consent — the
286
+ // root cause of the "授权操作通知 没撑过一晚上" reports).
287
+ await ok('refreshUAT: invalid_grant + peer rotated fresh token to disk → adopts & recovers (no false revoke)', async () => {
288
+ os.homedir = () => fakeHome;
289
+ const now = Math.floor(Date.now() / 1000);
290
+ // Disk starts equal to our stale in-memory token so the pre-fetch adopt
291
+ // checks find nothing newer and we proceed into the refresh fetch.
292
+ writeCanonical({
293
+ LARK_USER_ACCESS_TOKEN: 'stale.access',
294
+ LARK_USER_REFRESH_TOKEN: 'stale.refresh',
295
+ LARK_UAT_EXPIRES: String(now - 3600),
296
+ });
297
+
298
+ const origFetch = global.fetch;
299
+ global.fetch = async () => {
300
+ // The winning peer finishes mid-round-trip: persists a fresh VALID token.
301
+ writeCanonical({
302
+ LARK_USER_ACCESS_TOKEN: 'winner.access',
303
+ LARK_USER_REFRESH_TOKEN: 'winner.refresh',
304
+ LARK_UAT_EXPIRES: String(now + 7200),
305
+ });
306
+ // Our stale refresh_token was rotated away on the Feishu side.
307
+ return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
308
+ };
309
+
310
+ try {
311
+ const client = {
312
+ appId: 'test', appSecret: 'test',
313
+ _uat: 'stale.access',
314
+ _uatRefresh: 'stale.refresh',
315
+ _uatExpires: now - 3600,
316
+ };
317
+ let thrown = null, ret = null;
318
+ try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
319
+ assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
320
+ assert.strictEqual(ret, 'winner.access', 'should return the peer-rotated access token');
321
+ assert.strictEqual(client._uatRefresh, 'winner.refresh', 'should adopt the peer-rotated refresh_token');
322
+ } finally {
323
+ global.fetch = origFetch;
324
+ os.homedir = origHomedir;
325
+ }
326
+ });
327
+
328
+ // Codex review (PR #111) edge: a peer in-process refresh / credentials-monitor
329
+ // hot-reloads THIS client in memory (and persists to disk) WHILE our refresh
330
+ // request — carrying the now-stale token — is still in flight. Because we
331
+ // snapshot the sent token before awaiting and gate recovery on client state
332
+ // (not adoptPersistedUATIfNewer's return value), we must still recover instead
333
+ // of false-revoking. With the pre-fix code (post-await capture) this throws.
334
+ await ok('refreshUAT: invalid_grant after mid-flight hot-reload to a fresh token → recovers (no false revoke)', async () => {
335
+ os.homedir = () => fakeHome;
336
+ const now = Math.floor(Date.now() / 1000);
337
+ // Disk starts stale so the pre-fetch adopt checks proceed into the fetch.
338
+ writeCanonical({
339
+ LARK_USER_ACCESS_TOKEN: 'stale.access',
340
+ LARK_USER_REFRESH_TOKEN: 'stale.refresh',
341
+ LARK_UAT_EXPIRES: String(now - 3600),
342
+ });
343
+ const client = {
344
+ appId: 'test', appSecret: 'test',
345
+ _uat: 'stale.access', _uatRefresh: 'stale.refresh', _uatExpires: now - 3600,
346
+ };
347
+
348
+ const origFetch = global.fetch;
349
+ global.fetch = async () => {
350
+ // Concurrent winner finishes mid-flight: persists rotated token to disk
351
+ // AND a hot-reload updates this very client in memory.
352
+ writeCanonical({
353
+ LARK_USER_ACCESS_TOKEN: 'winner.access',
354
+ LARK_USER_REFRESH_TOKEN: 'winner.refresh',
355
+ LARK_UAT_EXPIRES: String(now + 7200),
356
+ });
357
+ client._uat = 'winner.access';
358
+ client._uatRefresh = 'winner.refresh';
359
+ client._uatExpires = now + 7200;
360
+ return { json: async () => ({ error: 'invalid_grant', error_description: 'refresh_token expired' }) };
361
+ };
362
+
363
+ try {
364
+ let thrown = null, ret = null;
365
+ try { ret = await uat.refreshUAT(client); } catch (e) { thrown = e; }
366
+ assert.ok(!thrown, `should recover, not throw; got: ${thrown && thrown.message}`);
367
+ assert.strictEqual(ret, 'winner.access', 'should recover the winner token despite mid-flight hot-reload');
368
+ } finally {
369
+ global.fetch = origFetch;
370
+ os.homedir = origHomedir;
371
+ }
372
+ });
373
+
374
+ // identity-state.js redact-regex regression guard. Exercises the actual
375
+ // regex on `_classifyUatFailure` path that the previous "no dead.refresh"
376
+ // assertion in test #14 did NOT cover (the invalid_grant message is
377
+ // hard-coded with no interpolation, so it's trivially redacted).
378
+ await ok('identity-state _classifyUatFailure: redact regex strips long token-like strings from uatError.message', () => {
379
+ const { _classifyUatFailure } = require('./auth/identity-state');
380
+ // A realistic-looking JWT-like base64-ish string > 40 chars.
381
+ const longToken = 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3Nzk0MDAwMDB9.abc123def456ghi789jklmnoXYZabcdefgXXXX';
382
+ assert.ok(longToken.length >= 40, 'fixture token should be >= 40 chars to trigger redact');
383
+ const fakeErr = new Error(`UAT request failed: server returned: ${longToken}`);
384
+ const cls = _classifyUatFailure(null, fakeErr);
385
+ assert.ok(cls, 'should classify a uatError');
386
+ assert.ok(!cls.viaReason.includes(longToken), `viaReason must not contain raw long token; got: ${cls.viaReason}`);
387
+ assert.ok(cls.viaReason.includes('<redacted>'), `viaReason must contain '<redacted>' marker; got: ${cls.viaReason}`);
388
+ assert.strictEqual(cls.state, null, 'non-uatRevoked errors should not refine identity from this path');
389
+ });
390
+
391
+ await ok('identity-state _classifyUatFailure: uatRevoked flag short-circuits to UAT_REVOKED state', () => {
392
+ const { _classifyUatFailure, IdentityState } = require('./auth/identity-state');
393
+ const err = new Error('UAT refresh_token rejected by Feishu (invalid_grant). The 7-day refresh chain is broken. Run: npx feishu-user-plugin oauth to re-authorize.');
394
+ err.uatRevoked = true;
395
+ const cls = _classifyUatFailure(null, err);
396
+ assert.strictEqual(cls.state, IdentityState.UAT_REVOKED, 'uatRevoked flag must refine state to UAT_REVOKED');
397
+ assert.ok(cls.viaReason.includes('invalid_grant') || cls.viaReason.includes('rejected'),
398
+ `viaReason must mention rejection: ${cls.viaReason}`);
399
+ });
400
+
401
+ await ok('refreshUAT: non-invalid_grant error does NOT set uatRevoked', async () => {
402
+ os.homedir = () => fakeHome;
403
+ writeCanonical({
404
+ LARK_USER_ACCESS_TOKEN: 'expired.token',
405
+ LARK_USER_REFRESH_TOKEN: 'live.refresh',
406
+ LARK_UAT_EXPIRES: Math.floor(Date.now() / 1000) - 3600,
407
+ });
408
+
409
+ const origFetch = global.fetch;
410
+ global.fetch = async () => ({
411
+ json: async () => ({ code: 99991663, msg: 'token transient error' }),
412
+ });
413
+
414
+ try {
415
+ const client = {
416
+ appId: 'test', appSecret: 'test',
417
+ _uat: 'expired.token',
418
+ _uatRefresh: 'live.refresh',
419
+ _uatExpires: Math.floor(Date.now() / 1000) - 3600,
420
+ };
421
+ let thrown = null;
422
+ try {
423
+ await uat.refreshUAT(client);
424
+ } catch (e) {
425
+ thrown = e;
426
+ }
427
+ assert.ok(thrown, 'should throw on non-success');
428
+ assert.ok(!thrown.uatRevoked, 'transient error should not set uatRevoked');
429
+ assert.ok(thrown.message.includes('99991663') || thrown.message.includes('transient'),
430
+ `should mention specific error: ${thrown.message}`);
431
+ } finally {
432
+ global.fetch = origFetch;
433
+ os.homedir = origHomedir;
434
+ }
435
+ });
436
+
437
+ // --- Cleanup ---
438
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
439
+ try { fs.rmSync(fakeHome, { recursive: true, force: true }); } catch (_) {}
440
+
441
+ console.log(`\n=== test-uat-lifecycle: ${pass} passed, ${fail} failed ===`);
442
+ if (fail > 0) process.exit(1);
443
+ }
444
+
445
+ if (require.main === module) {
446
+ run().catch((e) => { console.error('test-uat-lifecycle harness error:', e); process.exit(1); });
447
+ }
448
+
449
+ module.exports = { run };
package/src/tools/docs.js CHANGED
@@ -52,7 +52,7 @@ const schemas = [
52
52
  },
53
53
  {
54
54
  name: 'manage_doc_block',
55
- description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create — five modes:\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]).\n (B) Image from local file — pass `image_path`; plugin uploads and patches.\n (C) Image from token — pass `image_token` (already uploaded).\n (D) File attachment from local file — pass `file_path`; plugin handles VIEW-wrap + replace_file.\n (E) File from token — pass `file_token`.\n action=update — generic (pass `update_body`), image-replace (pass `image_token`), or file-replace (pass `file_token`).\n action=delete — pass `parent_block_id` + `start_index` + `end_index` (range delete).\n`document_id` accepts native ID, wiki node token, or Feishu URL.',
55
+ description: '[Official API] Manage content blocks in a document. Single tool replaces v1.3.6 create_doc_block / update_doc_block / delete_doc_blocks.\n action=create — six modes (pass exactly ONE):\n (A) Generic — pass `children` array (e.g. [{block_type:2, text:{...}}]).\n (B) Image from local file — pass `image_path`; plugin uploads and patches.\n (C) Image from token — pass `image_token` (already uploaded).\n (D) File attachment from local file — pass `file_path`; plugin handles VIEW-wrap + replace_file.\n (E) File from token — pass `file_token`.\n (F) Table — pass `table={rows,columns,cells?}`; plugin creates a block_type=31 table (Feishu auto-makes the block_type=32 cells) and fills each provided cell. USE THIS for tables — do NOT hand-build table blocks via `children` (the table block_type is 31, NOT 40; getting it wrong returns invalid_param).\n action=update — generic (pass `update_body`), image-replace (pass `image_token`), or file-replace (pass `file_token`).\n action=delete — pass `parent_block_id` + `start_index` + `end_index` (range delete).\n`document_id` accepts native ID, wiki node token, or Feishu URL.',
56
56
  inputSchema: {
57
57
  type: 'object',
58
58
  properties: {
@@ -69,6 +69,7 @@ const schemas = [
69
69
  file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
70
70
  file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
71
71
  update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
72
+ table: { type: 'object', description: 'Create a table — create mode F (mutually exclusive with other create modes). Shape: {rows:int>=1, columns:int>=1, cells?:string[][] (row-major plain text; omit/empty-string to leave a cell blank), column_width?:int[] (px, length=columns), header_row?:bool, header_column?:bool}. The plugin creates a block_type=31 table, lets Feishu auto-create the cells, and fills each provided cell by updating its text — you never specify block types. Returns {tableBlockId, cells:[[cellId,...]] (row-major grid), filled}. Example: {"rows":2,"columns":2,"cells":[["Name","Role"],["Ann","PM"]]}.' },
72
73
  },
73
74
  required: ['action', 'document_id'],
74
75
  },
@@ -121,8 +122,16 @@ const handlers = {
121
122
  switch (args.action) {
122
123
  case 'create': {
123
124
  need(args.parent_block_id, 'parent_block_id', 'create');
124
- const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token].filter(Boolean);
125
- if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token.');
125
+ const modes = [args.children, args.image_path, args.image_token, args.file_path, args.file_token, args.table].filter(Boolean);
126
+ if (modes.length > 1) return text('manage_doc_block(create): pass exactly ONE of children / image_path / image_token / file_path / file_token / table.');
127
+ if (args.table) {
128
+ const t = args.table;
129
+ return json(await official.createDocTable(docId, args.parent_block_id, {
130
+ rows: t.rows, columns: t.columns, cells: t.cells,
131
+ columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
132
+ index: args.index,
133
+ }));
134
+ }
126
135
  if (args.image_path || args.image_token) {
127
136
  const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
128
137
  imagePath: args.image_path,
@@ -139,7 +148,7 @@ const handlers = {
139
148
  });
140
149
  return json(r);
141
150
  }
142
- if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, or file_token is required.');
151
+ if (!args.children) return text('manage_doc_block(create): children, image_path, image_token, file_path, file_token, or table is required.');
143
152
  return json(await official.createDocBlock(docId, args.parent_block_id, args.children, args.index));
144
153
  }
145
154
  case 'update': {
package/src/oauth-auto.js DELETED
@@ -1,175 +0,0 @@
1
- #!/usr/bin/env node
2
- // DEV ONLY: Automated OAuth using local Playwright (not used in production).
3
- // Uses .env directly; not migrated to config module.
4
- // Requires: npm install playwright (not in package.json dependencies)
5
- const http = require('http');
6
- const { chromium } = require('playwright');
7
- const fs = require('fs');
8
- const path = require('path');
9
- const dotenv = require('dotenv');
10
-
11
- dotenv.config({ path: path.join(__dirname, '..', '.env') });
12
-
13
- const APP_ID = process.env.LARK_APP_ID;
14
- const APP_SECRET = process.env.LARK_APP_SECRET;
15
- const COOKIE_STR = process.env.LARK_COOKIE;
16
- const PORT = 9997;
17
- const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
18
- const SCOPES = 'offline_access im:message im:message:readonly im:chat:readonly contact:user.base:readonly';
19
-
20
- function parseCookies(cookieStr) {
21
- return cookieStr.split(';').map(c => {
22
- const [name, ...rest] = c.trim().split('=');
23
- return { name: name.trim(), value: rest.join('=').trim(), domain: '.feishu.cn', path: '/' };
24
- }).filter(c => c.name && c.value);
25
- }
26
-
27
- function saveToken(tokenData) {
28
- const envPath = path.join(__dirname, '..', '.env');
29
- let envContent = '';
30
- try { envContent = fs.readFileSync(envPath, 'utf8'); } catch {}
31
- const updates = {
32
- LARK_USER_ACCESS_TOKEN: tokenData.access_token,
33
- LARK_USER_REFRESH_TOKEN: tokenData.refresh_token || '',
34
- LARK_UAT_SCOPE: tokenData.scope || '',
35
- LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
36
- };
37
- for (const [key, val] of Object.entries(updates)) {
38
- const regex = new RegExp(`^${key}=.*$`, 'm');
39
- if (regex.test(envContent)) {
40
- envContent = envContent.replace(regex, `${key}=${val}`);
41
- } else {
42
- envContent += `\n${key}=${val}`;
43
- }
44
- }
45
- fs.writeFileSync(envPath, envContent.trim() + '\n');
46
- }
47
-
48
- async function exchangeCode(code) {
49
- console.log('[token] Exchanging code via v2...');
50
- const res = await fetch('https://open.feishu.cn/open-apis/authen/v2/oauth/token', {
51
- method: 'POST',
52
- headers: { 'content-type': 'application/json' },
53
- body: JSON.stringify({ grant_type: 'authorization_code', client_id: APP_ID, client_secret: APP_SECRET, code, redirect_uri: REDIRECT_URI }),
54
- });
55
- const raw = await res.text();
56
- console.log('[token] Response:', raw.slice(0, 300));
57
- const data = JSON.parse(raw);
58
- if (data.access_token) return data;
59
- if (data.data?.access_token) return data.data;
60
- throw new Error(`Token exchange failed: ${raw.slice(0, 200)}`);
61
- }
62
-
63
- async function run() {
64
- // Start callback server to capture the code
65
- let resolveCode;
66
- const codePromise = new Promise(resolve => { resolveCode = resolve; });
67
-
68
- const server = http.createServer((req, res) => {
69
- const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
70
- if (url.pathname === '/callback') {
71
- const code = url.searchParams.get('code');
72
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
73
- res.end(code ? '<h2>OK</h2>' : '<h2>No code</h2>');
74
- if (code) resolveCode(code);
75
- } else {
76
- res.writeHead(404); res.end();
77
- }
78
- });
79
- server.listen(PORT, '127.0.0.1');
80
- console.log(`[server] Listening on port ${PORT}`);
81
-
82
- // Launch browser — use the first page directly (no extra about:blank)
83
- const browser = await chromium.launch({ headless: false });
84
- const context = await browser.newContext({ viewport: { width: 1200, height: 800 } });
85
- await context.addCookies(parseCookies(COOKIE_STR));
86
-
87
- // Listen for any new page (popup) that might open
88
- context.on('page', async newPage => {
89
- const url = newPage.url();
90
- console.log('[context] New page opened:', url.slice(0, 200));
91
- });
92
-
93
- // Get the default page instead of creating a new one
94
- const pages = context.pages();
95
- const page = pages.length > 0 ? pages[0] : await context.newPage();
96
- page.setDefaultTimeout(30000);
97
-
98
- try {
99
- const authUrl = `https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=${APP_ID}&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&response_type=code&scope=${encodeURIComponent(SCOPES)}`;
100
- console.log('\n[auth] Opening authorize URL...');
101
-
102
- // Use route interception to capture the callback redirect directly
103
- let codeFromRoute = null;
104
- await context.route('**/callback**', async route => {
105
- const url = route.request().url();
106
- console.log('[route] Intercepted callback:', url.slice(0, 200));
107
- const parsed = new URL(url);
108
- const code = parsed.searchParams.get('code');
109
- if (code) {
110
- codeFromRoute = code;
111
- resolveCode(code);
112
- }
113
- await route.continue();
114
- });
115
-
116
- await page.goto(authUrl, { waitUntil: 'load' });
117
- await page.waitForTimeout(2000);
118
-
119
- const currentUrl = page.url();
120
- console.log('[auth] Current URL:', currentUrl.slice(0, 200));
121
-
122
- if (currentUrl.includes('callback?code=')) {
123
- console.log('[auth] Auto-authorized!');
124
- } else {
125
- // Find and click authorize button
126
- const authorizeBtn = page.locator('button:has-text("授权")').first();
127
- if (await authorizeBtn.isVisible().catch(() => false)) {
128
- console.log('[auth] Found 授权 button, clicking...');
129
- await authorizeBtn.click();
130
- console.log('[auth] Clicked, waiting for redirect...');
131
- await page.waitForTimeout(5000);
132
- console.log('[auth] After click URL:', page.url().slice(0, 200));
133
-
134
- // Check all pages in context
135
- const allPages = context.pages();
136
- console.log(`[auth] Total pages: ${allPages.length}`);
137
- for (let i = 0; i < allPages.length; i++) {
138
- const pUrl = allPages[i].url();
139
- console.log(` [${i}] ${pUrl.slice(0, 200)}`);
140
- if (pUrl.includes('callback?code=')) {
141
- const parsed = new URL(pUrl);
142
- resolveCode(parsed.searchParams.get('code'));
143
- }
144
- }
145
- } else {
146
- console.log('[auth] No authorize button found!');
147
- await page.screenshot({ path: '/tmp/feishu-oauth-nobutton.png' });
148
- }
149
- }
150
-
151
- // Wait for the code
152
- console.log('\n[token] Waiting for code...');
153
- const code = await Promise.race([
154
- codePromise,
155
- new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout (30s)')), 30000)),
156
- ]);
157
- console.log('[token] Got code:', code.slice(0, 20) + '...');
158
-
159
- const tokenData = await exchangeCode(code);
160
- saveToken(tokenData);
161
- console.log('\n=== SUCCESS ===');
162
- console.log('access_token:', tokenData.access_token?.slice(0, 30) + '...');
163
- console.log('scope:', tokenData.scope);
164
- console.log('expires_in:', tokenData.expires_in, 's');
165
-
166
- } catch (e) {
167
- console.error('\nError:', e.message);
168
- await page.screenshot({ path: '/tmp/feishu-oauth-error.png' }).catch(() => {});
169
- } finally {
170
- await browser.close();
171
- server.close();
172
- }
173
- }
174
-
175
- run();