feishu-user-plugin 1.3.15 → 1.3.17

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.
@@ -6,19 +6,73 @@
6
6
  // base.js or mixed in via other domain modules.
7
7
 
8
8
  const { buildEmptyImageBlock, buildReplaceImagePayload, buildEmptyFileBlock, buildReplaceFilePayload } = require('../../doc-blocks');
9
+ const { classifyError } = require('../../error-codes');
10
+
11
+ const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ // Backoff schedule between cell-fill retries (attempts = delays.length + 1).
14
+ // Field report 2026-06-07: rapid-fire docx writes intermittently trip the
15
+ // code=2200 scope-check flake / frequency control — a short pause clears it.
16
+ const CELL_RETRY_DELAYS_MS = [400, 1200];
17
+
18
+ // Run fn(); retry on transient failures (classifyError → 'retry') after the
19
+ // next backoff delay. Permanent failures propagate immediately. Safe here
20
+ // because every retried operation is idempotent (update_text_elements is a
21
+ // full replacement; getBlockChildren is a read).
22
+ async function _withTransientRetry(fn, delays) {
23
+ for (let attempt = 0; ; attempt++) {
24
+ try { return await fn(); }
25
+ catch (e) {
26
+ if (attempt >= delays.length || classifyError(e).action !== 'retry') throw e;
27
+ await _sleep(delays[attempt]);
28
+ }
29
+ }
30
+ }
31
+
32
+ // Structured error for the 3-step media flows (placeholder → upload → PATCH):
33
+ // the placeholder block already exists in the document when step 2/3 fails, so
34
+ // the failure must name it — and carry the uploaded media token when present —
35
+ // or the caller is left hunting for an unexplained empty block with no repair
36
+ // path (2026-06-07 audit).
37
+ function _orphanBlockError(label, documentId, blockId, stage, cause, mediaToken, tokenParam) {
38
+ const repair = mediaToken
39
+ ? `re-attach it with manage_doc_block(action=update, document_id=${documentId}, block_id=${blockId}, ${tokenParam}=${mediaToken}) — no need to re-upload`
40
+ : `retry, or remove the orphan via manage_doc_block(action=delete, document_id=${documentId}) on its parent block`;
41
+ const err = new Error(`${label}: placeholder block ${blockId} was created but ${stage} failed — ${cause.message}. The empty block remains in the document; ${repair}.`);
42
+ err.blockId = blockId;
43
+ if (mediaToken) err.mediaToken = mediaToken;
44
+ return err;
45
+ }
9
46
 
10
47
  module.exports = {
11
48
  // --- Docs ---
12
49
 
13
50
  async searchDocs(query, { pageSize = 10, pageToken } = {}) {
14
- const res = await this._safeSDKCall(
15
- () => this.client.request({
16
- method: 'POST', url: '/open-apis/suite/docs-api/search/object',
17
- data: { search_key: query, count: pageSize, offset: pageToken ? parseInt(pageToken) : 0, owner_ids: [], chat_ids: [], docs_types: [] },
18
- }),
19
- 'searchDocs'
20
- );
21
- return { items: res.data.docs_entities || [], hasMore: res.data.has_more };
51
+ // UAT-first (v1.3.16): the suite search API only indexes docs the calling
52
+ // identity can see. App identity misses everything in the user's personal
53
+ // space the 2026-06-06 "search_docs 搜不到个人空间 PDF" report.
54
+ // Tool args arrive unvalidated clamp to sane non-negative integers so a
55
+ // bad offset can't reach Feishu as NaN/negative or corrupt nextOffset
56
+ // math (Copilot review, PR #115).
57
+ const offset = Math.max(0, parseInt(pageToken, 10) || 0);
58
+ const size = Math.max(1, parseInt(pageSize, 10) || 10);
59
+ const body = { search_key: query, count: size, offset, owner_ids: [], chat_ids: [], docs_types: [] };
60
+ const res = await this._asUserOrApp({
61
+ uatPath: '/open-apis/suite/docs-api/search/object',
62
+ method: 'POST',
63
+ body,
64
+ sdkFn: () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: body }),
65
+ label: 'searchDocs',
66
+ });
67
+ const out = { items: res.data.docs_entities || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
68
+ // Offset-based cursor — hasMore alone gave callers no way to actually
69
+ // page forward, and UAT-wide search makes truncation likelier (the hidden
70
+ // tail may hold the very personal-space doc the user is hunting).
71
+ // Guard on items.length: an abnormal has_more:true + empty page would
72
+ // otherwise emit nextOffset === offset and stall a paging loop.
73
+ if (res.data.has_more && out.items.length > 0) out.nextOffset = offset + out.items.length;
74
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
75
+ return out;
22
76
  },
23
77
 
24
78
  async readDoc(documentId) {
@@ -53,14 +107,71 @@ module.exports = {
53
107
  return out;
54
108
  },
55
109
 
56
- async getDocBlocks(documentId) {
57
- const res = await this._asUserOrApp({
58
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
59
- query: { page_size: '500' },
60
- sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params: { page_size: 500 } }),
61
- label: 'getDocBlocks',
62
- });
63
- return { items: res.data.items || [] };
110
+ // Fetch the document's block tree. Follows page_token pagination until
111
+ // exhaustion by default — the pre-v1.3.17 single 500-block page silently
112
+ // truncated large docs (field report 2026-06-07: a ~300KB doc lost everything
113
+ // past mid-document, with no flag — callers believed the tail blocks were
114
+ // never created). Pass maxBlocks to bound one call (rounded up to page
115
+ // granularity) and resume with the returned nextPageToken as pageToken.
116
+ // Returns { items, total, hasMore, truncated?, nextPageToken?, viaUser, fallbackWarning? }.
117
+ // hasMore:false guarantees the full tree is in `items`.
118
+ async getDocBlocks(documentId, { pageToken, maxBlocks } = {}) {
119
+ // Tool args arrive unvalidated — accept the cap only as a finite integer
120
+ // >= 1; anything else (0, negatives, NaN, random strings) means "no cap"
121
+ // so a malformed value can never silently change paging semantics
122
+ // (Copilot review PR #118; same clamp precedent as searchDocs offset).
123
+ const cap = Number.isFinite(Number(maxBlocks)) && Number(maxBlocks) >= 1 ? Math.floor(Number(maxBlocks)) : null;
124
+ const items = [];
125
+ let token = pageToken || undefined;
126
+ let viaUser = true;
127
+ let fallbackWarning = null;
128
+ let hasMore = false;
129
+ let nextPageToken;
130
+ const seenTokens = new Set();
131
+ // 1000-page backstop (~500k blocks) — a server that keeps minting fresh
132
+ // tokens must not pin the loop forever. Hitting it reports hasMore:true.
133
+ const MAX_PAGES = 1000;
134
+ let page = 0;
135
+ for (; page < MAX_PAGES; page++) {
136
+ if (token) seenTokens.add(token);
137
+ const query = { page_size: '500' };
138
+ if (token) query.page_token = token;
139
+ const params = { page_size: 500 };
140
+ if (token) params.page_token = token;
141
+ const res = await this._asUserOrApp({
142
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
143
+ query,
144
+ sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId }, params }),
145
+ label: 'getDocBlocks',
146
+ });
147
+ const pageItems = res.data.items || [];
148
+ items.push(...pageItems);
149
+ viaUser = viaUser && !!res._viaUser;
150
+ if (!fallbackWarning && res._fallbackWarning) fallbackWarning = res._fallbackWarning;
151
+ hasMore = !!res.data.has_more;
152
+ if (!hasMore) break;
153
+ const next = res.data.page_token;
154
+ if (cap && items.length >= cap) {
155
+ if (next) nextPageToken = next;
156
+ break;
157
+ }
158
+ // Stall/cycle guards (PR #116 parity): a missing token, an unchanged
159
+ // token, or one we've already used means paging forward is futile — stop
160
+ // and WITHHOLD the cursor rather than loop forever. An EMPTY page is NOT
161
+ // a stop signal on its own: Feishu legitimately returns empty pages with
162
+ // has_more:true (permission filtering) and real data behind them, so we
163
+ // keep going as long as the cursor advances; the MAX_PAGES backstop bounds
164
+ // a pathological always-empty-new-token server.
165
+ if (!next || next === token || seenTokens.has(next)) break;
166
+ token = next;
167
+ }
168
+ // Backstop exhausted mid-document: `token` is the still-unfetched cursor.
169
+ if (page >= MAX_PAGES && hasMore && token) nextPageToken = token;
170
+ const out = { items, total: items.length, hasMore, viaUser };
171
+ if (hasMore) out.truncated = true;
172
+ if (nextPageToken) out.nextPageToken = nextPageToken;
173
+ if (fallbackWarning) out.fallbackWarning = fallbackWarning;
174
+ return out;
64
175
  },
65
176
 
66
177
  // Direct children of a single block — scoped, so it does not inherit the
@@ -106,8 +217,18 @@ module.exports = {
106
217
  // 3) fill: UPDATE each cell's existing text block (clean — no stray empty
107
218
  // block) when present, else CREATE a text block in the cell.
108
219
  // `cells` is an optional row-major 2D array of plain strings.
109
- // Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, viaUser, fallbackWarning }.
110
- async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index } = {}) {
220
+ // Cell fills retry transient Feishu errors (code=2200 scope-check flake,
221
+ // rate limits, 5xx) with backoff; cells that still fail are reported in
222
+ // `failedCells` [{row,col,cellId,textBlockId?,reason,skipped?}] (0-based)
223
+ // instead of aborting the whole call — the table block already exists, so
224
+ // the caller needs the partial-success map to repair, not an opaque throw
225
+ // (field report 2026-06-07: a 7×3 fill died at row 6 and the caller had to
226
+ // grep the whole doc for empty cells). After 3 consecutive cell failures the
227
+ // remaining fills are skipped (marked skipped:true) — a structural error
228
+ // (revoked perms) would otherwise burn 2 API calls per remaining cell.
229
+ // `retryDelaysMs` overrides the backoff schedule (tests only).
230
+ // Returns { tableBlockId, cells:[[cellId,...],...], rows, columns, filled, failedCells?, viaUser, fallbackWarning }.
231
+ async createDocTable(documentId, parentBlockId, { rows, columns, cells, columnWidth, headerRow, headerColumn, index, retryDelaysMs } = {}) {
111
232
  rows = Number(rows); columns = Number(columns);
112
233
  if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 1 || columns < 1) {
113
234
  throw new Error('createDocTable: rows and columns must be integers >= 1');
@@ -149,29 +270,58 @@ module.exports = {
149
270
  for (let r = 0; r < rows; r++) grid.push(flatCellIds.slice(r * columns, (r + 1) * columns));
150
271
 
151
272
  let filled = 0;
273
+ const failedCells = [];
152
274
  if (Array.isArray(cells)) {
275
+ const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
276
+ let consecutiveFailures = 0;
277
+ let lastReason = '';
153
278
  for (let r = 0; r < rows; r++) {
154
279
  for (let c = 0; c < columns; c++) {
155
280
  const content = cells[r] ? cells[r][c] : undefined;
156
281
  if (content === undefined || content === null || content === '') continue;
157
282
  const cellId = grid[r][c];
158
283
  if (!cellId) throw new Error(`createDocTable: missing cell id at row ${r}, col ${c}`);
284
+ if (consecutiveFailures >= 3) {
285
+ failedCells.push({
286
+ row: r, col: c, cellId, skipped: true,
287
+ reason: `skipped: aborted after ${consecutiveFailures} consecutive cell failures (last: ${lastReason})`,
288
+ });
289
+ continue;
290
+ }
159
291
  // Each fresh cell auto-creates exactly one empty text block — UPDATE it
160
292
  // (clean) rather than CREATE a second. Scoped per-cell fetch stays
161
293
  // correct regardless of overall document size.
162
- const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
163
- const textChild = cellChildren.find(b => b.block_type === 2);
164
- const elements = { elements: [{ text_run: { content: String(content) } }] };
165
- if (textChild) {
166
- await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
167
- } else {
168
- await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
294
+ let textBlockId = null;
295
+ try {
296
+ await _withTransientRetry(async () => {
297
+ // Reset per attempt — the cell is re-inspected on every retry, and
298
+ // a stale id from a prior attempt must not leak into failedCells.
299
+ textBlockId = null;
300
+ const cellChildren = (await this.getBlockChildren(documentId, cellId)).items || [];
301
+ const textChild = cellChildren.find(b => b.block_type === 2);
302
+ const elements = { elements: [{ text_run: { content: String(content) } }] };
303
+ if (textChild) {
304
+ textBlockId = textChild.block_id;
305
+ await this.updateDocBlock(documentId, textChild.block_id, { update_text_elements: elements });
306
+ } else {
307
+ await this.createDocBlock(documentId, cellId, [{ block_type: 2, text: elements }]);
308
+ }
309
+ }, delays);
310
+ filled++;
311
+ consecutiveFailures = 0;
312
+ } catch (e) {
313
+ consecutiveFailures++;
314
+ lastReason = e.message;
315
+ const entry = { row: r, col: c, cellId, reason: e.message };
316
+ if (textBlockId) entry.textBlockId = textBlockId;
317
+ failedCells.push(entry);
169
318
  }
170
- filled++;
171
319
  }
172
320
  }
173
321
  }
174
- return { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
322
+ const out = { tableBlockId, cells: grid, rows, columns, filled, viaUser, fallbackWarning };
323
+ if (failedCells.length) out.failedCells = failedCells;
324
+ return out;
175
325
  },
176
326
 
177
327
  async updateDocBlock(documentId, blockId, updateBody) {
@@ -207,8 +357,12 @@ module.exports = {
207
357
  // 1) create empty image placeholder block
208
358
  // 2) upload pixels (skipped if caller passes a ready-made imageToken)
209
359
  // 3) patch the placeholder with the uploaded token
360
+ // Steps 2/3 retry transient failures (upload re-send and PATCH full-replace
361
+ // are both idempotent); a persistent failure throws an error carrying the
362
+ // placeholder blockId (+ uploaded token when step 2 succeeded) so the caller
363
+ // can repair instead of orphan-hunting. `retryDelaysMs` is test-only.
210
364
  // Returns { blockId, imageToken, viaUser }.
211
- async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index } = {}) {
365
+ async createDocBlockWithImage(documentId, parentBlockId, { imagePath, imageToken, index, retryDelaysMs } = {}) {
212
366
  if (!imagePath && !imageToken) {
213
367
  throw new Error('createDocBlockWithImage: either imagePath or imageToken is required');
214
368
  }
@@ -231,28 +385,39 @@ module.exports = {
231
385
  const blockId = newBlock?.block_id;
232
386
  if (!blockId) throw new Error(`createDocBlockWithImage: placeholder creation returned no block_id: ${JSON.stringify(created.data).slice(0, 400)}`);
233
387
 
234
- // Step 2 — upload (if needed).
388
+ // Step 2 — upload (if needed), with transient retry.
389
+ const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
235
390
  let finalToken = imageToken;
236
391
  let viaUser = !!created._viaUser;
237
392
  let fallbackWarning = created._fallbackWarning || null;
238
393
  if (!finalToken) {
239
- const uploaded = await this.uploadMedia(imagePath, blockId, 'docx_image');
394
+ let uploaded;
395
+ try {
396
+ uploaded = await _withTransientRetry(() => this.uploadMedia(imagePath, blockId, 'docx_image'), delays);
397
+ } catch (e) {
398
+ throw _orphanBlockError('createDocBlockWithImage', documentId, blockId, 'image upload', e, null);
399
+ }
240
400
  finalToken = uploaded.fileToken;
241
401
  viaUser = viaUser && uploaded.viaUser; // true iff both steps went via user
242
402
  }
243
403
 
244
- // Step 3 — attach token to the placeholder via PATCH replace_image.
404
+ // Step 3 — attach token to the placeholder via PATCH replace_image
405
+ // (idempotent full replace), with transient retry.
245
406
  const patch = buildReplaceImagePayload(finalToken);
246
- await this._asUserOrApp({
247
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
248
- method: 'PATCH',
249
- body: patch,
250
- sdkFn: () => this.client.docx.documentBlock.patch({
251
- path: { document_id: documentId, block_id: blockId },
252
- data: patch,
253
- }),
254
- label: 'createDocBlockWithImage.replaceImage',
255
- });
407
+ try {
408
+ await _withTransientRetry(() => this._asUserOrApp({
409
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
410
+ method: 'PATCH',
411
+ body: patch,
412
+ sdkFn: () => this.client.docx.documentBlock.patch({
413
+ path: { document_id: documentId, block_id: blockId },
414
+ data: patch,
415
+ }),
416
+ label: 'createDocBlockWithImage.replaceImage',
417
+ }), delays);
418
+ } catch (e) {
419
+ throw _orphanBlockError('createDocBlockWithImage', documentId, blockId, 'replace_image PATCH', e, finalToken, 'image_token');
420
+ }
256
421
 
257
422
  return { blockId, imageToken: finalToken, viaUser, fallbackWarning };
258
423
  },
@@ -279,8 +444,11 @@ module.exports = {
279
444
  // 1) create empty file placeholder block
280
445
  // 2) upload the binary via uploadMedia(parent_type=docx_file)
281
446
  // 3) PATCH with replace_file.token to attach
447
+ // Steps 2/3 retry transient failures and a persistent failure throws an
448
+ // error carrying the placeholder blockId (+ uploaded token when step 2
449
+ // succeeded) — mirrors createDocBlockWithImage. `retryDelaysMs` is test-only.
282
450
  // Returns { blockId, fileToken, viaUser, fallbackWarning }.
283
- async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index } = {}) {
451
+ async createDocBlockWithFile(documentId, parentBlockId, { filePath, fileToken, index, retryDelaysMs } = {}) {
284
452
  if (!filePath && !fileToken) {
285
453
  throw new Error('createDocBlockWithFile: either filePath or fileToken is required');
286
454
  }
@@ -315,26 +483,36 @@ module.exports = {
315
483
  blockId = inner;
316
484
  }
317
485
 
486
+ const delays = Array.isArray(retryDelaysMs) ? retryDelaysMs : CELL_RETRY_DELAYS_MS;
318
487
  let finalToken = fileToken;
319
488
  let viaUser = !!created._viaUser;
320
489
  let fallbackWarning = created._fallbackWarning || null;
321
490
  if (!finalToken) {
322
- const uploaded = await this.uploadMedia(filePath, blockId, 'docx_file');
491
+ let uploaded;
492
+ try {
493
+ uploaded = await _withTransientRetry(() => this.uploadMedia(filePath, blockId, 'docx_file'), delays);
494
+ } catch (e) {
495
+ throw _orphanBlockError('createDocBlockWithFile', documentId, blockId, 'file upload', e, null);
496
+ }
323
497
  finalToken = uploaded.fileToken;
324
498
  viaUser = viaUser && uploaded.viaUser;
325
499
  }
326
500
 
327
501
  const patch = buildReplaceFilePayload(finalToken);
328
- await this._asUserOrApp({
329
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
330
- method: 'PATCH',
331
- body: patch,
332
- sdkFn: () => this.client.docx.documentBlock.patch({
333
- path: { document_id: documentId, block_id: blockId },
334
- data: patch,
335
- }),
336
- label: 'createDocBlockWithFile.replaceFile',
337
- });
502
+ try {
503
+ await _withTransientRetry(() => this._asUserOrApp({
504
+ uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks/${blockId}`,
505
+ method: 'PATCH',
506
+ body: patch,
507
+ sdkFn: () => this.client.docx.documentBlock.patch({
508
+ path: { document_id: documentId, block_id: blockId },
509
+ data: patch,
510
+ }),
511
+ label: 'createDocBlockWithFile.replaceFile',
512
+ }), delays);
513
+ } catch (e) {
514
+ throw _orphanBlockError('createDocBlockWithFile', documentId, blockId, 'replace_file PATCH', e, finalToken, 'file_token');
515
+ }
338
516
 
339
517
  return { blockId, viewBlockId: outerBlockId !== blockId ? outerBlockId : undefined, fileToken: finalToken, viaUser, fallbackWarning };
340
518
  },
@@ -360,15 +538,11 @@ module.exports = {
360
538
  // None matched directly; return the first as best-effort
361
539
  return childIds[0];
362
540
  }
363
- // Fallback: list all blocks and find a 23 whose parent_id is the view block
541
+ // Fallback: list all blocks and find a 23 whose parent_id is the view block.
542
+ // Uses getDocBlocks (paginates past 500 blocks) — a single unpaged list
543
+ // would miss the freshly appended view block in a large document.
364
544
  try {
365
- const res = await this._asUserOrApp({
366
- uatPath: `/open-apis/docx/v1/documents/${documentId}/blocks`,
367
- method: 'GET',
368
- sdkFn: () => this.client.docx.documentBlock.list({ path: { document_id: documentId } }),
369
- label: '_findFileChildOf.list',
370
- });
371
- const items = res?.data?.items || [];
545
+ const { items } = await this.getDocBlocks(documentId);
372
546
  const match = items.find(b => b.block_type === 23 && b.parent_id === viewBlockId);
373
547
  return match?.block_id || null;
374
548
  } catch (_) {
@@ -8,10 +8,35 @@ module.exports = {
8
8
  // --- Drive ---
9
9
 
10
10
  async listFiles(folderToken, { pageSize = 50, pageToken } = {}) {
11
- const params = { page_size: pageSize, folder_token: folderToken || '' };
11
+ // UAT-first (v1.3.16): the bot identity 403s on personal-space ("我的空间")
12
+ // folders it was never invited to, which made user-uploaded files (UAT
13
+ // upload path) undiscoverable — and therefore undeletable, because
14
+ // manage_drive_file needs a file_token only list_files can provide.
15
+ // Bot fallback keeps bot-shared folders working. (2026-06-06 user report.)
16
+ const size = Math.max(1, parseInt(pageSize, 10) || 50);
17
+ const params = { page_size: size, folder_token: folderToken || '' };
12
18
  if (pageToken) params.page_token = pageToken;
13
- const res = await this._safeSDKCall(() => this.client.drive.file.list({ params }), 'listFiles');
14
- return { items: res.data.files || [], hasMore: res.data.has_more };
19
+ const query = { page_size: String(size), folder_token: folderToken || '' };
20
+ if (pageToken) query.page_token = pageToken;
21
+ const res = await this._asUserOrApp({
22
+ uatPath: '/open-apis/drive/v1/files',
23
+ query,
24
+ sdkFn: () => this.client.drive.file.list({ params }),
25
+ label: 'listFiles',
26
+ });
27
+ const out = { items: res.data.files || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
28
+ if (res.data.next_page_token) out.nextPageToken = res.data.next_page_token;
29
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
30
+ // Empty + bot path + ROOT listing only: with an empty folder_token the
31
+ // bot lists its OWN root space (usually empty), not the user's 我的空间 —
32
+ // that mismatch is the blind spot worth explaining. A specific
33
+ // folder_token the bot cannot access throws (403) and never reaches here,
34
+ // and a bot-visible folder that is genuinely empty should stay a bare []
35
+ // (Copilot review, PR #115).
36
+ if (out.items.length === 0 && !res._viaUser && !folderToken) {
37
+ out.scopeHint = 'Empty result via app identity: with an empty folder_token the bot lists its OWN root space, not your 我的空间 — your personal files are invisible to it. Run `npx feishu-user-plugin oauth` so list_files can read your own space via UAT.';
38
+ }
39
+ return out;
15
40
  },
16
41
 
17
42
  async createFolder(name, parentToken) {
@@ -51,7 +51,14 @@ module.exports = {
51
51
  }),
52
52
  'addChatMembers'
53
53
  );
54
- return { invalidIds: res.data.invalid_id_list || [] };
54
+ // Feishu reports three partial-failure buckets on batch add (2026-06-07
55
+ // audit) — swallowing not_existed/pending_approval made a half-failed add
56
+ // read as full success (members "in the group" who never joined).
57
+ return {
58
+ invalidIds: res.data.invalid_id_list || [],
59
+ notExistedIds: res.data.not_existed_id_list || [],
60
+ pendingApprovalIds: res.data.pending_approval_id_list || [],
61
+ };
55
62
  },
56
63
 
57
64
  async removeChatMembers(chatId, userIds, memberIdType = 'open_id') {
@@ -11,29 +11,81 @@ module.exports = {
11
11
  // Try UAT first — most users access only their own / team Wiki spaces
12
12
  // which the bot may not have been invited to. Falling back to app keeps
13
13
  // the bot-shared-spaces case working too.
14
- const res = await this._asUserOrApp({
15
- uatPath: '/open-apis/wiki/v2/spaces?page_size=50',
16
- method: 'GET',
17
- sdkFn: () => this.client.wiki.space.list({ params: { page_size: 50 } }),
18
- label: 'listSpaces',
19
- });
20
- const items = res.data.items || [];
21
- const out = { items, viaUser: !!res._viaUser };
22
- if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
14
+ //
15
+ // Follows page_token pagination to completion (2026-06-07 audit): the
16
+ // endpoint pages at 50/page and the pre-fix single call silently dropped
17
+ // every space past the first page with no hasMore flag — spaces beyond
18
+ // #50 were unreachable (their space_id could never be discovered).
19
+ const items = [];
20
+ let token;
21
+ let viaUser = true;
22
+ let fallbackWarning = null;
23
+ let hasMore = false;
24
+ const seenTokens = new Set();
25
+ for (let page = 0; page < 200; page++) {
26
+ if (token) seenTokens.add(token);
27
+ const query = { page_size: '50' };
28
+ if (token) query.page_token = token;
29
+ const params = { page_size: 50 };
30
+ if (token) params.page_token = token;
31
+ const res = await this._asUserOrApp({
32
+ uatPath: '/open-apis/wiki/v2/spaces',
33
+ method: 'GET',
34
+ query,
35
+ sdkFn: () => this.client.wiki.space.list({ params }),
36
+ label: 'listSpaces',
37
+ });
38
+ const pageItems = res.data.items || [];
39
+ items.push(...pageItems);
40
+ viaUser = viaUser && !!res._viaUser;
41
+ if (!fallbackWarning && res._fallbackWarning) fallbackWarning = res._fallbackWarning;
42
+ hasMore = !!res.data.has_more;
43
+ if (!hasMore) break;
44
+ const next = res.data.page_token;
45
+ // Stall/cycle guards (getDocBlocks parity) — never loop on a server that
46
+ // drops or repeats the cursor. An empty page is NOT a stop signal: the
47
+ // Feishu wiki endpoints document empty pages with has_more:true under
48
+ // permission filtering, with real spaces behind them — keep paging while
49
+ // the cursor advances; the 200-page backstop bounds a pathological server.
50
+ if (!next || next === token || seenTokens.has(next)) break;
51
+ token = next;
52
+ }
53
+ const out = { items, viaUser };
54
+ if (hasMore) out.hasMore = true; // stalled upstream cursor — incompleteness stays visible
55
+ if (fallbackWarning) out.fallbackWarning = fallbackWarning;
23
56
  // Empty + bot path means scope is missing; surface a clear hint instead
24
57
  // of silently returning nothing.
25
- if (items.length === 0 && !res._viaUser) {
58
+ if (items.length === 0 && !viaUser) {
26
59
  out.scopeHint = 'No spaces returned via app — the bot likely lacks `wiki:wiki:readonly` scope, or has not been invited to any Wiki space. Run `npx feishu-user-plugin oauth` and ensure the wiki scope is granted; or invite the bot to the target Wiki space.';
27
60
  }
28
61
  return out;
29
62
  },
30
63
 
31
- async searchWiki(query) {
32
- const res = await this._safeSDKCall(
33
- () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: { search_key: query, count: 20, offset: 0, owner_ids: [], chat_ids: [], docs_types: ['wiki'] } }),
34
- 'searchWiki'
35
- );
36
- return { items: res.data.docs_entities || [] };
64
+ async searchWiki(query, { pageSize = 20, offset = 0 } = {}) {
65
+ // UAT-first (v1.3.16): same blind spot as searchDocs — the suite search
66
+ // API only indexes entities the calling identity can see, so the app
67
+ // identity misses wiki nodes in spaces the bot wasn't invited to.
68
+ // Clamp unvalidated tool args (Copilot review, PR #115).
69
+ const safeOffset = Math.max(0, parseInt(offset, 10) || 0);
70
+ const size = Math.max(1, parseInt(pageSize, 10) || 20);
71
+ const body = { search_key: query, count: size, offset: safeOffset, owner_ids: [], chat_ids: [], docs_types: ['wiki'] };
72
+ const res = await this._asUserOrApp({
73
+ uatPath: '/open-apis/suite/docs-api/search/object',
74
+ method: 'POST',
75
+ body,
76
+ sdkFn: () => this.client.request({ method: 'POST', url: '/open-apis/suite/docs-api/search/object', data: body }),
77
+ label: 'searchWiki',
78
+ });
79
+ const out = { items: res.data.docs_entities || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
80
+ // The suite search API is offset-based; hand the caller a ready-to-use
81
+ // cursor so paging doesn't require manual offset math (UAT-wide search
82
+ // makes truncation likelier — the hidden tail may hold the very
83
+ // personal-space doc the user is hunting).
84
+ // Guard on items.length: see searchDocs — prevents a stalled cursor on an
85
+ // abnormal has_more:true + empty page.
86
+ if (res.data.has_more && out.items.length > 0) out.nextOffset = safeOffset + out.items.length;
87
+ if (res._fallbackWarning) out.fallbackWarning = res._fallbackWarning;
88
+ return out;
37
89
  },
38
90
 
39
91
  // Resolves a wiki node token to its underlying object (docx / sheet / bitable / ...).
@@ -46,8 +98,27 @@ module.exports = {
46
98
  // and returns a synthesized node-shaped result so callers don't have to know
47
99
  // which ID space they're holding.
48
100
  async getWikiNode(nodeToken, _spaceId) {
49
- const res = await this._safeSDKCall(() => this.client.wiki.space.getNode({ params: { token: nodeToken } }), 'getNode');
50
- return res.data.node;
101
+ // UAT-first (v1.3.16): bot identity hits permission errors on spaces it
102
+ // wasn't invited to (same class as listWikiNodes' 131006). The dual-failure
103
+ // error from _asUserOrApp embeds the Feishu code ("as user: code=953001
104
+ // ..."), so the obj_token detection regex in tools/wiki.js keeps working.
105
+ const res = await this._asUserOrApp({
106
+ uatPath: '/open-apis/wiki/v2/spaces/get_node',
107
+ query: { token: nodeToken },
108
+ sdkFn: () => this.client.wiki.space.getNode({ params: { token: nodeToken } }),
109
+ label: 'getNode',
110
+ });
111
+ const node = res.data.node;
112
+ // Keep the bare-node return shape (resolver.js reads obj_token/obj_type
113
+ // off it), but attach identity metadata additively so the get_wiki_node
114
+ // tool surfaces degradation like its 3 sibling discovery reads — without
115
+ // this, a UAT-revoked → bot fallback would silently swallow the warning
116
+ // (json() hoists `fallbackWarning` only when it is on the returned object).
117
+ if (node && typeof node === 'object') {
118
+ node.viaUser = !!res._viaUser;
119
+ if (res._fallbackWarning) node.fallbackWarning = res._fallbackWarning;
120
+ }
121
+ return node;
51
122
  },
52
123
 
53
124
  async listWikiNodes(spaceId, { parentNodeToken, pageToken } = {}) {
@@ -66,7 +137,11 @@ module.exports = {
66
137
  sdkFn: () => this.client.wiki.spaceNode.list({ path: { space_id: spaceId }, params: sdkParams }),
67
138
  label: 'listWikiNodes',
68
139
  });
69
- return { items: res.data.items || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
140
+ // pageToken accompanies hasMore (2026-06-07 audit) hasMore without the
141
+ // resume cursor stranded callers at the first 50 nodes forever.
142
+ const out = { items: res.data.items || [], hasMore: res.data.has_more, viaUser: !!res._viaUser };
143
+ if (res.data.page_token) out.pageToken = res.data.page_token;
144
+ return out;
70
145
  },
71
146
 
72
147
  // --- Wiki write (v1.3.7) ---
@@ -49,6 +49,13 @@ const FAILURE_MAP = {
49
49
  1254301: { action: 'retry', reason: 'upload_transient' },
50
50
  1254400: { action: 'retry', reason: 'upload_transient' },
51
51
 
52
+ // docx scope-check flake — "check incr user_access_token scope fail".
53
+ // Field report 2026-06-07: a mode-F table fill saw 15 identical
54
+ // updateDocBlock calls succeed, then 2200 — same UAT, so the scope was
55
+ // granted; Feishu's incremental-scope check is intermittently flaky under
56
+ // rapid-fire docx writes. A short-backoff retry usually clears it.
57
+ 2200: { action: 'retry', reason: 'docx_scope_check_transient' },
58
+
52
59
  // Rate limited — Feishu throttles, try once more after a brief pause.
53
60
  42101: { action: 'retry', reason: 'bot_rate_limited' },
54
61
  // Frequency control variants occasionally observed.
package/src/test-all.js CHANGED
@@ -326,6 +326,18 @@ main().catch(console.error).finally(() => {
326
326
  console.error('doc-table: FAIL', e);
327
327
  process.exitCode = 1;
328
328
  });
329
+ require('./test-doc-blocks-pagination').run().catch(e => {
330
+ console.error('doc-blocks-pagination: FAIL', e);
331
+ process.exitCode = 1;
332
+ });
333
+ require('./test-pagination-cursor-chain').run().catch(e => {
334
+ console.error('pagination-cursor-chain: FAIL', e);
335
+ process.exitCode = 1;
336
+ });
337
+ require('./test-doc-block-media').run().catch(e => {
338
+ console.error('doc-block-media: FAIL', e);
339
+ process.exitCode = 1;
340
+ });
329
341
  require('./test-switch-profile').run().catch(e => {
330
342
  console.error('switch-profile-e2e: FAIL', e);
331
343
  process.exitCode = 1;
@@ -377,6 +389,10 @@ main().catch(console.error).finally(() => {
377
389
  console.error('search-messages: FAIL', e);
378
390
  process.exitCode = 1;
379
391
  });
392
+ require('./test-uat-read-paths').run().catch(e => {
393
+ console.error('uat-read-paths: FAIL', e);
394
+ process.exitCode = 1;
395
+ });
380
396
  require('./test-cli-tool').run();
381
397
  require('./test-lark-desktop').run();
382
398
  require('./test-display-label'); // standalone — runs on require, exits non-zero on fail