feishu-user-plugin 1.3.16 → 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,194 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for the cursor-chain fixes (2026-06-07 systemic audit, follow-up
3
+ // to PR #118): clients that return hasMore but drop the resume cursor, tool
4
+ // schemas/handlers that never expose page_token, and partial-failure arrays
5
+ // silently swallowed.
6
+ //
7
+ // Covers:
8
+ // 1. read_messages / read_p2p_messages — page_token passthrough (client ready)
9
+ // 2. list_wiki_nodes — client must return pageToken; handler must pass it
10
+ // 3. manage_bitable_record(search) — same chain as 2
11
+ // 4. listWikiSpaces — internal pagination to completion (was silent 50-cap)
12
+ // 5. manage_members(add) — surface not_existed_id_list / pending_approval_id_list
13
+ 'use strict';
14
+
15
+ const assert = require('assert');
16
+ const wikiClient = require('./clients/official/wiki');
17
+ const bitableClient = require('./clients/official/bitable');
18
+ const groupsClient = require('./clients/official/groups');
19
+ const imReadHandlers = require('./tools/im-read').handlers;
20
+ const wikiHandlers = require('./tools/wiki').handlers;
21
+ const bitableHandlers = require('./tools/bitable').handlers;
22
+ const groupsHandlers = require('./tools/groups').handlers;
23
+
24
+ let pass = 0, fail = 0;
25
+ async function ok(name, fn) {
26
+ try { await fn(); console.log(` OK ${name}`); pass++; }
27
+ catch (e) { console.log(` FAIL ${name}: ${e.message}`); fail++; }
28
+ }
29
+
30
+ async function run() {
31
+ console.log('=== test-pagination-cursor-chain ===');
32
+
33
+ // --- 1. read_messages / read_p2p_messages ---
34
+
35
+ await ok('read_messages handler passes page_token through to readMessagesWithFallback', async () => {
36
+ let got;
37
+ const official = {
38
+ hasUAT: true,
39
+ readMessagesWithFallback: async (chatId, opts) => { got = { chatId, opts }; return { items: [] }; },
40
+ };
41
+ const ctx = { getOfficialClient: () => official, getUserClient: async () => { throw new Error('no cookie'); } };
42
+ await imReadHandlers.read_messages({ chat_id: 'oc_test', page_token: 'PT1' }, ctx);
43
+ assert.strictEqual(got.chatId, 'oc_test');
44
+ assert.strictEqual(got.opts.pageToken, 'PT1', 'page_token must reach the client as pageToken');
45
+ });
46
+
47
+ await ok('read_p2p_messages handler passes page_token through to readMessagesAsUser', async () => {
48
+ let got;
49
+ const official = {
50
+ readMessagesAsUser: async (chatId, opts) => { got = { chatId, opts }; return { items: [] }; },
51
+ };
52
+ const ctx = { getOfficialClient: () => official, getUserClient: async () => { throw new Error('no cookie'); } };
53
+ await imReadHandlers.read_p2p_messages({ chat_id: '7123456', page_token: 'PT2' }, ctx);
54
+ assert.strictEqual(got.opts.pageToken, 'PT2');
55
+ });
56
+
57
+ // --- 2. list_wiki_nodes ---
58
+
59
+ await ok('listWikiNodes client returns the resume pageToken alongside hasMore', async () => {
60
+ const self = {
61
+ async _asUserOrApp() {
62
+ return { data: { items: [{ node_token: 'n1' }], has_more: true, page_token: 'WNEXT' }, _viaUser: true };
63
+ },
64
+ };
65
+ const r = await wikiClient.listWikiNodes.call(self, 'sp1', {});
66
+ assert.strictEqual(r.hasMore, true);
67
+ assert.strictEqual(r.pageToken, 'WNEXT', 'hasMore without a cursor is a dead end');
68
+ });
69
+
70
+ await ok('list_wiki_nodes handler passes page_token through', async () => {
71
+ let got;
72
+ const ctx = {
73
+ getOfficialClient: () => ({
74
+ listWikiNodes: async (spaceId, opts) => { got = { spaceId, opts }; return { items: [], hasMore: false }; },
75
+ }),
76
+ };
77
+ await wikiHandlers.list_wiki_nodes({ space_id: 'sp1', page_token: 'PT3' }, ctx);
78
+ assert.strictEqual(got.opts.pageToken, 'PT3');
79
+ });
80
+
81
+ // --- 3. manage_bitable_record(search) ---
82
+
83
+ await ok('searchBitableRecords client returns the resume pageToken alongside hasMore', async () => {
84
+ const self = {
85
+ async _asUserOrApp() {
86
+ return { data: { items: [{ record_id: 'r1' }], total: 2000, has_more: true, page_token: 'BNEXT' }, _viaUser: true };
87
+ },
88
+ // legacy path uses _safeSDKCall? keep both shims so the test follows the impl
89
+ async _safeSDKCall(fn) { return fn(); },
90
+ };
91
+ const r = await bitableClient.searchBitableRecords.call(self, 'app1', 'tbl1', {});
92
+ assert.strictEqual(r.hasMore, true);
93
+ assert.strictEqual(r.pageToken, 'BNEXT', 'hasMore + total without a cursor strands the caller at page 1');
94
+ });
95
+
96
+ await ok('manage_bitable_record(search) handler passes page_token through', async () => {
97
+ let got;
98
+ const ctx = {
99
+ resolveDocId: async (x) => x,
100
+ getOfficialClient: () => ({
101
+ searchBitableRecords: async (appToken, tableId, opts) => { got = { appToken, tableId, opts }; return { items: [], hasMore: false }; },
102
+ }),
103
+ };
104
+ await bitableHandlers.manage_bitable_record({ action: 'search', app_token: 'app1', table_id: 'tbl1', page_token: 'PT4' }, ctx);
105
+ assert.strictEqual(got.opts.pageToken, 'PT4');
106
+ });
107
+
108
+ // --- 4. listWikiSpaces full pagination ---
109
+
110
+ await ok('listWikiSpaces follows page_token to fetch ALL spaces past the 50/page cap', async () => {
111
+ const calls = [];
112
+ const self = {
113
+ async _asUserOrApp({ query }) {
114
+ const key = (query && query.page_token) || '';
115
+ calls.push(key);
116
+ if (key === '') return { data: { items: Array.from({ length: 50 }, (_, i) => ({ space_id: 's' + i })), has_more: true, page_token: 'SP2' }, _viaUser: true };
117
+ if (key === 'SP2') return { data: { items: [{ space_id: 's50' }], has_more: false }, _viaUser: true };
118
+ throw new Error('unexpected page_token: ' + key);
119
+ },
120
+ };
121
+ const r = await wikiClient.listWikiSpaces.call(self);
122
+ assert.strictEqual(r.items.length, 51, 'all pages concatenated');
123
+ assert.deepStrictEqual(calls, ['', 'SP2']);
124
+ assert.ok(!r.hasMore, 'complete fetch must not flag hasMore');
125
+ });
126
+
127
+ await ok('listWikiSpaces continues past a permission-filtered empty page (empty + advancing token)', async () => {
128
+ const calls = [];
129
+ const self = {
130
+ async _asUserOrApp({ query }) {
131
+ const key = (query && query.page_token) || '';
132
+ calls.push(key);
133
+ if (key === '') return { data: { items: [{ space_id: 's1' }], has_more: true, page_token: 'SP2' }, _viaUser: true };
134
+ if (key === 'SP2') return { data: { items: [], has_more: true, page_token: 'SP3' }, _viaUser: true }; // filtered-empty
135
+ if (key === 'SP3') return { data: { items: [{ space_id: 's2' }], has_more: false }, _viaUser: true };
136
+ throw new Error('unexpected page_token: ' + key);
137
+ },
138
+ };
139
+ const r = await wikiClient.listWikiSpaces.call(self);
140
+ assert.strictEqual(r.items.length, 2, 'must page through the filtered-empty page to reach s2');
141
+ assert.deepStrictEqual(calls, ['', 'SP2', 'SP3']);
142
+ assert.ok(!r.hasMore, 'complete fetch must not flag hasMore');
143
+ });
144
+
145
+ await ok('listWikiSpaces terminates on a stalled cursor and reports hasMore', async () => {
146
+ let n = 0;
147
+ const self = {
148
+ async _asUserOrApp() {
149
+ n++;
150
+ return { data: { items: n === 1 ? [{ space_id: 's1' }] : [], has_more: true, page_token: 'LOOP' }, _viaUser: true };
151
+ },
152
+ };
153
+ const r = await wikiClient.listWikiSpaces.call(self);
154
+ assert.ok(n <= 3, `must terminate, made ${n} calls`);
155
+ assert.strictEqual(r.items.length, 1);
156
+ assert.strictEqual(r.hasMore, true, 'incompleteness must be visible');
157
+ });
158
+
159
+ // --- 5. manage_members(add) partial-failure arrays ---
160
+
161
+ await ok('addChatMembers surfaces not_existed_id_list and pending_approval_id_list', async () => {
162
+ const self = {
163
+ async _safeSDKCall(fn) {
164
+ return { data: { invalid_id_list: ['bad1'], not_existed_id_list: ['ghost1'], pending_approval_id_list: ['wait1', 'wait2'] } };
165
+ },
166
+ };
167
+ const r = await groupsClient.addChatMembers.call(self, 'oc_g', ['bad1', 'ghost1', 'wait1', 'wait2', 'ok1']);
168
+ assert.deepStrictEqual(r.invalidIds, ['bad1']);
169
+ assert.deepStrictEqual(r.notExistedIds, ['ghost1'], 'not_existed ids must not be swallowed');
170
+ assert.deepStrictEqual(r.pendingApprovalIds, ['wait1', 'wait2'], 'pending-approval ids must not be swallowed');
171
+ });
172
+
173
+ await ok('manage_members(add) response names the partial failures, not a silent success', async () => {
174
+ const ctx = {
175
+ getOfficialClient: () => ({
176
+ addChatMembers: async () => ({ invalidIds: [], notExistedIds: ['ghost1'], pendingApprovalIds: ['wait1'] }),
177
+ }),
178
+ };
179
+ const res = await groupsHandlers.manage_members({ chat_id: 'oc_g', member_ids: ['ghost1', 'wait1', 'ok1'], action: 'add' }, ctx);
180
+ const txt = res.content[0].text;
181
+ assert.ok(/ghost1/.test(txt), 'not-existed id visible in response');
182
+ assert.ok(/wait1/.test(txt), 'pending id visible in response');
183
+ assert.ok(txt.startsWith('⚠'), `partial failure must be lifted as a top warning, not buried in JSON: ${txt.slice(0, 120)}`);
184
+ });
185
+
186
+ console.log(`\n=== test-pagination-cursor-chain: ${pass} passed, ${fail} failed ===`);
187
+ if (fail > 0) process.exit(1);
188
+ }
189
+
190
+ if (require.main === module) {
191
+ run().catch((e) => { console.error('test-pagination-cursor-chain harness error:', e); process.exit(1); });
192
+ }
193
+
194
+ 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
@@ -31,11 +31,13 @@ const schemas = [
31
31
  },
32
32
  {
33
33
  name: 'get_doc_blocks',
34
- 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.',
35
35
  inputSchema: {
36
36
  type: 'object',
37
37
  properties: {
38
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.' },
39
41
  },
40
42
  required: ['document_id'],
41
43
  },
@@ -56,7 +58,7 @@ const schemas = [
56
58
  },
57
59
  {
58
60
  name: 'manage_doc_block',
59
- 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.',
60
62
  inputSchema: {
61
63
  type: 'object',
62
64
  properties: {
@@ -72,8 +74,8 @@ const schemas = [
72
74
  image_token: { type: 'string', description: 'Pre-uploaded docx image token — create mode C, or update image-replace.' },
73
75
  file_path: { type: 'string', description: 'Local file path — create mode D (mutually exclusive with other create modes).' },
74
76
  file_token: { type: 'string', description: 'Pre-uploaded docx file token — create mode E, or update file-replace.' },
75
- update_body: { type: 'object', description: 'Generic update payload for action=update. E.g. {update_text_elements:{elements:[{text_run:{content:"new text"}}]}}.' },
76
- 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"]]}.' },
77
79
  },
78
80
  required: ['action', 'document_id'],
79
81
  },
@@ -108,7 +110,12 @@ const handlers = {
108
110
  return json(await ctx.getOfficialClient().readDoc(await ctx.resolveDocId(args.document_id)));
109
111
  },
110
112
  async get_doc_blocks(args, ctx) {
111
- 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));
112
119
  },
113
120
  async create_doc(args, ctx) {
114
121
  const r = await ctx.getOfficialClient().createDoc(args.title, args.folder_id, {
@@ -133,11 +140,20 @@ const handlers = {
133
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.');
134
141
  if (args.table) {
135
142
  const t = args.table;
136
- return json(await official.createDocTable(docId, args.parent_block_id, {
143
+ const r = await official.createDocTable(docId, args.parent_block_id, {
137
144
  rows: t.rows, columns: t.columns, cells: t.cells,
138
145
  columnWidth: t.column_width, headerRow: t.header_row, headerColumn: t.header_column,
139
146
  index: args.index,
140
- }));
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);
141
157
  }
142
158
  if (args.image_path || args.image_token) {
143
159
  const r = await official.createDocBlockWithImage(docId, args.parent_block_id, {
@@ -207,7 +223,13 @@ const handlers = {
207
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)`);
208
224
  }
209
225
 
210
- 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);
211
233
  },
212
234
  };
213
235
 
@@ -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,7 +5,7 @@ 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
  {
@@ -23,12 +23,13 @@ const schemas = [
23
23
  },
24
24
  {
25
25
  name: 'list_wiki_nodes',
26
- 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.',
27
27
  inputSchema: {
28
28
  type: 'object',
29
29
  properties: {
30
30
  space_id: { type: 'string', description: 'Wiki space ID' },
31
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.' },
32
33
  },
33
34
  required: ['space_id'],
34
35
  },
@@ -129,7 +130,7 @@ const handlers = {
129
130
  return json(await ctx.getOfficialClient().searchWiki(args.query, opts));
130
131
  },
131
132
  async list_wiki_nodes(args, ctx) {
132
- 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 }));
133
134
  },
134
135
  async create_wiki_node(args, ctx) {
135
136
  return json(await ctx.getOfficialClient().createWikiNode(args.space_id, {