@openclaw/feishu 2026.2.25 → 2026.3.2

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 (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
package/src/docx.ts CHANGED
@@ -1,12 +1,28 @@
1
- import { Readable } from "stream";
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";
2
5
  import type * as Lark from "@larksuiteoapi/node-sdk";
3
6
  import { Type } from "@sinclair/typebox";
4
7
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
8
  import { listEnabledFeishuAccounts } from "./accounts.js";
6
- import { createFeishuClient } from "./client.js";
7
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";
8
20
  import { getFeishuRuntime } from "./runtime.js";
9
- import { resolveToolsConfig } from "./tools-config.js";
21
+ import {
22
+ createFeishuToolClient,
23
+ resolveAnyEnabledFeishuToolsConfig,
24
+ resolveFeishuToolAccount,
25
+ } from "./tool-account.js";
10
26
 
11
27
  // ============ Helpers ============
12
28
 
@@ -80,6 +96,10 @@ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[
80
96
 
81
97
  // ============ Core Functions ============
82
98
 
99
+ /** Max blocks per documentBlockChildren.create request */
100
+ const MAX_BLOCKS_PER_INSERT = 50;
101
+ const MAX_CONVERT_RETRY_DEPTH = 8;
102
+
83
103
  async function convertMarkdown(client: Lark.Client, markdown: string) {
84
104
  const res = await client.docx.document.convert({
85
105
  data: { content_type: "markdown", content: markdown },
@@ -107,6 +127,7 @@ async function insertBlocks(
107
127
  docToken: string,
108
128
  blocks: any[],
109
129
  parentBlockId?: string,
130
+ index?: number,
110
131
  ): Promise<{ children: any[]; skipped: string[] }> {
111
132
  /* eslint-enable @typescript-eslint/no-explicit-any */
112
133
  const { cleaned, skipped } = cleanBlocksForInsert(blocks);
@@ -116,14 +137,194 @@ async function insertBlocks(
116
137
  return { children: [], skipped };
117
138
  }
118
139
 
119
- const res = await client.docx.documentBlockChildren.create({
120
- path: { document_id: docToken, block_id: blockId },
121
- data: { children: cleaned },
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 },
122
321
  });
322
+
123
323
  if (res.code !== 0) {
124
- throw new Error(res.msg);
324
+ throw new Error(`${res.msg} (code: ${res.code})`);
125
325
  }
126
- return { children: res.data?.children ?? [], skipped };
326
+
327
+ return { children: res.data?.children ?? [] };
127
328
  }
128
329
 
129
330
  async function clearDocumentContent(client: Lark.Client, docToken: string) {
@@ -157,6 +358,7 @@ async function uploadImageToDocx(
157
358
  blockId: string,
158
359
  imageBuffer: Buffer,
159
360
  fileName: string,
361
+ docToken?: string,
160
362
  ): Promise<string> {
161
363
  const res = await client.drive.media.uploadAll({
162
364
  data: {
@@ -164,8 +366,15 @@ async function uploadImageToDocx(
164
366
  parent_type: "docx_image",
165
367
  parent_node: blockId,
166
368
  size: imageBuffer.length,
167
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
168
- file: Readable.from(imageBuffer) as any,
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 }) } : {}),
169
378
  },
170
379
  });
171
380
 
@@ -181,6 +390,142 @@ async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
181
390
  return fetched.buffer;
182
391
  }
183
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
+
184
529
  /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
185
530
  async function processImages(
186
531
  client: Lark.Client,
@@ -206,7 +551,7 @@ async function processImages(
206
551
  const buffer = await downloadImage(url, maxBytes);
207
552
  const urlPath = new URL(url).pathname;
208
553
  const fileName = urlPath.split("/").pop() || `image_${i}.png`;
209
- const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
554
+ const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName, docToken);
210
555
 
211
556
  await client.docx.documentBlock.patch({
212
557
  path: { document_id: docToken, block_id: blockId },
@@ -224,6 +569,140 @@ async function processImages(
224
569
  return processed;
225
570
  }
226
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
+
227
706
  // ============ Actions ============
228
707
 
229
708
  const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
@@ -268,7 +747,12 @@ async function readDoc(client: Lark.Client, docToken: string) {
268
747
  };
269
748
  }
270
749
 
271
- async function createDoc(client: Lark.Client, title: string, folderToken?: string) {
750
+ async function createDoc(
751
+ client: Lark.Client,
752
+ title: string,
753
+ folderToken?: string,
754
+ options?: { grantToRequester?: boolean; requesterOpenId?: string },
755
+ ) {
272
756
  const res = await client.docx.document.create({
273
757
  data: { title, folder_token: folderToken },
274
758
  });
@@ -276,33 +760,83 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
276
760
  throw new Error(res.msg);
277
761
  }
278
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
+
279
796
  return {
280
- document_id: doc?.document_id,
797
+ document_id: docToken,
281
798
  title: doc?.title,
282
- url: `https://feishu.cn/docx/${doc?.document_id}`,
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
+ }),
283
809
  };
284
810
  }
285
811
 
286
- async function writeDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
812
+ async function writeDoc(
813
+ client: Lark.Client,
814
+ docToken: string,
815
+ markdown: string,
816
+ maxBytes: number,
817
+ logger?: Logger,
818
+ ) {
287
819
  const deleted = await clearDocumentContent(client, docToken);
288
-
289
- const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
820
+ logger?.info?.("feishu_doc: Converting markdown...");
821
+ const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
290
822
  if (blocks.length === 0) {
291
823
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
292
824
  }
293
- const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
294
825
 
295
- const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
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);
296
832
  const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
833
+ logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
297
834
 
298
835
  return {
299
836
  success: true,
300
837
  blocks_deleted: deleted,
301
- blocks_added: inserted.length,
838
+ blocks_added: blocks.length,
302
839
  images_processed: imagesProcessed,
303
- ...(skipped.length > 0 && {
304
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
305
- }),
306
840
  };
307
841
  }
308
842
 
@@ -311,25 +845,276 @@ async function appendDoc(
311
845
  docToken: string,
312
846
  markdown: string,
313
847
  maxBytes: number,
848
+ logger?: Logger,
314
849
  ) {
315
- const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
850
+ logger?.info?.("feishu_doc: Converting markdown...");
851
+ const { blocks, firstLevelBlockIds } = await chunkedConvertMarkdown(client, markdown);
316
852
  if (blocks.length === 0) {
317
853
  throw new Error("Content is empty");
318
854
  }
855
+
856
+ logger?.info?.(`feishu_doc: Converted to ${blocks.length} blocks, inserting...`);
319
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
+ });
320
937
 
321
- const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
322
938
  const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
939
+ logger?.info?.(`feishu_doc: Done (${blocks.length} blocks, ${imagesProcessed} images)`);
323
940
 
324
941
  return {
325
942
  success: true,
326
- blocks_added: inserted.length,
943
+ blocks_added: blocks.length,
327
944
  images_processed: imagesProcessed,
328
945
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK block type
329
946
  block_ids: inserted.map((b: any) => b.block_id),
330
- ...(skipped.length > 0 && {
331
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
332
- }),
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,
333
1118
  };
334
1119
  }
335
1120
 
@@ -454,53 +1239,192 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
454
1239
  return;
455
1240
  }
456
1241
 
457
- // Use first account's config for tools configuration
458
- const firstAccount = accounts[0];
459
- const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
460
- const mediaMaxBytes = (firstAccount.config?.mediaMaxMb ?? 30) * 1024 * 1024;
1242
+ // Register if enabled on any account; account routing is resolved per execution.
1243
+ const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
461
1244
 
462
- // Helper to get client for the default account
463
- const getClient = () => createFeishuClient(firstAccount);
464
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;
465
1259
 
466
1260
  // Main document tool with action-based dispatch
467
1261
  if (toolsCfg.doc) {
468
1262
  api.registerTool(
469
- {
470
- name: "feishu_doc",
471
- label: "Feishu Doc",
472
- description:
473
- "Feishu document operations. Actions: read, write, append, create, list_blocks, get_block, update_block, delete_block",
474
- parameters: FeishuDocSchema,
475
- async execute(_toolCallId, params) {
476
- const p = params as FeishuDocParams;
477
- try {
478
- const client = getClient();
479
- switch (p.action) {
480
- case "read":
481
- return json(await readDoc(client, p.doc_token));
482
- case "write":
483
- return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
484
- case "append":
485
- return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
486
- case "create":
487
- return json(await createDoc(client, p.title, p.folder_token));
488
- case "list_blocks":
489
- return json(await listBlocks(client, p.doc_token));
490
- case "get_block":
491
- return json(await getBlock(client, p.doc_token, p.block_id));
492
- case "update_block":
493
- return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
494
- case "delete_block":
495
- return json(await deleteBlock(client, p.doc_token, p.block_id));
496
- default:
497
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
498
- return json({ error: `Unknown action: ${(p as any).action}` });
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) });
499
1425
  }
500
- } catch (err) {
501
- return json({ error: err instanceof Error ? err.message : String(err) });
502
- }
503
- },
1426
+ },
1427
+ };
504
1428
  },
505
1429
  { name: "feishu_doc" },
506
1430
  );
@@ -510,7 +1434,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
510
1434
  // Keep feishu_app_scopes as independent tool
511
1435
  if (toolsCfg.scopes) {
512
1436
  api.registerTool(
513
- {
1437
+ (ctx) => ({
514
1438
  name: "feishu_app_scopes",
515
1439
  label: "Feishu App Scopes",
516
1440
  description:
@@ -518,13 +1442,13 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
518
1442
  parameters: Type.Object({}),
519
1443
  async execute() {
520
1444
  try {
521
- const result = await listAppScopes(getClient());
1445
+ const result = await listAppScopes(getClient(undefined, ctx.agentAccountId));
522
1446
  return json(result);
523
1447
  } catch (err) {
524
1448
  return json({ error: err instanceof Error ? err.message : String(err) });
525
1449
  }
526
1450
  },
527
- },
1451
+ }),
528
1452
  { name: "feishu_app_scopes" },
529
1453
  );
530
1454
  registered.push("feishu_app_scopes");