@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15

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.
Files changed (120) hide show
  1. package/README.md +3 -1
  2. package/index.ts +65 -0
  3. package/openclaw.plugin.json +3 -7
  4. package/package.json +32 -9
  5. package/skills/feishu-doc/SKILL.md +211 -0
  6. package/skills/feishu-doc/references/block-types.md +103 -0
  7. package/skills/feishu-drive/SKILL.md +97 -0
  8. package/skills/feishu-perm/SKILL.md +119 -0
  9. package/skills/feishu-wiki/SKILL.md +111 -0
  10. package/src/accounts.test.ts +371 -0
  11. package/src/accounts.ts +244 -0
  12. package/src/async.ts +62 -0
  13. package/src/bitable.ts +725 -0
  14. package/src/bot.card-action.test.ts +63 -0
  15. package/src/bot.checkBotMentioned.test.ts +193 -0
  16. package/src/bot.stripBotMention.test.ts +134 -0
  17. package/src/bot.test.ts +2107 -0
  18. package/src/bot.ts +1556 -0
  19. package/src/card-action.ts +79 -0
  20. package/src/channel.test.ts +48 -0
  21. package/src/channel.ts +369 -0
  22. package/src/chat-schema.ts +24 -0
  23. package/src/chat.test.ts +89 -0
  24. package/src/chat.ts +130 -0
  25. package/src/client.test.ts +324 -0
  26. package/src/client.ts +196 -0
  27. package/src/config-schema.test.ts +247 -0
  28. package/src/config-schema.ts +306 -0
  29. package/src/dedup.ts +203 -0
  30. package/src/directory.test.ts +40 -0
  31. package/src/directory.ts +156 -0
  32. package/src/doc-schema.ts +182 -0
  33. package/src/docx-batch-insert.test.ts +90 -0
  34. package/src/docx-batch-insert.ts +187 -0
  35. package/src/docx-color-text.ts +149 -0
  36. package/src/docx-table-ops.ts +298 -0
  37. package/src/docx.account-selection.test.ts +70 -0
  38. package/src/docx.test.ts +445 -0
  39. package/src/docx.ts +1460 -0
  40. package/src/drive-schema.ts +46 -0
  41. package/src/drive.ts +228 -0
  42. package/src/dynamic-agent.ts +131 -0
  43. package/src/external-keys.test.ts +20 -0
  44. package/src/external-keys.ts +19 -0
  45. package/src/feishu-command-handler.ts +59 -0
  46. package/src/media.test.ts +523 -0
  47. package/src/media.ts +484 -0
  48. package/src/mention.ts +133 -0
  49. package/src/monitor.account.ts +562 -0
  50. package/src/monitor.reaction.test.ts +653 -0
  51. package/src/monitor.startup.test.ts +190 -0
  52. package/src/monitor.startup.ts +64 -0
  53. package/src/monitor.state.defaults.test.ts +46 -0
  54. package/src/monitor.state.ts +155 -0
  55. package/src/monitor.test-mocks.ts +45 -0
  56. package/src/monitor.transport.ts +264 -0
  57. package/src/monitor.ts +95 -0
  58. package/src/monitor.webhook-e2e.test.ts +214 -0
  59. package/src/monitor.webhook-security.test.ts +142 -0
  60. package/src/monitor.webhook.test-helpers.ts +98 -0
  61. package/src/nextclaw-sdk/account-id.ts +31 -0
  62. package/src/nextclaw-sdk/compat.ts +8 -0
  63. package/src/nextclaw-sdk/core-channel.ts +296 -0
  64. package/src/nextclaw-sdk/core-pairing.ts +224 -0
  65. package/src/nextclaw-sdk/core.ts +26 -0
  66. package/src/nextclaw-sdk/dedupe.ts +246 -0
  67. package/src/nextclaw-sdk/feishu.ts +77 -0
  68. package/src/nextclaw-sdk/history.ts +127 -0
  69. package/src/nextclaw-sdk/network-body.ts +245 -0
  70. package/src/nextclaw-sdk/network-fetch.ts +129 -0
  71. package/src/nextclaw-sdk/network-webhook.ts +182 -0
  72. package/src/nextclaw-sdk/network.ts +13 -0
  73. package/src/nextclaw-sdk/runtime-store.ts +26 -0
  74. package/src/nextclaw-sdk/secrets-config.ts +109 -0
  75. package/src/nextclaw-sdk/secrets-core.ts +170 -0
  76. package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
  77. package/src/nextclaw-sdk/secrets.ts +18 -0
  78. package/src/nextclaw-sdk/types.ts +300 -0
  79. package/src/onboarding.status.test.ts +25 -0
  80. package/src/onboarding.test.ts +143 -0
  81. package/src/onboarding.ts +489 -0
  82. package/src/outbound.test.ts +356 -0
  83. package/src/outbound.ts +176 -0
  84. package/src/perm-schema.ts +52 -0
  85. package/src/perm.ts +176 -0
  86. package/src/policy.test.ts +154 -0
  87. package/src/policy.ts +123 -0
  88. package/src/post.test.ts +105 -0
  89. package/src/post.ts +274 -0
  90. package/src/probe.test.ts +270 -0
  91. package/src/probe.ts +156 -0
  92. package/src/reactions.ts +153 -0
  93. package/src/reply-dispatcher.test.ts +513 -0
  94. package/src/reply-dispatcher.ts +397 -0
  95. package/src/runtime.ts +6 -0
  96. package/src/secret-input.ts +13 -0
  97. package/src/send-message.ts +71 -0
  98. package/src/send-result.ts +29 -0
  99. package/src/send-target.test.ts +74 -0
  100. package/src/send-target.ts +29 -0
  101. package/src/send.reply-fallback.test.ts +189 -0
  102. package/src/send.test.ts +168 -0
  103. package/src/send.ts +481 -0
  104. package/src/streaming-card.test.ts +54 -0
  105. package/src/streaming-card.ts +374 -0
  106. package/src/targets.test.ts +70 -0
  107. package/src/targets.ts +107 -0
  108. package/src/tool-account-routing.test.ts +129 -0
  109. package/src/tool-account.ts +70 -0
  110. package/src/tool-factory-test-harness.ts +76 -0
  111. package/src/tool-result.test.ts +32 -0
  112. package/src/tool-result.ts +14 -0
  113. package/src/tools-config.test.ts +21 -0
  114. package/src/tools-config.ts +22 -0
  115. package/src/types.ts +103 -0
  116. package/src/typing.test.ts +144 -0
  117. package/src/typing.ts +210 -0
  118. package/src/wiki-schema.ts +55 -0
  119. package/src/wiki.ts +233 -0
  120. package/index.js +0 -27
package/src/docx.ts ADDED
@@ -0,0 +1,1460 @@
1
+ import { existsSync, promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute } from "node:path";
4
+ import { basename } from "node:path";
5
+ import type * as Lark from "@larksuiteoapi/node-sdk";
6
+ import { Type } from "@sinclair/typebox";
7
+ import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
8
+ import { listEnabledFeishuAccounts } from "./accounts.js";
9
+ import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
10
+ import { BATCH_SIZE, insertBlocksInBatches } from "./docx-batch-insert.js";
11
+ import { updateColorText } from "./docx-color-text.js";
12
+ import {
13
+ cleanBlocksForDescendant,
14
+ insertTableRow,
15
+ insertTableColumn,
16
+ deleteTableRows,
17
+ deleteTableColumns,
18
+ mergeTableCells,
19
+ } from "./docx-table-ops.js";
20
+ import { getFeishuRuntime } from "./runtime.js";
21
+ import {
22
+ createFeishuToolClient,
23
+ resolveAnyEnabledFeishuToolsConfig,
24
+ resolveFeishuToolAccount,
25
+ } from "./tool-account.js";
26
+
27
+ // ============ Helpers ============
28
+
29
+ function json(data: unknown) {
30
+ return {
31
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
32
+ details: data,
33
+ };
34
+ }
35
+
36
+ /** Extract image URLs from markdown content */
37
+ function extractImageUrls(markdown: string): string[] {
38
+ const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
39
+ const urls: string[] = [];
40
+ let match;
41
+ while ((match = regex.exec(markdown)) !== null) {
42
+ const url = match[1].trim();
43
+ if (url.startsWith("http://") || url.startsWith("https://")) {
44
+ urls.push(url);
45
+ }
46
+ }
47
+ return urls;
48
+ }
49
+
50
+ const BLOCK_TYPE_NAMES: Record<number, string> = {
51
+ 1: "Page",
52
+ 2: "Text",
53
+ 3: "Heading1",
54
+ 4: "Heading2",
55
+ 5: "Heading3",
56
+ 12: "Bullet",
57
+ 13: "Ordered",
58
+ 14: "Code",
59
+ 15: "Quote",
60
+ 17: "Todo",
61
+ 18: "Bitable",
62
+ 21: "Diagram",
63
+ 22: "Divider",
64
+ 23: "File",
65
+ 27: "Image",
66
+ 30: "Sheet",
67
+ 31: "Table",
68
+ 32: "TableCell",
69
+ };
70
+
71
+ // Block types that cannot be created via documentBlockChildren.create API
72
+ const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
73
+
74
+ /** Clean blocks for insertion (remove unsupported types and read-only fields) */
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
76
+ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
77
+ const skipped: string[] = [];
78
+ const cleaned = blocks
79
+ .filter((block) => {
80
+ if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
81
+ const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
82
+ skipped.push(typeName);
83
+ return false;
84
+ }
85
+ return true;
86
+ })
87
+ .map((block) => {
88
+ if (block.block_type === 31 && block.table?.merge_info) {
89
+ const { merge_info: _merge_info, ...tableRest } = block.table;
90
+ return { ...block, table: tableRest };
91
+ }
92
+ return block;
93
+ });
94
+ return { cleaned, skipped };
95
+ }
96
+
97
+ // ============ Core Functions ============
98
+
99
+ /** Max blocks per documentBlockChildren.create request */
100
+ const MAX_BLOCKS_PER_INSERT = 50;
101
+ const MAX_CONVERT_RETRY_DEPTH = 8;
102
+
103
+ async function convertMarkdown(client: Lark.Client, markdown: string) {
104
+ const res = await client.docx.document.convert({
105
+ data: { content_type: "markdown", content: markdown },
106
+ });
107
+ if (res.code !== 0) {
108
+ throw new Error(res.msg);
109
+ }
110
+ return {
111
+ blocks: res.data?.blocks ?? [],
112
+ firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
113
+ };
114
+ }
115
+
116
+ function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
117
+ if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
118
+ const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
119
+ const sortedIds = new Set(firstLevelIds);
120
+ const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
121
+ return [...sorted, ...remaining];
122
+ }
123
+
124
+ /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
125
+ async function insertBlocks(
126
+ client: Lark.Client,
127
+ docToken: string,
128
+ blocks: any[],
129
+ parentBlockId?: string,
130
+ index?: number,
131
+ ): Promise<{ children: any[]; skipped: string[] }> {
132
+ /* eslint-enable @typescript-eslint/no-explicit-any */
133
+ const { cleaned, skipped } = cleanBlocksForInsert(blocks);
134
+ const blockId = parentBlockId ?? docToken;
135
+
136
+ if (cleaned.length === 0) {
137
+ return { children: [], skipped };
138
+ }
139
+
140
+ // Insert blocks one at a time to preserve document order.
141
+ // The batch API (sending all children at once) does not guarantee ordering
142
+ // because Feishu processes the batch asynchronously. Sequential single-block
143
+ // inserts (each appended to the end) produce deterministic results.
144
+ const allInserted: any[] = [];
145
+ for (const [offset, block] of cleaned.entries()) {
146
+ const res = await client.docx.documentBlockChildren.create({
147
+ path: { document_id: docToken, block_id: blockId },
148
+ data: {
149
+ children: [block],
150
+ ...(index !== undefined ? { index: index + offset } : {}),
151
+ },
152
+ });
153
+ if (res.code !== 0) {
154
+ throw new Error(res.msg);
155
+ }
156
+ allInserted.push(...(res.data?.children ?? []));
157
+ }
158
+ return { children: allInserted, skipped };
159
+ }
160
+
161
+ /** Split markdown into chunks at top-level headings (# or ##) to stay within API content limits */
162
+ function splitMarkdownByHeadings(markdown: string): string[] {
163
+ const lines = markdown.split("\n");
164
+ const chunks: string[] = [];
165
+ let current: string[] = [];
166
+ let inFencedBlock = false;
167
+
168
+ for (const line of lines) {
169
+ if (/^(`{3,}|~{3,})/.test(line)) {
170
+ inFencedBlock = !inFencedBlock;
171
+ }
172
+ if (!inFencedBlock && /^#{1,2}\s/.test(line) && current.length > 0) {
173
+ chunks.push(current.join("\n"));
174
+ current = [];
175
+ }
176
+ current.push(line);
177
+ }
178
+ if (current.length > 0) {
179
+ chunks.push(current.join("\n"));
180
+ }
181
+ return chunks;
182
+ }
183
+
184
+ /** Split markdown by size, preferring to break outside fenced code blocks when possible */
185
+ function splitMarkdownBySize(markdown: string, maxChars: number): string[] {
186
+ if (markdown.length <= maxChars) {
187
+ return [markdown];
188
+ }
189
+
190
+ const lines = markdown.split("\n");
191
+ const chunks: string[] = [];
192
+ let current: string[] = [];
193
+ let currentLength = 0;
194
+ let inFencedBlock = false;
195
+
196
+ for (const line of lines) {
197
+ if (/^(`{3,}|~{3,})/.test(line)) {
198
+ inFencedBlock = !inFencedBlock;
199
+ }
200
+
201
+ const lineLength = line.length + 1;
202
+ const wouldExceed = currentLength + lineLength > maxChars;
203
+ if (current.length > 0 && wouldExceed && !inFencedBlock) {
204
+ chunks.push(current.join("\n"));
205
+ current = [];
206
+ currentLength = 0;
207
+ }
208
+
209
+ current.push(line);
210
+ currentLength += lineLength;
211
+ }
212
+
213
+ if (current.length > 0) {
214
+ chunks.push(current.join("\n"));
215
+ }
216
+
217
+ if (chunks.length > 1) {
218
+ return chunks;
219
+ }
220
+
221
+ // Degenerate case: no safe boundary outside fenced content.
222
+ const midpoint = Math.floor(lines.length / 2);
223
+ if (midpoint <= 0 || midpoint >= lines.length) {
224
+ return [markdown];
225
+ }
226
+ return [lines.slice(0, midpoint).join("\n"), lines.slice(midpoint).join("\n")];
227
+ }
228
+
229
+ async function convertMarkdownWithFallback(client: Lark.Client, markdown: string, depth = 0) {
230
+ try {
231
+ return await convertMarkdown(client, markdown);
232
+ } catch (error) {
233
+ if (depth >= MAX_CONVERT_RETRY_DEPTH || markdown.length < 2) {
234
+ throw error;
235
+ }
236
+
237
+ const splitTarget = Math.max(256, Math.floor(markdown.length / 2));
238
+ const chunks = splitMarkdownBySize(markdown, splitTarget);
239
+ if (chunks.length <= 1) {
240
+ throw error;
241
+ }
242
+
243
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
244
+ const blocks: any[] = [];
245
+ const firstLevelBlockIds: string[] = [];
246
+
247
+ for (const chunk of chunks) {
248
+ const converted = await convertMarkdownWithFallback(client, chunk, depth + 1);
249
+ blocks.push(...converted.blocks);
250
+ firstLevelBlockIds.push(...converted.firstLevelBlockIds);
251
+ }
252
+
253
+ return { blocks, firstLevelBlockIds };
254
+ }
255
+ }
256
+
257
+ /** Convert markdown in chunks to avoid document.convert content size limits */
258
+ async function chunkedConvertMarkdown(client: Lark.Client, markdown: string) {
259
+ const chunks = splitMarkdownByHeadings(markdown);
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
261
+ const allBlocks: any[] = [];
262
+ const allFirstLevelBlockIds: string[] = [];
263
+ for (const chunk of chunks) {
264
+ const { blocks, firstLevelBlockIds } = await convertMarkdownWithFallback(client, chunk);
265
+ const sorted = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
266
+ allBlocks.push(...sorted);
267
+ allFirstLevelBlockIds.push(...firstLevelBlockIds);
268
+ }
269
+ return { blocks: allBlocks, firstLevelBlockIds: allFirstLevelBlockIds };
270
+ }
271
+
272
+ /** Insert blocks in batches of MAX_BLOCKS_PER_INSERT to avoid API 400 errors */
273
+ /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
274
+ async function chunkedInsertBlocks(
275
+ client: Lark.Client,
276
+ docToken: string,
277
+ blocks: any[],
278
+ parentBlockId?: string,
279
+ ): Promise<{ children: any[]; skipped: string[] }> {
280
+ /* eslint-enable @typescript-eslint/no-explicit-any */
281
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block types
282
+ const allChildren: any[] = [];
283
+ const allSkipped: string[] = [];
284
+
285
+ for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_INSERT) {
286
+ const batch = blocks.slice(i, i + MAX_BLOCKS_PER_INSERT);
287
+ const { children, skipped } = await insertBlocks(client, docToken, batch, parentBlockId);
288
+ allChildren.push(...children);
289
+ allSkipped.push(...skipped);
290
+ }
291
+
292
+ return { children: allChildren, skipped: allSkipped };
293
+ }
294
+
295
+ type Logger = { info?: (msg: string) => void };
296
+
297
+ /**
298
+ * Insert blocks using the Descendant API (supports tables, nested lists, large docs).
299
+ * Unlike the Children API, this supports block_type 31/32 (Table/TableCell).
300
+ *
301
+ * @param parentBlockId - Parent block to insert into (defaults to docToken = document root)
302
+ * @param index - Position within parent's children (-1 = end, 0 = first)
303
+ */
304
+ /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
305
+ async function insertBlocksWithDescendant(
306
+ client: Lark.Client,
307
+ docToken: string,
308
+ blocks: any[],
309
+ firstLevelBlockIds: string[],
310
+ { parentBlockId = docToken, index = -1 }: { parentBlockId?: string; index?: number } = {},
311
+ ): Promise<{ children: any[] }> {
312
+ /* eslint-enable @typescript-eslint/no-explicit-any */
313
+ const descendants = cleanBlocksForDescendant(blocks);
314
+ if (descendants.length === 0) {
315
+ return { children: [] };
316
+ }
317
+
318
+ const res = await client.docx.documentBlockDescendant.create({
319
+ path: { document_id: docToken, block_id: parentBlockId },
320
+ data: { children_id: firstLevelBlockIds, descendants, index },
321
+ });
322
+
323
+ if (res.code !== 0) {
324
+ throw new Error(`${res.msg} (code: ${res.code})`);
325
+ }
326
+
327
+ return { children: res.data?.children ?? [] };
328
+ }
329
+
330
+ async function clearDocumentContent(client: Lark.Client, docToken: string) {
331
+ const existing = await client.docx.documentBlock.list({
332
+ path: { document_id: docToken },
333
+ });
334
+ if (existing.code !== 0) {
335
+ throw new Error(existing.msg);
336
+ }
337
+
338
+ const childIds =
339
+ existing.data?.items
340
+ ?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
341
+ .map((b) => b.block_id) ?? [];
342
+
343
+ if (childIds.length > 0) {
344
+ const res = await client.docx.documentBlockChildren.batchDelete({
345
+ path: { document_id: docToken, block_id: docToken },
346
+ data: { start_index: 0, end_index: childIds.length },
347
+ });
348
+ if (res.code !== 0) {
349
+ throw new Error(res.msg);
350
+ }
351
+ }
352
+
353
+ return childIds.length;
354
+ }
355
+
356
+ async function uploadImageToDocx(
357
+ client: Lark.Client,
358
+ blockId: string,
359
+ imageBuffer: Buffer,
360
+ fileName: string,
361
+ docToken?: string,
362
+ ): Promise<string> {
363
+ const res = await client.drive.media.uploadAll({
364
+ data: {
365
+ file_name: fileName,
366
+ parent_type: "docx_image",
367
+ parent_node: blockId,
368
+ size: imageBuffer.length,
369
+ // Pass Buffer directly so form-data can calculate Content-Length correctly.
370
+ // Readable.from() produces a stream with unknown length, causing Content-Length
371
+ // mismatch that silently truncates uploads for images larger than ~1KB.
372
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK file type
373
+ file: imageBuffer as any,
374
+ // Required when the document block belongs to a non-default datacenter:
375
+ // tells the drive service which document the block belongs to for routing.
376
+ // Per API docs: certain upload scenarios require the cloud document token.
377
+ ...(docToken ? { extra: JSON.stringify({ drive_route_token: docToken }) } : {}),
378
+ },
379
+ });
380
+
381
+ const fileToken = res?.file_token;
382
+ if (!fileToken) {
383
+ throw new Error("Image upload failed: no file_token returned");
384
+ }
385
+ return fileToken;
386
+ }
387
+
388
+ async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
389
+ const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
390
+ return fetched.buffer;
391
+ }
392
+
393
+ async function resolveUploadInput(
394
+ url: string | undefined,
395
+ filePath: string | undefined,
396
+ maxBytes: number,
397
+ explicitFileName?: string,
398
+ imageInput?: string, // data URI, plain base64, or local path
399
+ ): Promise<{ buffer: Buffer; fileName: string }> {
400
+ // Enforce mutual exclusivity: exactly one input source must be provided.
401
+ const inputSources = (
402
+ [url ? "url" : null, filePath ? "file_path" : null, imageInput ? "image" : null] as (
403
+ | string
404
+ | null
405
+ )[]
406
+ ).filter(Boolean);
407
+ if (inputSources.length > 1) {
408
+ throw new Error(`Provide only one image source; got: ${inputSources.join(", ")}`);
409
+ }
410
+
411
+ // data URI: data:image/png;base64,xxxx
412
+ if (imageInput?.startsWith("data:")) {
413
+ const commaIdx = imageInput.indexOf(",");
414
+ if (commaIdx === -1) {
415
+ throw new Error("Invalid data URI: missing comma separator.");
416
+ }
417
+ const header = imageInput.slice(0, commaIdx);
418
+ const data = imageInput.slice(commaIdx + 1);
419
+ // Only base64-encoded data URIs are supported; reject plain/URL-encoded ones.
420
+ if (!header.includes(";base64")) {
421
+ throw new Error(
422
+ `Invalid data URI: missing ';base64' marker. ` +
423
+ `Expected format: data:image/png;base64,<base64data>`,
424
+ );
425
+ }
426
+ // Validate the payload is actually base64 before decoding; Node's decoder
427
+ // is permissive and would silently accept garbage bytes otherwise.
428
+ const trimmedData = data.trim();
429
+ if (trimmedData.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmedData)) {
430
+ throw new Error(
431
+ `Invalid data URI: base64 payload contains characters outside the standard alphabet.`,
432
+ );
433
+ }
434
+ const mimeMatch = header.match(/data:([^;]+)/);
435
+ const ext = mimeMatch?.[1]?.split("/")[1] ?? "png";
436
+ // Estimate decoded byte count from base64 length BEFORE allocating the
437
+ // full buffer to avoid spiking memory on oversized payloads.
438
+ const estimatedBytes = Math.ceil((trimmedData.length * 3) / 4);
439
+ if (estimatedBytes > maxBytes) {
440
+ throw new Error(
441
+ `Image data URI exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`,
442
+ );
443
+ }
444
+ const buffer = Buffer.from(trimmedData, "base64");
445
+ return { buffer, fileName: explicitFileName ?? `image.${ext}` };
446
+ }
447
+
448
+ // local path: ~, ./ and ../ are unambiguous (not in base64 alphabet).
449
+ // Absolute paths (/...) are supported but must exist on disk. If an absolute
450
+ // path does not exist we throw immediately rather than falling through to
451
+ // base64 decoding, which would silently upload garbage bytes.
452
+ // Note: JPEG base64 starts with "/9j/" — pass as data:image/jpeg;base64,...
453
+ // to avoid ambiguity with absolute paths.
454
+ if (imageInput) {
455
+ const candidate = imageInput.startsWith("~") ? imageInput.replace(/^~/, homedir()) : imageInput;
456
+ const unambiguousPath =
457
+ imageInput.startsWith("~") || imageInput.startsWith("./") || imageInput.startsWith("../");
458
+ const absolutePath = isAbsolute(imageInput);
459
+
460
+ if (unambiguousPath || (absolutePath && existsSync(candidate))) {
461
+ const buffer = await fs.readFile(candidate);
462
+ if (buffer.length > maxBytes) {
463
+ throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`);
464
+ }
465
+ return { buffer, fileName: explicitFileName ?? basename(candidate) };
466
+ }
467
+
468
+ if (absolutePath && !existsSync(candidate)) {
469
+ throw new Error(
470
+ `File not found: "${candidate}". ` +
471
+ `If you intended to pass image binary data, use a data URI instead: data:image/jpeg;base64,...`,
472
+ );
473
+ }
474
+ }
475
+
476
+ // plain base64 string (standard base64 alphabet includes '+', '/', '=')
477
+ if (imageInput) {
478
+ const trimmed = imageInput.trim();
479
+ // Node's Buffer.from is permissive and silently ignores out-of-alphabet chars,
480
+ // which would decode malformed strings into arbitrary bytes. Reject early.
481
+ if (trimmed.length === 0 || !/^[A-Za-z0-9+/]+=*$/.test(trimmed)) {
482
+ throw new Error(
483
+ `Invalid base64: image input contains characters outside the standard base64 alphabet. ` +
484
+ `Use a data URI (data:image/png;base64,...) or a local file path instead.`,
485
+ );
486
+ }
487
+ // Estimate decoded byte count from base64 length BEFORE allocating the
488
+ // full buffer to avoid spiking memory on oversized payloads.
489
+ const estimatedBytes = Math.ceil((trimmed.length * 3) / 4);
490
+ if (estimatedBytes > maxBytes) {
491
+ throw new Error(
492
+ `Base64 image exceeds limit: estimated ${estimatedBytes} bytes > ${maxBytes} bytes`,
493
+ );
494
+ }
495
+ const buffer = Buffer.from(trimmed, "base64");
496
+ if (buffer.length === 0) {
497
+ throw new Error("Base64 image decoded to empty buffer; check the input.");
498
+ }
499
+ return { buffer, fileName: explicitFileName ?? "image.png" };
500
+ }
501
+
502
+ if (!url && !filePath) {
503
+ throw new Error("Either url, file_path, or image (base64/data URI) must be provided");
504
+ }
505
+ if (url && filePath) {
506
+ throw new Error("Provide only one of url or file_path");
507
+ }
508
+
509
+ if (url) {
510
+ const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
511
+ const urlPath = new URL(url).pathname;
512
+ const guessed = urlPath.split("/").pop() || "upload.bin";
513
+ return {
514
+ buffer: fetched.buffer,
515
+ fileName: explicitFileName || guessed,
516
+ };
517
+ }
518
+
519
+ const buffer = await fs.readFile(filePath!);
520
+ if (buffer.length > maxBytes) {
521
+ throw new Error(`Local file exceeds limit: ${buffer.length} bytes > ${maxBytes} bytes`);
522
+ }
523
+ return {
524
+ buffer,
525
+ fileName: explicitFileName || basename(filePath!),
526
+ };
527
+ }
528
+
529
+ /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
530
+ async function processImages(
531
+ client: Lark.Client,
532
+ docToken: string,
533
+ markdown: string,
534
+ insertedBlocks: any[],
535
+ maxBytes: number,
536
+ ): Promise<number> {
537
+ /* eslint-enable @typescript-eslint/no-explicit-any */
538
+ const imageUrls = extractImageUrls(markdown);
539
+ if (imageUrls.length === 0) {
540
+ return 0;
541
+ }
542
+
543
+ const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
544
+
545
+ let processed = 0;
546
+ for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
547
+ const url = imageUrls[i];
548
+ const blockId = imageBlocks[i].block_id;
549
+
550
+ try {
551
+ const buffer = await downloadImage(url, maxBytes);
552
+ const urlPath = new URL(url).pathname;
553
+ const fileName = urlPath.split("/").pop() || `image_${i}.png`;
554
+ const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName, docToken);
555
+
556
+ await client.docx.documentBlock.patch({
557
+ path: { document_id: docToken, block_id: blockId },
558
+ data: {
559
+ replace_image: { token: fileToken },
560
+ },
561
+ });
562
+
563
+ processed++;
564
+ } catch (err) {
565
+ console.error(`Failed to process image ${url}:`, err);
566
+ }
567
+ }
568
+
569
+ return processed;
570
+ }
571
+
572
+ async function uploadImageBlock(
573
+ client: Lark.Client,
574
+ docToken: string,
575
+ maxBytes: number,
576
+ url?: string,
577
+ filePath?: string,
578
+ parentBlockId?: string,
579
+ filename?: string,
580
+ index?: number,
581
+ imageInput?: string, // data URI, plain base64, or local path
582
+ ) {
583
+ // Step 1: Create an empty image block (block_type 27).
584
+ // Per Feishu FAQ: image token cannot be set at block creation time.
585
+ const insertRes = await client.docx.documentBlockChildren.create({
586
+ path: { document_id: docToken, block_id: parentBlockId ?? docToken },
587
+ params: { document_revision_id: -1 },
588
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK type
589
+ data: { children: [{ block_type: 27, image: {} as any }], index: index ?? -1 },
590
+ });
591
+ if (insertRes.code !== 0) {
592
+ throw new Error(`Failed to create image block: ${insertRes.msg}`);
593
+ }
594
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape
595
+ const imageBlockId = insertRes.data?.children?.find((b: any) => b.block_type === 27)?.block_id;
596
+ if (!imageBlockId) {
597
+ throw new Error("Failed to create image block");
598
+ }
599
+
600
+ // Step 2: Resolve and upload the image buffer.
601
+ const upload = await resolveUploadInput(url, filePath, maxBytes, filename, imageInput);
602
+ const fileToken = await uploadImageToDocx(
603
+ client,
604
+ imageBlockId,
605
+ upload.buffer,
606
+ upload.fileName,
607
+ docToken, // drive_route_token for multi-datacenter routing
608
+ );
609
+
610
+ // Step 3: Set the image token on the block.
611
+ const patchRes = await client.docx.documentBlock.patch({
612
+ path: { document_id: docToken, block_id: imageBlockId },
613
+ data: { replace_image: { token: fileToken } },
614
+ });
615
+ if (patchRes.code !== 0) {
616
+ throw new Error(patchRes.msg);
617
+ }
618
+
619
+ return {
620
+ success: true,
621
+ block_id: imageBlockId,
622
+ file_token: fileToken,
623
+ file_name: upload.fileName,
624
+ size: upload.buffer.length,
625
+ };
626
+ }
627
+
628
+ async function uploadFileBlock(
629
+ client: Lark.Client,
630
+ docToken: string,
631
+ maxBytes: number,
632
+ url?: string,
633
+ filePath?: string,
634
+ parentBlockId?: string,
635
+ filename?: string,
636
+ ) {
637
+ const blockId = parentBlockId ?? docToken;
638
+
639
+ // Feishu API does not allow creating empty file blocks (block_type 23).
640
+ // Workaround: create a placeholder text block, then replace it with file content.
641
+ // Actually, file blocks need a different approach: use markdown link as placeholder.
642
+ const upload = await resolveUploadInput(url, filePath, maxBytes, filename);
643
+
644
+ // Create a placeholder text block first
645
+ const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`;
646
+ const converted = await convertMarkdown(client, placeholderMd);
647
+ const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds);
648
+ const { children: inserted } = await insertBlocks(client, docToken, sorted, blockId);
649
+
650
+ // Get the first inserted block - we'll delete it and create the file in its place
651
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape
652
+ const placeholderBlock = inserted[0];
653
+ if (!placeholderBlock?.block_id) {
654
+ throw new Error("Failed to create placeholder block for file upload");
655
+ }
656
+
657
+ // Delete the placeholder
658
+ const parentId = placeholderBlock.parent_id ?? blockId;
659
+ const childrenRes = await client.docx.documentBlockChildren.get({
660
+ path: { document_id: docToken, block_id: parentId },
661
+ });
662
+ if (childrenRes.code !== 0) {
663
+ throw new Error(childrenRes.msg);
664
+ }
665
+ const items = childrenRes.data?.items ?? [];
666
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
667
+ const placeholderIdx = items.findIndex(
668
+ (item: any) => item.block_id === placeholderBlock.block_id,
669
+ );
670
+ if (placeholderIdx >= 0) {
671
+ const deleteRes = await client.docx.documentBlockChildren.batchDelete({
672
+ path: { document_id: docToken, block_id: parentId },
673
+ data: { start_index: placeholderIdx, end_index: placeholderIdx + 1 },
674
+ });
675
+ if (deleteRes.code !== 0) {
676
+ throw new Error(deleteRes.msg);
677
+ }
678
+ }
679
+
680
+ // Upload file to Feishu drive
681
+ const fileRes = await client.drive.media.uploadAll({
682
+ data: {
683
+ file_name: upload.fileName,
684
+ parent_type: "docx_file",
685
+ parent_node: docToken,
686
+ size: upload.buffer.length,
687
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK file type
688
+ file: upload.buffer as any,
689
+ },
690
+ });
691
+
692
+ const fileToken = fileRes?.file_token;
693
+ if (!fileToken) {
694
+ throw new Error("File upload failed: no file_token returned");
695
+ }
696
+
697
+ return {
698
+ success: true,
699
+ file_token: fileToken,
700
+ file_name: upload.fileName,
701
+ size: upload.buffer.length,
702
+ note: "File uploaded to drive. Use the file_token to reference it. Direct file block creation is not supported by the Feishu API.",
703
+ };
704
+ }
705
+
706
+ // ============ Actions ============
707
+
708
+ const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
709
+
710
+ async function readDoc(client: Lark.Client, docToken: string) {
711
+ const [contentRes, infoRes, blocksRes] = await Promise.all([
712
+ client.docx.document.rawContent({ path: { document_id: docToken } }),
713
+ client.docx.document.get({ path: { document_id: docToken } }),
714
+ client.docx.documentBlock.list({ path: { document_id: docToken } }),
715
+ ]);
716
+
717
+ if (contentRes.code !== 0) {
718
+ throw new Error(contentRes.msg);
719
+ }
720
+
721
+ const blocks = blocksRes.data?.items ?? [];
722
+ const blockCounts: Record<string, number> = {};
723
+ const structuredTypes: string[] = [];
724
+
725
+ for (const b of blocks) {
726
+ const type = b.block_type ?? 0;
727
+ const name = BLOCK_TYPE_NAMES[type] || `type_${type}`;
728
+ blockCounts[name] = (blockCounts[name] || 0) + 1;
729
+
730
+ if (STRUCTURED_BLOCK_TYPES.has(type) && !structuredTypes.includes(name)) {
731
+ structuredTypes.push(name);
732
+ }
733
+ }
734
+
735
+ let hint: string | undefined;
736
+ if (structuredTypes.length > 0) {
737
+ hint = `This document contains ${structuredTypes.join(", ")} which are NOT included in the plain text above. Use feishu_doc with action: "list_blocks" to get full content.`;
738
+ }
739
+
740
+ return {
741
+ title: infoRes.data?.document?.title,
742
+ content: contentRes.data?.content,
743
+ revision_id: infoRes.data?.document?.revision_id,
744
+ block_count: blocks.length,
745
+ block_types: blockCounts,
746
+ ...(hint && { hint }),
747
+ };
748
+ }
749
+
750
+ async function createDoc(
751
+ client: Lark.Client,
752
+ title: string,
753
+ folderToken?: string,
754
+ options?: { grantToRequester?: boolean; requesterOpenId?: string },
755
+ ) {
756
+ const res = await client.docx.document.create({
757
+ data: { title, folder_token: folderToken },
758
+ });
759
+ if (res.code !== 0) {
760
+ throw new Error(res.msg);
761
+ }
762
+ const doc = res.data?.document;
763
+ const docToken = doc?.document_id;
764
+ if (!docToken) {
765
+ throw new Error("Document creation succeeded but no document_id was returned");
766
+ }
767
+ const shouldGrantToRequester = options?.grantToRequester !== false;
768
+ const requesterOpenId = options?.requesterOpenId?.trim();
769
+ const requesterPermType: "edit" = "edit";
770
+
771
+ let requesterPermissionAdded = false;
772
+ let requesterPermissionSkippedReason: string | undefined;
773
+ let requesterPermissionError: string | undefined;
774
+
775
+ if (shouldGrantToRequester) {
776
+ if (!requesterOpenId) {
777
+ requesterPermissionSkippedReason = "trusted requester identity unavailable";
778
+ } else {
779
+ try {
780
+ await client.drive.permissionMember.create({
781
+ path: { token: docToken },
782
+ params: { type: "docx", need_notification: false },
783
+ data: {
784
+ member_type: "openid",
785
+ member_id: requesterOpenId,
786
+ perm: requesterPermType,
787
+ },
788
+ });
789
+ requesterPermissionAdded = true;
790
+ } catch (err) {
791
+ requesterPermissionError = err instanceof Error ? err.message : String(err);
792
+ }
793
+ }
794
+ }
795
+
796
+ return {
797
+ document_id: docToken,
798
+ title: doc?.title,
799
+ url: `https://feishu.cn/docx/${docToken}`,
800
+ ...(shouldGrantToRequester && {
801
+ requester_permission_added: requesterPermissionAdded,
802
+ ...(requesterOpenId && { requester_open_id: requesterOpenId }),
803
+ requester_perm_type: requesterPermType,
804
+ ...(requesterPermissionSkippedReason && {
805
+ requester_permission_skipped_reason: requesterPermissionSkippedReason,
806
+ }),
807
+ ...(requesterPermissionError && { requester_permission_error: requesterPermissionError }),
808
+ }),
809
+ };
810
+ }
811
+
812
+ async function writeDoc(
813
+ client: Lark.Client,
814
+ docToken: string,
815
+ markdown: string,
816
+ maxBytes: number,
817
+ logger?: Logger,
818
+ ) {
819
+ const deleted = await clearDocumentContent(client, docToken);
820
+ logger?.info?.("feishu_doc: Converting markdown...");
821
+ const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
822
+ if (blocks.length === 0) {
823
+ return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
824
+ }
825
+
826
+ logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
827
+ const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
828
+ const { children: inserted } =
829
+ blocks.length > BATCH_SIZE
830
+ ? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
831
+ : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
832
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
833
+ logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
834
+
835
+ return {
836
+ success: true,
837
+ blocks_deleted: deleted,
838
+ blocks_added: blocks.length,
839
+ images_processed: imagesProcessed,
840
+ };
841
+ }
842
+
843
+ async function appendDoc(
844
+ client: Lark.Client,
845
+ docToken: string,
846
+ markdown: string,
847
+ maxBytes: number,
848
+ logger?: Logger,
849
+ ) {
850
+ logger?.info?.("feishu_doc: Converting markdown...");
851
+ const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
852
+ if (blocks.length === 0) {
853
+ throw new Error("Content is empty");
854
+ }
855
+
856
+ logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
857
+ const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
858
+ const { children: inserted } =
859
+ blocks.length > BATCH_SIZE
860
+ ? await insertBlocksInBatches(client, docToken, sortedBlocks, firstLevelBlockIds, logger)
861
+ : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds);
862
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
863
+ logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
864
+
865
+ return {
866
+ success: true,
867
+ blocks_added: blocks.length,
868
+ images_processed: imagesProcessed,
869
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
870
+ block_ids: inserted.map((b: any) => b.block_id),
871
+ };
872
+ }
873
+
874
+ async function insertDoc(
875
+ client: Lark.Client,
876
+ docToken: string,
877
+ markdown: string,
878
+ afterBlockId: string,
879
+ maxBytes: number,
880
+ logger?: Logger,
881
+ ) {
882
+ const blockInfo = await client.docx.documentBlock.get({
883
+ path: { document_id: docToken, block_id: afterBlockId },
884
+ });
885
+ if (blockInfo.code !== 0) throw new Error(blockInfo.msg);
886
+
887
+ const parentId = blockInfo.data?.block?.parent_id ?? docToken;
888
+
889
+ // Paginate through all children to reliably locate after_block_id.
890
+ // documentBlockChildren.get returns up to 200 children per page; large
891
+ // parents require multiple requests.
892
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
893
+ const items: any[] = [];
894
+ let pageToken: string | undefined;
895
+ do {
896
+ const childrenRes = await client.docx.documentBlockChildren.get({
897
+ path: { document_id: docToken, block_id: parentId },
898
+ params: pageToken ? { page_token: pageToken } : {},
899
+ });
900
+ if (childrenRes.code !== 0) throw new Error(childrenRes.msg);
901
+ items.push(...(childrenRes.data?.items ?? []));
902
+ pageToken = childrenRes.data?.page_token ?? undefined;
903
+ } while (pageToken);
904
+
905
+ const blockIndex = items.findIndex((item) => item.block_id === afterBlockId);
906
+ if (blockIndex === -1) {
907
+ throw new Error(
908
+ `after_block_id "${afterBlockId}" was not found among the children of parent block "${parentId}". ` +
909
+ `Use list_blocks to verify the block ID.`,
910
+ );
911
+ }
912
+ const insertIndex = blockIndex + 1;
913
+
914
+ logger?.info?.("feishu_doc: Converting markdown...");
915
+ const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
916
+ if (blocks.length === 0) throw new Error("Content is empty");
917
+ const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
918
+
919
+ logger?.info?.(
920
+ `feishu_doc: Converted to ${blocks.length} blocks, inserting at index ${insertIndex}...`,
921
+ );
922
+ const { children: inserted } =
923
+ blocks.length > BATCH_SIZE
924
+ ? await insertBlocksInBatches(
925
+ client,
926
+ docToken,
927
+ sortedBlocks,
928
+ firstLevelBlockIds,
929
+ logger,
930
+ parentId,
931
+ insertIndex,
932
+ )
933
+ : await insertBlocksWithDescendant(client, docToken, sortedBlocks, firstLevelBlockIds, {
934
+ parentBlockId: parentId,
935
+ index: insertIndex,
936
+ });
937
+
938
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
939
+ logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
940
+
941
+ return {
942
+ success: true,
943
+ blocks_added: blocks.length,
944
+ images_processed: imagesProcessed,
945
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
946
+ block_ids: inserted.map((b: any) => b.block_id),
947
+ };
948
+ }
949
+
950
+ async function createTable(
951
+ client: Lark.Client,
952
+ docToken: string,
953
+ rowSize: number,
954
+ columnSize: number,
955
+ parentBlockId?: string,
956
+ columnWidth?: number[],
957
+ ) {
958
+ if (columnWidth && columnWidth.length !== columnSize) {
959
+ throw new Error("column_width length must equal column_size");
960
+ }
961
+
962
+ const blockId = parentBlockId ?? docToken;
963
+ const res = await client.docx.documentBlockChildren.create({
964
+ path: { document_id: docToken, block_id: blockId },
965
+ data: {
966
+ children: [
967
+ {
968
+ block_type: 31,
969
+ table: {
970
+ property: {
971
+ row_size: rowSize,
972
+ column_size: columnSize,
973
+ ...(columnWidth && columnWidth.length > 0 ? { column_width: columnWidth } : {}),
974
+ },
975
+ },
976
+ },
977
+ ],
978
+ },
979
+ });
980
+
981
+ if (res.code !== 0) {
982
+ throw new Error(res.msg);
983
+ }
984
+
985
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type
986
+ const tableBlock = (res.data?.children as any[] | undefined)?.find((b) => b.block_type === 31);
987
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return shape may vary by version
988
+ const cells = (tableBlock?.children as any[] | undefined) ?? [];
989
+
990
+ return {
991
+ success: true,
992
+ table_block_id: tableBlock?.block_id,
993
+ row_size: rowSize,
994
+ column_size: columnSize,
995
+ // row-major cell ids, if API returns them directly
996
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK return type
997
+ table_cell_block_ids: cells.map((c: any) => c.block_id).filter(Boolean),
998
+ raw_children_count: res.data?.children?.length ?? 0,
999
+ };
1000
+ }
1001
+
1002
+ async function writeTableCells(
1003
+ client: Lark.Client,
1004
+ docToken: string,
1005
+ tableBlockId: string,
1006
+ values: string[][],
1007
+ ) {
1008
+ if (!values.length || !values[0]?.length) {
1009
+ throw new Error("values must be a non-empty 2D array");
1010
+ }
1011
+
1012
+ const tableRes = await client.docx.documentBlock.get({
1013
+ path: { document_id: docToken, block_id: tableBlockId },
1014
+ });
1015
+ if (tableRes.code !== 0) {
1016
+ throw new Error(tableRes.msg);
1017
+ }
1018
+
1019
+ const tableBlock = tableRes.data?.block;
1020
+ if (tableBlock?.block_type !== 31) {
1021
+ throw new Error("table_block_id is not a table block");
1022
+ }
1023
+
1024
+ // SDK types are loose here across versions
1025
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload
1026
+ const tableData = (tableBlock as any).table;
1027
+ const rows = tableData?.property?.row_size as number | undefined;
1028
+ const cols = tableData?.property?.column_size as number | undefined;
1029
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block payload
1030
+ const cellIds = (tableData?.cells as any[] | undefined) ?? [];
1031
+
1032
+ if (!rows || !cols || !cellIds.length) {
1033
+ throw new Error(
1034
+ "Table cell IDs unavailable from table block. Use list_blocks/get_block and pass explicit cell block IDs if needed.",
1035
+ );
1036
+ }
1037
+
1038
+ const writeRows = Math.min(values.length, rows);
1039
+ let written = 0;
1040
+
1041
+ for (let r = 0; r < writeRows; r++) {
1042
+ const rowValues = values[r] ?? [];
1043
+ const writeCols = Math.min(rowValues.length, cols);
1044
+
1045
+ for (let c = 0; c < writeCols; c++) {
1046
+ const cellId = cellIds[r * cols + c];
1047
+ if (!cellId) continue;
1048
+
1049
+ // table cell is a container block: clear existing children, then create text child blocks
1050
+ const childrenRes = await client.docx.documentBlockChildren.get({
1051
+ path: { document_id: docToken, block_id: cellId },
1052
+ });
1053
+ if (childrenRes.code !== 0) {
1054
+ throw new Error(childrenRes.msg);
1055
+ }
1056
+
1057
+ const existingChildren = childrenRes.data?.items ?? [];
1058
+ if (existingChildren.length > 0) {
1059
+ const delRes = await client.docx.documentBlockChildren.batchDelete({
1060
+ path: { document_id: docToken, block_id: cellId },
1061
+ data: { start_index: 0, end_index: existingChildren.length },
1062
+ });
1063
+ if (delRes.code !== 0) {
1064
+ throw new Error(delRes.msg);
1065
+ }
1066
+ }
1067
+
1068
+ const text = rowValues[c] ?? "";
1069
+ const converted = await convertMarkdown(client, text);
1070
+ const sorted = sortBlocksByFirstLevel(converted.blocks, converted.firstLevelBlockIds);
1071
+
1072
+ if (sorted.length > 0) {
1073
+ await insertBlocks(client, docToken, sorted, cellId);
1074
+ }
1075
+
1076
+ written++;
1077
+ }
1078
+ }
1079
+
1080
+ return {
1081
+ success: true,
1082
+ table_block_id: tableBlockId,
1083
+ cells_written: written,
1084
+ table_size: { rows, cols },
1085
+ };
1086
+ }
1087
+
1088
+ async function createTableWithValues(
1089
+ client: Lark.Client,
1090
+ docToken: string,
1091
+ rowSize: number,
1092
+ columnSize: number,
1093
+ values: string[][],
1094
+ parentBlockId?: string,
1095
+ columnWidth?: number[],
1096
+ ) {
1097
+ const created = await createTable(
1098
+ client,
1099
+ docToken,
1100
+ rowSize,
1101
+ columnSize,
1102
+ parentBlockId,
1103
+ columnWidth,
1104
+ );
1105
+
1106
+ const tableBlockId = created.table_block_id;
1107
+ if (!tableBlockId) {
1108
+ throw new Error("create_table succeeded but table_block_id is missing");
1109
+ }
1110
+
1111
+ const written = await writeTableCells(client, docToken, tableBlockId, values);
1112
+ return {
1113
+ success: true,
1114
+ table_block_id: tableBlockId,
1115
+ row_size: rowSize,
1116
+ column_size: columnSize,
1117
+ cells_written: written.cells_written,
1118
+ };
1119
+ }
1120
+
1121
+ async function updateBlock(
1122
+ client: Lark.Client,
1123
+ docToken: string,
1124
+ blockId: string,
1125
+ content: string,
1126
+ ) {
1127
+ const blockInfo = await client.docx.documentBlock.get({
1128
+ path: { document_id: docToken, block_id: blockId },
1129
+ });
1130
+ if (blockInfo.code !== 0) {
1131
+ throw new Error(blockInfo.msg);
1132
+ }
1133
+
1134
+ const res = await client.docx.documentBlock.patch({
1135
+ path: { document_id: docToken, block_id: blockId },
1136
+ data: {
1137
+ update_text_elements: {
1138
+ elements: [{ text_run: { content } }],
1139
+ },
1140
+ },
1141
+ });
1142
+ if (res.code !== 0) {
1143
+ throw new Error(res.msg);
1144
+ }
1145
+
1146
+ return { success: true, block_id: blockId };
1147
+ }
1148
+
1149
+ async function deleteBlock(client: Lark.Client, docToken: string, blockId: string) {
1150
+ const blockInfo = await client.docx.documentBlock.get({
1151
+ path: { document_id: docToken, block_id: blockId },
1152
+ });
1153
+ if (blockInfo.code !== 0) {
1154
+ throw new Error(blockInfo.msg);
1155
+ }
1156
+
1157
+ const parentId = blockInfo.data?.block?.parent_id ?? docToken;
1158
+
1159
+ const children = await client.docx.documentBlockChildren.get({
1160
+ path: { document_id: docToken, block_id: parentId },
1161
+ });
1162
+ if (children.code !== 0) {
1163
+ throw new Error(children.msg);
1164
+ }
1165
+
1166
+ const items = children.data?.items ?? [];
1167
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
1168
+ const index = items.findIndex((item: any) => item.block_id === blockId);
1169
+ if (index === -1) {
1170
+ throw new Error("Block not found");
1171
+ }
1172
+
1173
+ const res = await client.docx.documentBlockChildren.batchDelete({
1174
+ path: { document_id: docToken, block_id: parentId },
1175
+ data: { start_index: index, end_index: index + 1 },
1176
+ });
1177
+ if (res.code !== 0) {
1178
+ throw new Error(res.msg);
1179
+ }
1180
+
1181
+ return { success: true, deleted_block_id: blockId };
1182
+ }
1183
+
1184
+ async function listBlocks(client: Lark.Client, docToken: string) {
1185
+ const res = await client.docx.documentBlock.list({
1186
+ path: { document_id: docToken },
1187
+ });
1188
+ if (res.code !== 0) {
1189
+ throw new Error(res.msg);
1190
+ }
1191
+
1192
+ return {
1193
+ blocks: res.data?.items ?? [],
1194
+ };
1195
+ }
1196
+
1197
+ async function getBlock(client: Lark.Client, docToken: string, blockId: string) {
1198
+ const res = await client.docx.documentBlock.get({
1199
+ path: { document_id: docToken, block_id: blockId },
1200
+ });
1201
+ if (res.code !== 0) {
1202
+ throw new Error(res.msg);
1203
+ }
1204
+
1205
+ return {
1206
+ block: res.data?.block,
1207
+ };
1208
+ }
1209
+
1210
+ async function listAppScopes(client: Lark.Client) {
1211
+ const res = await client.application.scope.list({});
1212
+ if (res.code !== 0) {
1213
+ throw new Error(res.msg);
1214
+ }
1215
+
1216
+ const scopes = res.data?.scopes ?? [];
1217
+ const granted = scopes.filter((s) => s.grant_status === 1);
1218
+ const pending = scopes.filter((s) => s.grant_status !== 1);
1219
+
1220
+ return {
1221
+ granted: granted.map((s) => ({ name: s.scope_name, type: s.scope_type })),
1222
+ pending: pending.map((s) => ({ name: s.scope_name, type: s.scope_type })),
1223
+ summary: `${granted.length} granted, ${pending.length} pending`,
1224
+ };
1225
+ }
1226
+
1227
+ // ============ Tool Registration ============
1228
+
1229
+ export function registerFeishuDocTools(api: OpenClawPluginApi) {
1230
+ if (!api.config) {
1231
+ api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
1232
+ return;
1233
+ }
1234
+
1235
+ // Check if any account is configured
1236
+ const accounts = listEnabledFeishuAccounts(api.config);
1237
+ if (accounts.length === 0) {
1238
+ api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
1239
+ return;
1240
+ }
1241
+
1242
+ // Register if enabled on any account; account routing is resolved per execution.
1243
+ const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
1244
+
1245
+ const registered: string[] = [];
1246
+ type FeishuDocExecuteParams = FeishuDocParams & { accountId?: string };
1247
+
1248
+ const getClient = (params: { accountId?: string } | undefined, defaultAccountId?: string) =>
1249
+ createFeishuToolClient({ api, executeParams: params, defaultAccountId });
1250
+
1251
+ const getMediaMaxBytes = (
1252
+ params: { accountId?: string } | undefined,
1253
+ defaultAccountId?: string,
1254
+ ) =>
1255
+ (resolveFeishuToolAccount({ api, executeParams: params, defaultAccountId }).config
1256
+ ?.mediaMaxMb ?? 30) *
1257
+ 1024 *
1258
+ 1024;
1259
+
1260
+ // Main document tool with action-based dispatch
1261
+ if (toolsCfg.doc) {
1262
+ api.registerTool(
1263
+ (ctx) => {
1264
+ const defaultAccountId = ctx.agentAccountId;
1265
+ const trustedRequesterOpenId =
1266
+ ctx.messageChannel === "feishu" ? ctx.requesterSenderId?.trim() || undefined : undefined;
1267
+ return {
1268
+ name: "feishu_doc",
1269
+ label: "Feishu Doc",
1270
+ description:
1271
+ "Feishu document operations. Actions: read, write, append, insert, create, list_blocks, get_block, update_block, delete_block, create_table, write_table_cells, create_table_with_values, insert_table_row, insert_table_column, delete_table_rows, delete_table_columns, merge_table_cells, upload_image, upload_file, color_text",
1272
+ parameters: FeishuDocSchema,
1273
+ async execute(_toolCallId, params) {
1274
+ const p = params as FeishuDocExecuteParams;
1275
+ try {
1276
+ const client = getClient(p, defaultAccountId);
1277
+ switch (p.action) {
1278
+ case "read":
1279
+ return json(await readDoc(client, p.doc_token));
1280
+ case "write":
1281
+ return json(
1282
+ await writeDoc(
1283
+ client,
1284
+ p.doc_token,
1285
+ p.content,
1286
+ getMediaMaxBytes(p, defaultAccountId),
1287
+ api.logger,
1288
+ ),
1289
+ );
1290
+ case "append":
1291
+ return json(
1292
+ await appendDoc(
1293
+ client,
1294
+ p.doc_token,
1295
+ p.content,
1296
+ getMediaMaxBytes(p, defaultAccountId),
1297
+ api.logger,
1298
+ ),
1299
+ );
1300
+ case "insert":
1301
+ return json(
1302
+ await insertDoc(
1303
+ client,
1304
+ p.doc_token,
1305
+ p.content,
1306
+ p.after_block_id,
1307
+ getMediaMaxBytes(p, defaultAccountId),
1308
+ api.logger,
1309
+ ),
1310
+ );
1311
+ case "create":
1312
+ return json(
1313
+ await createDoc(client, p.title, p.folder_token, {
1314
+ grantToRequester: p.grant_to_requester,
1315
+ requesterOpenId: trustedRequesterOpenId,
1316
+ }),
1317
+ );
1318
+ case "list_blocks":
1319
+ return json(await listBlocks(client, p.doc_token));
1320
+ case "get_block":
1321
+ return json(await getBlock(client, p.doc_token, p.block_id));
1322
+ case "update_block":
1323
+ return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
1324
+ case "delete_block":
1325
+ return json(await deleteBlock(client, p.doc_token, p.block_id));
1326
+ case "create_table":
1327
+ return json(
1328
+ await createTable(
1329
+ client,
1330
+ p.doc_token,
1331
+ p.row_size,
1332
+ p.column_size,
1333
+ p.parent_block_id,
1334
+ p.column_width,
1335
+ ),
1336
+ );
1337
+ case "write_table_cells":
1338
+ return json(
1339
+ await writeTableCells(client, p.doc_token, p.table_block_id, p.values),
1340
+ );
1341
+ case "create_table_with_values":
1342
+ return json(
1343
+ await createTableWithValues(
1344
+ client,
1345
+ p.doc_token,
1346
+ p.row_size,
1347
+ p.column_size,
1348
+ p.values,
1349
+ p.parent_block_id,
1350
+ p.column_width,
1351
+ ),
1352
+ );
1353
+ case "upload_image":
1354
+ return json(
1355
+ await uploadImageBlock(
1356
+ client,
1357
+ p.doc_token,
1358
+ getMediaMaxBytes(p, defaultAccountId),
1359
+ p.url,
1360
+ p.file_path,
1361
+ p.parent_block_id,
1362
+ p.filename,
1363
+ p.index,
1364
+ p.image, // data URI or plain base64
1365
+ ),
1366
+ );
1367
+ case "upload_file":
1368
+ return json(
1369
+ await uploadFileBlock(
1370
+ client,
1371
+ p.doc_token,
1372
+ getMediaMaxBytes(p, defaultAccountId),
1373
+ p.url,
1374
+ p.file_path,
1375
+ p.parent_block_id,
1376
+ p.filename,
1377
+ ),
1378
+ );
1379
+ case "color_text":
1380
+ return json(await updateColorText(client, p.doc_token, p.block_id, p.content));
1381
+ case "insert_table_row":
1382
+ return json(await insertTableRow(client, p.doc_token, p.block_id, p.row_index));
1383
+ case "insert_table_column":
1384
+ return json(
1385
+ await insertTableColumn(client, p.doc_token, p.block_id, p.column_index),
1386
+ );
1387
+ case "delete_table_rows":
1388
+ return json(
1389
+ await deleteTableRows(
1390
+ client,
1391
+ p.doc_token,
1392
+ p.block_id,
1393
+ p.row_start,
1394
+ p.row_count,
1395
+ ),
1396
+ );
1397
+ case "delete_table_columns":
1398
+ return json(
1399
+ await deleteTableColumns(
1400
+ client,
1401
+ p.doc_token,
1402
+ p.block_id,
1403
+ p.column_start,
1404
+ p.column_count,
1405
+ ),
1406
+ );
1407
+ case "merge_table_cells":
1408
+ return json(
1409
+ await mergeTableCells(
1410
+ client,
1411
+ p.doc_token,
1412
+ p.block_id,
1413
+ p.row_start,
1414
+ p.row_end,
1415
+ p.column_start,
1416
+ p.column_end,
1417
+ ),
1418
+ );
1419
+ default:
1420
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
1421
+ return json({ error: `Unknown action: ${(p as any).action}` });
1422
+ }
1423
+ } catch (err) {
1424
+ return json({ error: err instanceof Error ? err.message : String(err) });
1425
+ }
1426
+ },
1427
+ };
1428
+ },
1429
+ { name: "feishu_doc" },
1430
+ );
1431
+ registered.push("feishu_doc");
1432
+ }
1433
+
1434
+ // Keep feishu_app_scopes as independent tool
1435
+ if (toolsCfg.scopes) {
1436
+ api.registerTool(
1437
+ (ctx) => ({
1438
+ name: "feishu_app_scopes",
1439
+ label: "Feishu App Scopes",
1440
+ description:
1441
+ "List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
1442
+ parameters: Type.Object({}),
1443
+ async execute() {
1444
+ try {
1445
+ const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
1446
+ return json(result);
1447
+ } catch (err) {
1448
+ return json({ error: err instanceof Error ? err.message : String(err) });
1449
+ }
1450
+ },
1451
+ }),
1452
+ { name: "feishu_app_scopes" },
1453
+ );
1454
+ registered.push("feishu_app_scopes");
1455
+ }
1456
+
1457
+ if (registered.length > 0) {
1458
+ api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
1459
+ }
1460
+ }