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.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +51 -0
- package/package.json +3 -3
- package/scripts/probe-feishu-docx.js +2 -3
- package/scripts/sync-server-json.js +14 -1
- package/skills/feishu-user-plugin/SKILL.md +2 -2
- package/skills/feishu-user-plugin/references/doc.md +3 -0
- package/skills/feishu-user-plugin/references/drive.md +8 -2
- package/skills/feishu-user-plugin/references/wiki.md +1 -1
- package/src/clients/official/bitable.js +6 -1
- package/src/clients/official/docs.js +235 -61
- package/src/clients/official/drive.js +28 -3
- package/src/clients/official/groups.js +8 -1
- package/src/clients/official/wiki.js +94 -19
- package/src/error-codes.js +7 -0
- package/src/test-all.js +16 -0
- package/src/test-doc-block-media.js +140 -0
- package/src/test-doc-blocks-pagination.js +186 -0
- package/src/test-doc-table.js +92 -3
- package/src/test-error-codes.js +10 -0
- package/src/test-pagination-cursor-chain.js +194 -0
- package/src/test-uat-read-paths.js +313 -0
- package/src/tools/bitable.js +2 -1
- package/src/tools/docs.js +40 -11
- package/src/tools/drive.js +10 -3
- package/src/tools/groups.js +12 -1
- package/src/tools/im-read.js +4 -2
- package/src/tools/wiki.js +14 -6
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
);
|
|
21
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
110
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 && !
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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) ---
|
package/src/error-codes.js
CHANGED
|
@@ -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
|