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.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcpb/manifest.json +1 -1
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- 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/wiki.md +1 -1
- package/src/clients/official/bitable.js +6 -1
- package/src/clients/official/docs.js +210 -53
- package/src/clients/official/groups.js +8 -1
- package/src/clients/official/wiki.js +48 -11
- package/src/error-codes.js +7 -0
- package/src/test-all.js +12 -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/tools/bitable.js +2 -1
- package/src/tools/docs.js +30 -8
- package/src/tools/groups.js +12 -1
- package/src/tools/im-read.js +4 -2
- package/src/tools/wiki.js +4 -3
|
@@ -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 };
|
package/src/tools/bitable.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/tools/groups.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/tools/im-read.js
CHANGED
|
@@ -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, {
|