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.
@@ -0,0 +1,313 @@
1
+ // src/test-uat-read-paths.js — verify discovery-read paths are UAT-first.
2
+ //
3
+ // Background (2026-06-06 user report): upload_drive_file goes UAT (file owned
4
+ // by the user), but list_files went app-token-only → bot gets 403 on personal
5
+ // space folders ("我的空间"), so uploaded files were undiscoverable and thus
6
+ // undeletable (manage_drive_file needs a file_token the user can't obtain).
7
+ // search_docs had the same blind spot (personal-space files not indexed for
8
+ // the bot identity). searchWiki / getWikiNode shared the app-only pattern.
9
+ //
10
+ // Fix: route listFiles / searchDocs / searchWiki / getWikiNode through
11
+ // _asUserOrApp (UAT-first, bot fallback + fallbackWarning), matching
12
+ // listWikiSpaces / listWikiNodes which were already UAT-first.
13
+ //
14
+ // Tests stub `this._asUserOrApp` at the mixin level (methods are mixed into
15
+ // LarkOfficialClient.prototype; binding them to a fake `this` is the
16
+ // supported seam — same approach as test-via-user.js's fakeCtx).
17
+
18
+ 'use strict';
19
+
20
+ const assert = require('node:assert/strict');
21
+
22
+ const driveMixin = require('./clients/official/drive');
23
+ const docsMixin = require('./clients/official/docs');
24
+ const wikiMixin = require('./clients/official/wiki');
25
+
26
+ // fake `this` for mixin methods. Records _asUserOrApp / _safeSDKCall calls.
27
+ // uatResult is what _asUserOrApp resolves to (shape: legacy asUserOrApp
28
+ // contract — data object with _viaUser + optional _fallbackWarning).
29
+ function fakeClient({ uatResult, sdkResult }) {
30
+ const calls = { asUserOrApp: [], safeSDKCall: [] };
31
+ return {
32
+ calls,
33
+ async _asUserOrApp(opts) {
34
+ calls.asUserOrApp.push(opts);
35
+ return uatResult;
36
+ },
37
+ async _safeSDKCall(fn, label) {
38
+ calls.safeSDKCall.push(label);
39
+ // Default shape covers all four legacy call sites so pre-fix code fails
40
+ // on the routing assertions (clean RED) instead of a TypeError here.
41
+ return sdkResult || { code: 0, data: { files: [], has_more: false, docs_entities: [], node: {} } };
42
+ },
43
+ // SDK surface — only reached via the sdkFn closures, which these tests
44
+ // never execute (the _asUserOrApp stub doesn't call sdkFn).
45
+ client: {
46
+ drive: { file: { list: async () => { throw new Error('sdkFn should not run in these tests'); } } },
47
+ wiki: { space: { getNode: async () => { throw new Error('sdkFn should not run in these tests'); } } },
48
+ request: async () => { throw new Error('sdkFn should not run in these tests'); },
49
+ },
50
+ };
51
+ }
52
+
53
+ async function run() {
54
+ // --- 1. listFiles is UAT-first via _asUserOrApp ---
55
+ {
56
+ const c = fakeClient({
57
+ uatResult: { code: 0, data: { files: [{ token: 'boxcnX', name: 'a.pdf' }], has_more: false }, _viaUser: true },
58
+ });
59
+ const res = await driveMixin.listFiles.call(c, 'fldcnROOT');
60
+ assert.equal(c.calls.asUserOrApp.length, 1, 'listFiles must route through _asUserOrApp (UAT-first)');
61
+ assert.equal(c.calls.safeSDKCall.length, 0, 'listFiles must not call _safeSDKCall directly (app-only blind spot)');
62
+ const opts = c.calls.asUserOrApp[0];
63
+ assert.equal(opts.uatPath, '/open-apis/drive/v1/files', 'listFiles UAT path');
64
+ assert.equal(opts.query.folder_token, 'fldcnROOT');
65
+ assert.ok(opts.sdkFn, 'bot fallback must be preserved');
66
+ assert.equal(res.viaUser, true, 'viaUser surfaced');
67
+ assert.equal(res.items.length, 1);
68
+ }
69
+
70
+ // --- 2. listFiles surfaces fallbackWarning + scopeHint on bot path ---
71
+ {
72
+ const c = fakeClient({
73
+ uatResult: { code: 0, data: { files: [], has_more: false }, _viaUser: false, _fallbackWarning: '⚠️ test-warning' },
74
+ });
75
+ const res = await driveMixin.listFiles.call(c, '');
76
+ assert.equal(res.viaUser, false);
77
+ assert.equal(res.fallbackWarning, '⚠️ test-warning', 'fallbackWarning must surface so ownership blind spot is visible');
78
+ assert.ok(res.scopeHint && /403|个人|personal|my space|我的空间|scope/i.test(res.scopeHint),
79
+ 'empty bot-path result must carry a scopeHint explaining the personal-space blind spot');
80
+ }
81
+
82
+ // --- 3. listFiles passes pagination through ---
83
+ {
84
+ const c = fakeClient({
85
+ uatResult: { code: 0, data: { files: [], has_more: true, next_page_token: 'NPT' }, _viaUser: true },
86
+ });
87
+ const res = await driveMixin.listFiles.call(c, 'fld', { pageSize: 10, pageToken: 'PT' });
88
+ const opts = c.calls.asUserOrApp[0];
89
+ assert.equal(String(opts.query.page_size), '10');
90
+ assert.equal(opts.query.page_token, 'PT');
91
+ assert.equal(res.nextPageToken, 'NPT', 'next_page_token must surface for pagination');
92
+ }
93
+
94
+ // --- 4. searchDocs is UAT-first ---
95
+ {
96
+ const c = fakeClient({
97
+ uatResult: { code: 0, data: { docs_entities: [{ docs_token: 'boxcnY' }], has_more: false }, _viaUser: true },
98
+ });
99
+ const res = await docsMixin.searchDocs.call(c, 'PDF 报告');
100
+ assert.equal(c.calls.asUserOrApp.length, 1, 'searchDocs must route through _asUserOrApp');
101
+ const opts = c.calls.asUserOrApp[0];
102
+ assert.equal(opts.uatPath, '/open-apis/suite/docs-api/search/object');
103
+ assert.equal(opts.method, 'POST');
104
+ assert.equal(opts.body.search_key, 'PDF 报告');
105
+ assert.deepEqual(opts.body.docs_types, [], 'searchDocs searches all types');
106
+ assert.equal(res.viaUser, true);
107
+ assert.equal(res.items.length, 1);
108
+ }
109
+
110
+ // --- 5. searchWiki is UAT-first, scoped to wiki ---
111
+ {
112
+ const c = fakeClient({
113
+ uatResult: { code: 0, data: { docs_entities: [] }, _viaUser: false, _fallbackWarning: '⚠️ w' },
114
+ });
115
+ const res = await wikiMixin.searchWiki.call(c, 'roadmap');
116
+ assert.equal(c.calls.asUserOrApp.length, 1, 'searchWiki must route through _asUserOrApp');
117
+ const opts = c.calls.asUserOrApp[0];
118
+ assert.equal(opts.uatPath, '/open-apis/suite/docs-api/search/object');
119
+ assert.deepEqual(opts.body.docs_types, ['wiki'], 'searchWiki restricted to wiki entities');
120
+ assert.equal(res.viaUser, false);
121
+ assert.equal(res.fallbackWarning, '⚠️ w');
122
+ }
123
+
124
+ // --- 6. getWikiNode is UAT-first and surfaces viaUser ---
125
+ {
126
+ const c = fakeClient({
127
+ uatResult: { code: 0, data: { node: { node_token: 'wikcnZ', obj_type: 'docx' } }, _viaUser: true },
128
+ });
129
+ const node = await wikiMixin.getWikiNode.call(c, 'wikcnZ');
130
+ assert.equal(c.calls.asUserOrApp.length, 1, 'getWikiNode must route through _asUserOrApp');
131
+ const opts = c.calls.asUserOrApp[0];
132
+ assert.equal(opts.uatPath, '/open-apis/wiki/v2/spaces/get_node');
133
+ assert.equal(opts.query.token, 'wikcnZ');
134
+ assert.equal(node.node_token, 'wikcnZ');
135
+ assert.equal(node.viaUser, true, 'getWikiNode must surface viaUser like its 3 sibling reads');
136
+ }
137
+
138
+ // --- 6b. getWikiNode bot fallback must NOT swallow the fallbackWarning ---
139
+ // The warning lives on the top-level data object from withIdentityFallback,
140
+ // not on data.node — without explicit copying, a UAT-revoked → bot fallback
141
+ // silently drops it (caught by multi-agent review of the original commit).
142
+ {
143
+ const c = fakeClient({
144
+ uatResult: { code: 0, data: { node: { node_token: 'wikcnZ', obj_type: 'docx' } }, _viaUser: false, _fallbackWarning: '⚠️ g' },
145
+ });
146
+ const node = await wikiMixin.getWikiNode.call(c, 'wikcnZ');
147
+ assert.equal(node.viaUser, false);
148
+ assert.equal(node.fallbackWarning, '⚠️ g', 'fallbackWarning must survive onto the node so json() hoists it');
149
+ }
150
+
151
+ // --- 7. get_wiki_node handler still synthesizes for obj_tokens on dual failure ---
152
+ // withIdentityFallback dual-failure error message embeds the Feishu code
153
+ // (e.g. "as user: code=953001 ..."). The handler's /95300\d/ detection must
154
+ // keep matching so search_wiki obj_tokens (docxXXX) still resolve.
155
+ {
156
+ const { handlers } = require('./tools/wiki');
157
+ const err = new Error('getNode failed on both identities. as user: code=953001 msg=node not found. as app: getNode failed (953001): invalid token');
158
+ const ctx = {
159
+ getOfficialClient: () => ({
160
+ getWikiNode: async () => { throw err; },
161
+ }),
162
+ };
163
+ const resp = await handlers.get_wiki_node({ node_token: 'docxabcdef' }, ctx);
164
+ const body = JSON.parse(resp.content[0].text);
165
+ assert.equal(body.obj_type, 'docx', 'obj_token synthesis must survive the dual-identity error shape');
166
+ assert.equal(body.obj_token, 'docxabcdef');
167
+ }
168
+
169
+ // --- 7b. synthesis also survives the LIVE error shape (131005, not 95300x) ---
170
+ // Real Feishu instances return code=131005 "not found" for non-wiki tokens
171
+ // (observed in E2E 2026-06-06); only the `node.*not.*found` regex branch
172
+ // catches it. Pin that branch so a regex edit can't silently regress it.
173
+ {
174
+ const { handlers } = require('./tools/wiki');
175
+ const err = new Error('getNode failed on both identities. as user: code=131005 msg=not found. as app: getNode failed (HTTP 400, code=131005): not found');
176
+ const ctx = {
177
+ getOfficialClient: () => ({
178
+ getWikiNode: async () => { throw err; },
179
+ }),
180
+ };
181
+ const resp = await handlers.get_wiki_node({ node_token: 'bascnabcdef' }, ctx);
182
+ const body = JSON.parse(resp.content[0].text);
183
+ assert.equal(body.obj_type, 'bitable', 'live 131005 error shape must still trigger obj_token synthesis');
184
+ assert.equal(body.obj_token, 'bascnabcdef');
185
+ }
186
+
187
+ // --- 10. search pagination: nextOffset cursor surfaces; params pass through ---
188
+ {
189
+ const c = fakeClient({
190
+ uatResult: { code: 0, data: { docs_entities: [{ t: 1 }, { t: 2 }], has_more: true }, _viaUser: true },
191
+ });
192
+ const res = await docsMixin.searchDocs.call(c, 'q', { pageSize: 2, pageToken: '4' });
193
+ assert.equal(c.calls.asUserOrApp[0].body.offset, 4, 'searchDocs offset passthrough');
194
+ assert.equal(c.calls.asUserOrApp[0].body.count, 2, 'searchDocs page size passthrough');
195
+ assert.equal(res.nextOffset, 6, 'searchDocs nextOffset = offset + items returned');
196
+ }
197
+ {
198
+ const c = fakeClient({
199
+ uatResult: { code: 0, data: { docs_entities: [{ t: 1 }], has_more: true }, _viaUser: true },
200
+ });
201
+ const res = await wikiMixin.searchWiki.call(c, 'q', { pageSize: 1, offset: 3 });
202
+ assert.equal(c.calls.asUserOrApp[0].body.offset, 3, 'searchWiki offset passthrough');
203
+ assert.equal(c.calls.asUserOrApp[0].body.count, 1, 'searchWiki page size passthrough');
204
+ assert.equal(res.nextOffset, 4, 'searchWiki nextOffset cursor');
205
+ assert.equal(res.hasMore, true, 'searchWiki must surface hasMore');
206
+ }
207
+ // schema: pagination params exposed on both search tools
208
+ {
209
+ const sd = require('./tools/docs').schemas.find(s => s.name === 'search_docs');
210
+ const sw = require('./tools/wiki').schemas.find(s => s.name === 'search_wiki');
211
+ assert.ok(sd.inputSchema.properties.page_size && sd.inputSchema.properties.offset, 'search_docs schema pagination');
212
+ assert.ok(sw.inputSchema.properties.page_size && sw.inputSchema.properties.offset, 'search_wiki schema pagination');
213
+ }
214
+
215
+ // --- 11. unvalidated args are clamped, never reach Feishu as NaN/negative ---
216
+ // Tool args have no schema validation layer; a bad offset/page_size must be
217
+ // normalized to sane non-negative integers (Copilot review, PR #115).
218
+ {
219
+ const c = fakeClient({
220
+ uatResult: { code: 0, data: { docs_entities: [{ t: 1 }], has_more: true }, _viaUser: true },
221
+ });
222
+ const res = await docsMixin.searchDocs.call(c, 'q', { pageSize: 'abc', pageToken: '-5' });
223
+ const body = c.calls.asUserOrApp[0].body;
224
+ assert.equal(body.offset, 0, 'searchDocs negative offset clamps to 0');
225
+ assert.equal(body.count, 10, 'searchDocs non-numeric page size falls back to default');
226
+ assert.equal(res.nextOffset, 1, 'nextOffset math stays sane after clamping');
227
+ }
228
+ {
229
+ const c = fakeClient({
230
+ uatResult: { code: 0, data: { docs_entities: [], has_more: false }, _viaUser: true },
231
+ });
232
+ await wikiMixin.searchWiki.call(c, 'q', { pageSize: NaN, offset: 'xyz' });
233
+ const body = c.calls.asUserOrApp[0].body;
234
+ assert.equal(body.offset, 0, 'searchWiki non-numeric offset clamps to 0');
235
+ assert.equal(body.count, 20, 'searchWiki NaN page size falls back to default');
236
+ }
237
+
238
+ // --- 11b. abnormal has_more:true + empty page must NOT emit a stalled cursor ---
239
+ // nextOffset === offset would loop a paging caller forever (final release
240
+ // review, v1.3.16). hasMore stays visible; the unusable cursor is withheld.
241
+ {
242
+ const c = fakeClient({
243
+ uatResult: { code: 0, data: { docs_entities: [], has_more: true }, _viaUser: true },
244
+ });
245
+ const res = await docsMixin.searchDocs.call(c, 'q', { pageToken: '5' });
246
+ assert.equal(res.hasMore, true);
247
+ assert.equal(res.nextOffset, undefined, 'searchDocs empty page must not emit nextOffset === offset');
248
+ }
249
+ {
250
+ const c = fakeClient({
251
+ uatResult: { code: 0, data: { docs_entities: [], has_more: true }, _viaUser: true },
252
+ });
253
+ const res = await wikiMixin.searchWiki.call(c, 'q', { offset: 5 });
254
+ assert.equal(res.nextOffset, undefined, 'searchWiki empty page must not emit nextOffset === offset');
255
+ }
256
+
257
+ // --- 11c. explicit offset:0 is honored by the handlers (not dropped as falsy) ---
258
+ {
259
+ const docsHandlers = require('./tools/docs').handlers;
260
+ let got;
261
+ const ctx = { getOfficialClient: () => ({ searchDocs: async (q, opts) => { got = opts; return { items: [] }; } }) };
262
+ await docsHandlers.search_docs({ query: 'q', offset: 0 }, ctx);
263
+ assert.equal(got.pageToken, '0', 'search_docs handler must pass explicit offset:0 through');
264
+ }
265
+ {
266
+ const wikiHandlers = require('./tools/wiki').handlers;
267
+ let got;
268
+ const ctx = { getOfficialClient: () => ({ searchWiki: async (q, opts) => { got = opts; return { items: [] }; } }) };
269
+ await wikiHandlers.search_wiki({ query: 'q', offset: 0 }, ctx);
270
+ assert.equal(got.offset, 0, 'search_wiki handler must pass explicit offset:0 through');
271
+ }
272
+
273
+ // --- 12. scopeHint fires ONLY for empty root listing via bot ---
274
+ // A bot-visible folder that is genuinely empty must stay a bare [] — the
275
+ // blind-spot hint is about the bot's OWN root vs the user's 我的空间
276
+ // (Copilot review, PR #115). 403-on-personal-folder throws and never gets here.
277
+ {
278
+ const c = fakeClient({
279
+ uatResult: { code: 0, data: { files: [], has_more: false }, _viaUser: false },
280
+ });
281
+ const res = await driveMixin.listFiles.call(c, 'fldcnSharedEmpty');
282
+ assert.equal(res.scopeHint, undefined, 'empty bot-visible folder must NOT carry the root blind-spot hint');
283
+ }
284
+
285
+ // --- 8. list_files tool schema exposes pagination + UAT-first semantics ---
286
+ {
287
+ const { schemas } = require('./tools/drive');
288
+ const lf = schemas.find(s => s.name === 'list_files');
289
+ assert.ok(lf.inputSchema.properties.page_size, 'list_files schema: page_size');
290
+ assert.ok(lf.inputSchema.properties.page_token, 'list_files schema: page_token');
291
+ assert.ok(/UAT/i.test(lf.description), 'list_files description must state UAT-first routing');
292
+ }
293
+
294
+ // --- 9. list_files handler passes pagination args through ---
295
+ {
296
+ const { handlers } = require('./tools/drive');
297
+ let got;
298
+ const ctx = {
299
+ getOfficialClient: () => ({
300
+ listFiles: async (folderToken, opts) => { got = { folderToken, opts }; return { items: [], viaUser: true }; },
301
+ }),
302
+ };
303
+ await handlers.list_files({ folder_token: 'fldX', page_size: 25, page_token: 'PT2' }, ctx);
304
+ assert.equal(got.folderToken, 'fldX');
305
+ assert.equal(got.opts.pageSize, 25);
306
+ assert.equal(got.opts.pageToken, 'PT2');
307
+ }
308
+
309
+ console.log('uat-read-paths.js: PASS');
310
+ }
311
+
312
+ if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
313
+ module.exports = { run };
@@ -106,6 +106,7 @@ const schemas = [
106
106
  filter: { type: 'object', description: 'Filter conditions (search only, optional)' },
107
107
  sort: { type: 'array', description: 'Sort conditions (search only, optional)' },
108
108
  page_size: { type: 'number', description: 'Results per page (search only, default 20)' },
109
+ page_token: { type: 'string', description: 'Pagination cursor (search only) — pass the pageToken from a previous response to fetch the next page when hasMore is true.' },
109
110
  },
110
111
  required: ['action', 'app_token', 'table_id'],
111
112
  },
@@ -221,7 +222,7 @@ const handlers = {
221
222
  switch (args.action) {
222
223
  case 'search':
223
224
  return json(await c.searchBitableRecords(appToken, args.table_id, {
224
- filter: args.filter, sort: args.sort, pageSize: args.page_size,
225
+ filter: args.filter, sort: args.sort, pageSize: args.page_size, pageToken: args.page_token,
225
226
  }));
226
227
  case 'get': {
227
228
  need(args.record_id, 'record_id', 'get');
package/src/tools/docs.js CHANGED
@@ -9,10 +9,14 @@ const { text, json } = require('./_registry');
9
9
  const schemas = [
10
10
  {
11
11
  name: 'search_docs',
12
- description: '[Official API] Search Feishu documents by keyword.',
12
+ description: '[Official API] Search Feishu documents by keyword. UAT-first with app fallback: with user identity (UAT) the search covers docs visible to YOU, including your personal space; via bot it only covers docs shared with the bot. Response carries viaUser; when hasMore is true, pass the returned nextOffset back as offset to page forward.',
13
13
  inputSchema: {
14
14
  type: 'object',
15
- properties: { query: { type: 'string', description: 'Search keyword' } },
15
+ properties: {
16
+ query: { type: 'string', description: 'Search keyword' },
17
+ page_size: { type: 'number', description: 'Max results per page (default 10)' },
18
+ offset: { type: 'number', description: 'Pagination offset from a previous nextOffset' },
19
+ },
16
20
  required: ['query'],
17
21
  },
18
22
  },
@@ -27,11 +31,13 @@ const schemas = [
27
31
  },
28
32
  {
29
33
  name: 'get_doc_blocks',
30
- description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis.',
34
+ description: '[Official API] Get structured block tree of a document. Returns block types, content, and hierarchy for precise document analysis. Follows pagination internally and returns ALL blocks by default — hasMore:false in the response guarantees the complete tree (pre-v1.3.17 silently capped at 500 blocks). For very large docs, pass max_blocks to bound one call (rounded up to 500/page granularity) and page forward by passing the returned nextPageToken back as page_token; a bounded response carries truncated:true + hasMore:true so partial output is never silent.',
31
35
  inputSchema: {
32
36
  type: 'object',
33
37
  properties: {
34
38
  document_id: { type: 'string', description: 'Document ID (from search_docs or create_doc)' },
39
+ page_token: { type: 'string', description: 'Resume cursor — pass the nextPageToken from a previous truncated response to fetch the next slice.' },
40
+ max_blocks: { type: 'number', description: 'Soft cap on blocks returned in this call (rounded up to page granularity of 500). Omit to fetch the entire document.' },
35
41
  },
36
42
  required: ['document_id'],
37
43
  },
@@ -52,7 +58,7 @@ const schemas = [
52
58
  },
53
59
  {
54
60
  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 — 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.',
61
+ 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`). ⚠ `update_text_elements` REPLACES the block\'s ENTIRE elements array — it is a full overwrite, NOT a patch/append. Any element you omit (bold runs, links, prefixes) is permanently lost; to change part of a block, read it first (get_doc_blocks) and resend ALL elements.\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
62
  inputSchema: {
57
63
  type: 'object',
58
64
  properties: {
@@ -68,8 +74,8 @@ const schemas = [
68
74
  image_token: { type: 'string', description: 'Pre-uploaded docx image token — create mode C, or update image-replace.' },
69
75
  file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
70
76
  file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
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"]]}.' },
77
+ update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}. ⚠ update_text_elements is a FULL REPLACEMENT of the block\'s elements array (not patch/append) — include every element you want to keep, or the omitted ones are permanently lost.' },
78
+ 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, failedCells?}. Cell fills auto-retry transient Feishu errors with backoff; cells that still fail do NOT abort the call — they are listed in failedCells:[{row,col,cellId,textBlockId?,reason,skipped?}] (0-based row/col) so you can repair each via action=update on its textBlockId (or action=create into its cellId). Example: {"rows":2,"columns":2,"cells":[["Name","Role"],["Ann","PM"]]}.' },
73
79
  },
74
80
  required: ['action', 'document_id'],
75
81
  },
@@ -95,13 +101,21 @@ function need(arg, name, action) {
95
101
 
96
102
  const handlers = {
97
103
  async search_docs(args, ctx) {
98
- return json(await ctx.getOfficialClient().searchDocs(args.query));
104
+ const opts = {};
105
+ if (args.page_size) opts.pageSize = args.page_size;
106
+ if (args.offset !== undefined) opts.pageToken = String(args.offset);
107
+ return json(await ctx.getOfficialClient().searchDocs(args.query, opts));
99
108
  },
100
109
  async read_doc(args, ctx) {
101
110
  return json(await ctx.getOfficialClient().readDoc(await ctx.resolveDocId(args.document_id)));
102
111
  },
103
112
  async get_doc_blocks(args, ctx) {
104
- return json(await ctx.getOfficialClient().getDocBlocks(await ctx.resolveDocId(args.document_id)));
113
+ const opts = {};
114
+ if (args.page_token) opts.pageToken = args.page_token;
115
+ // Pass through verbatim — the client clamps to a finite integer >= 1 and
116
+ // treats anything else as "no cap" (PR #118 review).
117
+ if (args.max_blocks !== undefined) opts.maxBlocks = args.max_blocks;
118
+ return json(await ctx.getOfficialClient().getDocBlocks(await ctx.resolveDocId(args.document_id), opts));
105
119
  },
106
120
  async create_doc(args, ctx) {
107
121
  const r = await ctx.getOfficialClient().createDoc(args.title, args.folder_id, {
@@ -126,11 +140,20 @@ const handlers = {
126
140
  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
141
  if (args.table) {
128
142
  const t = args.table;
129
- return json(await official.createDocTable(docId, args.parent_block_id, {
143
+ const r = await official.createDocTable(docId, args.parent_block_id, {
130
144
  rows: t.rows, columns: t.columns, cells: t.cells,
131
145
  columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
132
146
  index: args.index,
133
- }));
147
+ });
148
+ // Partial fill — lift a repair-oriented warning above the JSON so the
149
+ // caller cannot mistake a half-filled table for success (json() hoists
150
+ // fallbackWarning to the top of the rendered response).
151
+ if (r.failedCells && r.failedCells.length) {
152
+ const attempted = r.filled + r.failedCells.length;
153
+ const fillWarn = `⚠ Table ${r.tableBlockId} created, but ${r.failedCells.length}/${attempted} cell(s) could not be filled (transient errors were already retried). See failedCells[] below — repair each with manage_doc_block(action=update, block_id=<textBlockId>, update_body={update_text_elements:...}), or action=create into its cellId when no textBlockId is given.`;
154
+ r.fallbackWarning = r.fallbackWarning ? `${fillWarn}\n\n${r.fallbackWarning}` : fillWarn;
155
+ }
156
+ return json(r);
134
157
  }
135
158
  if (args.image_path || args.image_token) {
136
159
  const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
@@ -200,7 +223,13 @@ const handlers = {
200
223
  return text(`read_doc_markdown: feishu-docx render failed — ${e.message}. Try get_doc_blocks for raw JSON fallback. (feishu-docx version may need upgrading)`);
201
224
  }
202
225
 
203
- return text(_normaliseEmbeds(md));
226
+ md = _normaliseEmbeds(md);
227
+ // getDocBlocks paginates to completion, so hasMore only survives a stalled
228
+ // upstream cursor — still, never let partial output pass as the whole doc.
229
+ if (result.hasMore) {
230
+ md += `\n\n[output truncated: rendered ${blocks.length} blocks but the document has more — the block list could not be fetched to completion. Retry, or use get_doc_blocks with page_token to inspect the tail.]`;
231
+ }
232
+ return text(md);
204
233
  },
205
234
  };
206
235
 
@@ -9,10 +9,14 @@ const { text, json } = require('./_registry');
9
9
  const schemas = [
10
10
  {
11
11
  name: 'list_files',
12
- description: '[Official API] List files in a Drive folder.',
12
+ description: '[Official API] List files in a Drive folder. UAT-first with app fallback: with user identity (UAT), empty folder_token lists YOUR personal-space ("我的空间") root; via bot it can only see folders shared with the bot (personal-space folders return 403). Response carries viaUser so you know whose view you got. Use the returned file token with manage_drive_file to copy/move/delete.',
13
13
  inputSchema: {
14
14
  type: 'object',
15
- properties: { folder_token: { type: 'string', description: 'Folder token (empty for root)' } },
15
+ properties: {
16
+ folder_token: { type: 'string', description: 'Folder token (empty for root — your 我的空间 root when UAT is configured)' },
17
+ page_size: { type: 'number', description: 'Max files per page (default 50)' },
18
+ page_token: { type: 'string', description: 'Pagination token from a previous nextPageToken' },
19
+ },
16
20
  },
17
21
  },
18
22
  {
@@ -66,7 +70,10 @@ function need(arg, name, action) {
66
70
 
67
71
  const handlers = {
68
72
  async list_files(args, ctx) {
69
- return json(await ctx.getOfficialClient().listFiles(args.folder_token));
73
+ const opts = {};
74
+ if (args.page_size) opts.pageSize = args.page_size;
75
+ if (args.page_token) opts.pageToken = args.page_token;
76
+ return json(await ctx.getOfficialClient().listFiles(args.folder_token, opts));
70
77
  },
71
78
  async create_folder(args, ctx) {
72
79
  const r = await ctx.getOfficialClient().createFolder(args.name, args.parent_token);
@@ -74,7 +74,18 @@ const handlers = {
74
74
  if (args.action === 'remove') {
75
75
  return json(await official.removeChatMembers(args.chat_id, args.member_ids, memberIdType));
76
76
  }
77
- return json(await official.addChatMembers(args.chat_id, args.member_ids, memberIdType));
77
+ const r = await official.addChatMembers(args.chat_id, args.member_ids, memberIdType);
78
+ // Lift a top warning when any id did NOT actually join — invalid format,
79
+ // nonexistent user, or stuck behind join-approval. Without it an empty
80
+ // invalidIds read as "everyone is in" while some never joined.
81
+ const problems = [];
82
+ if (r.invalidIds?.length) problems.push(`${r.invalidIds.length} invalid id(s): ${r.invalidIds.join(', ')}`);
83
+ if (r.notExistedIds?.length) problems.push(`${r.notExistedIds.length} nonexistent user(s): ${r.notExistedIds.join(', ')}`);
84
+ if (r.pendingApprovalIds?.length) problems.push(`${r.pendingApprovalIds.length} pending group-owner approval (NOT yet in the group): ${r.pendingApprovalIds.join(', ')}`);
85
+ if (problems.length) {
86
+ r.fallbackWarning = `⚠ Partial add — ${problems.join('; ')}. The rest joined successfully.`;
87
+ }
88
+ return json(r);
78
89
  },
79
90
  };
80
91
 
@@ -117,6 +117,7 @@ const schemas = [
117
117
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
118
118
  sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
119
119
  expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_message_resource (kind=image or file).' },
120
+ page_token: { type: 'string', description: 'Pagination cursor — pass the pageToken from a previous response to fetch the next (older) page when hasMore is true.' },
120
121
  },
121
122
  required: ['chat_id'],
122
123
  },
@@ -155,6 +156,7 @@ const schemas = [
155
156
  end_time: { type: 'string', description: 'End timestamp in seconds (optional)' },
156
157
  sort_type: { type: 'string', enum: ['ByCreateTimeDesc', 'ByCreateTimeAsc'], description: 'Sort order (default: ByCreateTimeDesc = newest first)' },
157
158
  expand_merge_forward: { type: 'boolean', description: 'Auto-expand merge_forward placeholders into their child messages (default true). Children carry parentMessageId; use that id (not the child id) with download_message_resource (kind=image or file).' },
159
+ page_token: { type: 'string', description: 'Pagination cursor — pass the pageToken from a previous response to fetch the next (older) page when hasMore is true.' },
158
160
  via_user: { type: 'boolean', description: 'v1.3.12 — explicit identity override. `true` skips the bot path and reads directly via UAT (use when the chat is yours / external and you know bot has no access). `false` skips UAT fallback and surfaces the bot error instead of cross-identity hop (use when you specifically want the bot view). Omit for default auto-fallback (bot first, UAT on failure).' },
159
161
  },
160
162
  required: ['chat_id'],
@@ -230,7 +232,7 @@ const handlers = {
230
232
  }
231
233
  return json(await official.readMessagesAsUser(chatId, {
232
234
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
233
- sortType: args.sort_type,
235
+ sortType: args.sort_type, pageToken: args.page_token,
234
236
  expandMergeForward: args.expand_merge_forward !== false,
235
237
  }, uc));
236
238
  },
@@ -247,7 +249,7 @@ const handlers = {
247
249
  const official = ctx.getOfficialClient();
248
250
  const msgOpts = {
249
251
  pageSize: args.page_size, startTime: args.start_time, endTime: args.end_time,
250
- sortType: args.sort_type,
252
+ sortType: args.sort_type, pageToken: args.page_token,
251
253
  expandMergeForward: args.expand_merge_forward !== false,
252
254
  };
253
255
  // v1.3.12: via_user opt-in routing override. true=skip bot (UAT only),
package/src/tools/wiki.js CHANGED
@@ -5,26 +5,31 @@ const { json } = require('./_registry');
5
5
  const schemas = [
6
6
  {
7
7
  name: 'list_wiki_spaces',
8
- description: '[Official API] List all accessible Wiki spaces.',
8
+ description: '[Official API] List all accessible Wiki spaces. Follows pagination internally and returns ALL spaces (pre-v1.3.17 silently capped at 50). If the upstream cursor stalls, the response carries hasMore:true — treat that as a partial list.',
9
9
  inputSchema: { type: 'object', properties: {} },
10
10
  },
11
11
  {
12
12
  name: 'search_wiki',
13
- description: '[Official API] Search Wiki nodes by keyword.',
13
+ description: '[Official API] Search Wiki nodes by keyword. UAT-first with app fallback: with user identity (UAT) the search covers wiki spaces visible to YOU; via bot it only covers spaces the bot was invited to. Response carries viaUser; when hasMore is true, pass the returned nextOffset back as offset to page forward.',
14
14
  inputSchema: {
15
15
  type: 'object',
16
- properties: { query: { type: 'string', description: 'Search keyword' } },
16
+ properties: {
17
+ query: { type: 'string', description: 'Search keyword' },
18
+ page_size: { type: 'number', description: 'Max results per page (default 20)' },
19
+ offset: { type: 'number', description: 'Pagination offset from a previous nextOffset' },
20
+ },
17
21
  required: ['query'],
18
22
  },
19
23
  },
20
24
  {
21
25
  name: 'list_wiki_nodes',
22
- description: '[Official API] List nodes in a Wiki space.',
26
+ description: '[Official API] List nodes in a Wiki space (50 per page). When hasMore is true, pass the returned pageToken back as page_token to fetch the next page. A page may be EMPTY while hasMore is still true (Feishu permission-filters per page) — do not stop at an empty page; keep paging until hasMore is false.',
23
27
  inputSchema: {
24
28
  type: 'object',
25
29
  properties: {
26
30
  space_id: { type: 'string', description: 'Wiki space ID' },
27
31
  parent_node_token: { type: 'string', description: 'Parent node token (optional)' },
32
+ page_token: { type: 'string', description: 'Pagination cursor — pass the pageToken from a previous response to fetch the next page.' },
28
33
  },
29
34
  required: ['space_id'],
30
35
  },
@@ -119,10 +124,13 @@ const handlers = {
119
124
  return json(await ctx.getOfficialClient().listWikiSpaces());
120
125
  },
121
126
  async search_wiki(args, ctx) {
122
- return json(await ctx.getOfficialClient().searchWiki(args.query));
127
+ const opts = {};
128
+ if (args.page_size) opts.pageSize = args.page_size;
129
+ if (args.offset !== undefined) opts.offset = args.offset;
130
+ return json(await ctx.getOfficialClient().searchWiki(args.query, opts));
123
131
  },
124
132
  async list_wiki_nodes(args, ctx) {
125
- return json(await ctx.getOfficialClient().listWikiNodes(args.space_id, { parentNodeToken: args.parent_node_token }));
133
+ return json(await ctx.getOfficialClient().listWikiNodes(args.space_id, { parentNodeToken: args.parent_node_token, pageToken: args.page_token }));
126
134
  },
127
135
  async create_wiki_node(args, ctx) {
128
136
  return json(await ctx.getOfficialClient().createWikiNode(args.space_id, {