@m1heng-clawd/feishu 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/docx.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
3
- import { createFeishuClient } from "./client.js";
4
- import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
5
3
  import type * as Lark from "@larksuiteoapi/node-sdk";
6
4
  import { Readable } from "stream";
7
5
  import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
8
- import { resolveToolsConfig } from "./tools-config.js";
6
+ import { getFeishuRuntime } from "./runtime.js";
7
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "./tools-common/tool-exec.js";
9
8
 
10
9
  // ============ Helpers ============
11
10
 
@@ -52,7 +51,34 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
52
51
  };
53
52
 
54
53
  // Block types that cannot be created via documentBlockChildren.create API
55
- const UNSUPPORTED_CREATE_TYPES = new Set([31, 32]);
54
+ const UNSUPPORTED_CREATE_TYPES = new Set([32]);
55
+
56
+ /**
57
+ * Reorder blocks according to firstLevelBlockIds from convertMarkdown API.
58
+ * The API returns blocks as a flat unordered array across all levels.
59
+ * firstLevelBlockIds provides the correct top-level document order.
60
+ */
61
+ function reorderBlocks(blocks: any[], firstLevelBlockIds: string[]): any[] {
62
+ if (!firstLevelBlockIds || firstLevelBlockIds.length === 0) return blocks;
63
+
64
+ const blockMap = new Map<string, any>();
65
+ for (const block of blocks) {
66
+ if (block.block_id) {
67
+ blockMap.set(block.block_id, block);
68
+ }
69
+ }
70
+
71
+ const ordered: any[] = [];
72
+ for (const id of firstLevelBlockIds) {
73
+ const block = blockMap.get(id);
74
+ if (block) {
75
+ ordered.push(block);
76
+ }
77
+ }
78
+
79
+ // If mapping unexpectedly fails, fall back to original to avoid hard data loss.
80
+ return ordered.length > 0 ? ordered : blocks;
81
+ }
56
82
 
57
83
  /** Clean blocks for insertion (remove unsupported types and read-only fields) */
58
84
  function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
@@ -67,15 +93,251 @@ function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[
67
93
  return true;
68
94
  })
69
95
  .map((block) => {
70
- if (block.block_type === 31 && block.table?.merge_info) {
71
- const { merge_info, ...tableRest } = block.table;
72
- return { ...block, table: tableRest };
96
+ const cleanedBlock = { ...block };
97
+ delete cleanedBlock.block_id;
98
+ delete cleanedBlock.parent_id;
99
+ delete cleanedBlock.children;
100
+
101
+ // Table cell IDs and merge metadata are not accepted in create payload.
102
+ if (cleanedBlock.block_type === 31 && cleanedBlock.table) {
103
+ const property = cleanedBlock.table.property ?? {};
104
+ const { merge_info, ...propertyRest } = property;
105
+ cleanedBlock.table = { property: propertyRest };
73
106
  }
74
- return block;
107
+
108
+ return cleanedBlock;
75
109
  });
76
110
  return { cleaned, skipped };
77
111
  }
78
112
 
113
+ function buildBlockMap(blocks: any[]): Map<string, any> {
114
+ const map = new Map<string, any>();
115
+ for (const block of blocks) {
116
+ if (block.block_id) map.set(block.block_id, block);
117
+ }
118
+ return map;
119
+ }
120
+
121
+ type InsertResult = { children: any[]; skipped: string[]; warnings: string[] };
122
+
123
+ function sleep(ms: number) {
124
+ return new Promise((resolve) => setTimeout(resolve, ms));
125
+ }
126
+
127
+ // Known transient/throughput-related Feishu codes observed across endpoints.
128
+ // Code matching is primary; message matching is fallback for undocumented new codes.
129
+ const RETRYABLE_CREATE_ERROR_CODES = new Set<number>([
130
+ 429, // HTTP-like throttle surfaces in some SDK wrappers
131
+ 1254290, // Too many requests
132
+ 1254291, // Write conflict
133
+ 1255040, // Request timeout
134
+ ]);
135
+
136
+ const RETRYABLE_MESSAGE_PATTERNS = [
137
+ /\brate\b/i,
138
+ /\bfrequency\b/i,
139
+ /\btoo many\b/i,
140
+ /\blimit\b/i,
141
+ /\bqps\b/i,
142
+ /频率/u,
143
+ /限流/u,
144
+ ];
145
+
146
+ function isRetryableCreateError(code?: number, msg?: string) {
147
+ if (!code || code === 0) return false;
148
+ if (RETRYABLE_CREATE_ERROR_CODES.has(code)) return true;
149
+ const text = msg ?? "";
150
+ return RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(text));
151
+ }
152
+
153
+ const CREATE_CHILDREN_RETRY_POLICY = {
154
+ maxAttempts: 4,
155
+ baseDelayMs: 250,
156
+ maxDelayMs: 2500,
157
+ jitterRatio: 0.2,
158
+ } as const;
159
+
160
+ function computeBackoffDelayMs(attempt: number, policy = CREATE_CHILDREN_RETRY_POLICY) {
161
+ const exp = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
162
+ const jitter = exp * policy.jitterRatio;
163
+ const min = Math.max(0, exp - jitter);
164
+ const max = exp + jitter;
165
+ return Math.round(min + Math.random() * (max - min));
166
+ }
167
+
168
+ type CreateChildrenPayload = Parameters<Lark.Client["docx"]["documentBlockChildren"]["create"]>[0];
169
+ type CreateChildrenResponse = Awaited<
170
+ ReturnType<Lark.Client["docx"]["documentBlockChildren"]["create"]>
171
+ >;
172
+
173
+ async function executeWithBackoff<T>(args: {
174
+ operationName: string;
175
+ operation: () => Promise<T>;
176
+ isSuccess: (result: T) => boolean;
177
+ shouldRetry: (result: T) => boolean;
178
+ getMessage: (result: T) => string | undefined;
179
+ policy?: typeof CREATE_CHILDREN_RETRY_POLICY;
180
+ }): Promise<T> {
181
+ const policy = args.policy ?? CREATE_CHILDREN_RETRY_POLICY;
182
+ let lastResult: T | undefined;
183
+
184
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) {
185
+ const result = await args.operation();
186
+ lastResult = result;
187
+
188
+ if (args.isSuccess(result)) return result;
189
+ if (!args.shouldRetry(result) || attempt === policy.maxAttempts) return result;
190
+
191
+ const delayMs = computeBackoffDelayMs(attempt, policy);
192
+ const msg = args.getMessage(result) ?? "unknown error";
193
+ console.warn(
194
+ `[feishu_doc] ${args.operationName} retry ${attempt}/${policy.maxAttempts - 1} after ${delayMs}ms: ${msg}`,
195
+ );
196
+ await sleep(delayMs);
197
+ }
198
+
199
+ return lastResult!;
200
+ }
201
+
202
+ async function createChildrenWithRetry(
203
+ client: Lark.Client,
204
+ payload: CreateChildrenPayload,
205
+ policy = CREATE_CHILDREN_RETRY_POLICY,
206
+ ) {
207
+ return executeWithBackoff<CreateChildrenResponse>({
208
+ operationName: "docx.documentBlockChildren.create",
209
+ operation: () => client.docx.documentBlockChildren.create(payload),
210
+ isSuccess: (res) => res.code === 0,
211
+ shouldRetry: (res) => isRetryableCreateError(res.code, res.msg),
212
+ getMessage: (res) => res.msg,
213
+ policy,
214
+ });
215
+ }
216
+
217
+ async function insertTableWithCells(
218
+ client: Lark.Client,
219
+ docToken: string,
220
+ tableBlock: any,
221
+ blockMap: Map<string, any>,
222
+ parentBlockId?: string,
223
+ ): Promise<InsertResult> {
224
+ const tableInsert = await insertBlocks(client, docToken, [tableBlock], parentBlockId);
225
+ const insertedTable = tableInsert.children[0];
226
+
227
+ if (!insertedTable || insertedTable.block_type !== 31) {
228
+ return {
229
+ children: tableInsert.children,
230
+ skipped: tableInsert.skipped,
231
+ warnings: ["Table block was not returned after create; skipped table cell content."],
232
+ };
233
+ }
234
+
235
+ const srcCells: string[] = tableBlock.table?.cells ?? [];
236
+ const dstCells: string[] = insertedTable.table?.cells ?? [];
237
+ if (srcCells.length === 0) {
238
+ return { children: tableInsert.children, skipped: tableInsert.skipped, warnings: [] };
239
+ }
240
+
241
+ if (dstCells.length === 0) {
242
+ return {
243
+ children: tableInsert.children,
244
+ skipped: tableInsert.skipped,
245
+ warnings: ["Table created but API did not return generated cells; table content may be empty."],
246
+ };
247
+ }
248
+
249
+ const copiedChildren: any[] = [];
250
+ const allSkipped = [...tableInsert.skipped];
251
+ const warnings: string[] = [];
252
+ let sourceCellsWithContent = 0;
253
+ let copiedCellCount = 0;
254
+
255
+ const cellCount = Math.min(srcCells.length, dstCells.length);
256
+ for (let i = 0; i < cellCount; i++) {
257
+ const srcCellId = srcCells[i];
258
+ const dstCellId = dstCells[i];
259
+ const srcCell = blockMap.get(srcCellId);
260
+ const srcChildIds: string[] = srcCell?.children ?? [];
261
+ let srcChildBlocks = srcChildIds
262
+ .map((id) => blockMap.get(id))
263
+ .filter((b): b is any => Boolean(b));
264
+
265
+ // Some convert payloads may carry plain text directly on table_cell.
266
+ if (srcChildBlocks.length === 0 && srcCell?.text?.elements?.length) {
267
+ srcChildBlocks = [{ block_type: 2, text: srcCell.text }];
268
+ }
269
+ if (srcChildBlocks.length === 0 && srcCell?.table_cell?.text?.elements?.length) {
270
+ srcChildBlocks = [{ block_type: 2, text: srcCell.table_cell.text }];
271
+ }
272
+
273
+ if (srcChildBlocks.length === 0) continue;
274
+ sourceCellsWithContent++;
275
+
276
+ const cellInsert = await insertBlocksInBatches(client, docToken, srcChildBlocks, dstCellId);
277
+ copiedChildren.push(...cellInsert.children);
278
+ allSkipped.push(...cellInsert.skipped);
279
+ if (cellInsert.children.length > 0) copiedCellCount++;
280
+ }
281
+
282
+ if (srcCells.length !== dstCells.length) {
283
+ warnings.push(
284
+ `Table cell count mismatch after create (source=${srcCells.length}, target=${dstCells.length}); content may be partially copied.`,
285
+ );
286
+ }
287
+ if (sourceCellsWithContent > 0 && copiedCellCount < sourceCellsWithContent) {
288
+ warnings.push(
289
+ `Copied table cell content for ${copiedCellCount}/${sourceCellsWithContent} non-empty cells.`,
290
+ );
291
+ }
292
+
293
+ return {
294
+ children: [...tableInsert.children, ...copiedChildren],
295
+ skipped: [...new Set(allSkipped)],
296
+ warnings,
297
+ };
298
+ }
299
+
300
+ async function insertBlocksPreservingTables(
301
+ client: Lark.Client,
302
+ docToken: string,
303
+ blocks: any[],
304
+ blockMap: Map<string, any>,
305
+ parentBlockId?: string,
306
+ ): Promise<InsertResult> {
307
+ const inserted: any[] = [];
308
+ const skipped: string[] = [];
309
+ const warnings: string[] = [];
310
+ const buffer: any[] = [];
311
+
312
+ const flushBuffer = async () => {
313
+ if (buffer.length === 0) return;
314
+ const res = await insertBlocksInBatches(client, docToken, buffer, parentBlockId);
315
+ inserted.push(...res.children);
316
+ skipped.push(...res.skipped);
317
+ buffer.length = 0;
318
+ };
319
+
320
+ for (const block of blocks) {
321
+ if (block.block_type === 31) {
322
+ await flushBuffer();
323
+ const tableRes = await insertTableWithCells(client, docToken, block, blockMap, parentBlockId);
324
+ inserted.push(...tableRes.children);
325
+ skipped.push(...tableRes.skipped);
326
+ warnings.push(...tableRes.warnings);
327
+ continue;
328
+ }
329
+ buffer.push(block);
330
+ }
331
+
332
+ await flushBuffer();
333
+
334
+ return {
335
+ children: inserted,
336
+ skipped: [...new Set(skipped)],
337
+ warnings: [...new Set(warnings)],
338
+ };
339
+ }
340
+
79
341
  // ============ Core Functions ============
80
342
 
81
343
  async function convertMarkdown(client: Lark.Client, markdown: string) {
@@ -102,7 +364,7 @@ async function insertBlocks(
102
364
  return { children: [], skipped };
103
365
  }
104
366
 
105
- const res = await client.docx.documentBlockChildren.create({
367
+ const res = await createChildrenWithRetry(client, {
106
368
  path: { document_id: docToken, block_id: blockId },
107
369
  data: { children: cleaned },
108
370
  });
@@ -155,12 +417,9 @@ async function uploadImageToDocx(
155
417
  return fileToken;
156
418
  }
157
419
 
158
- async function downloadImage(url: string): Promise<Buffer> {
159
- const response = await fetch(url);
160
- if (!response.ok) {
161
- throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
162
- }
163
- return Buffer.from(await response.arrayBuffer());
420
+ async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
421
+ const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
422
+ return fetched.buffer;
164
423
  }
165
424
 
166
425
  async function processImages(
@@ -168,6 +427,7 @@ async function processImages(
168
427
  docToken: string,
169
428
  markdown: string,
170
429
  insertedBlocks: any[],
430
+ maxBytes: number,
171
431
  ): Promise<number> {
172
432
  const imageUrls = extractImageUrls(markdown);
173
433
  if (imageUrls.length === 0) return 0;
@@ -180,7 +440,7 @@ async function processImages(
180
440
  const blockId = imageBlocks[i].block_id;
181
441
 
182
442
  try {
183
- const buffer = await downloadImage(url);
443
+ const buffer = await downloadImage(url, maxBytes);
184
444
  const urlPath = new URL(url).pathname;
185
445
  const fileName = urlPath.split("/").pop() || `image_${i}.png`;
186
446
  const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
@@ -256,44 +516,155 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
256
516
  };
257
517
  }
258
518
 
259
- async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
519
+ // Maximum content length for a single API call (empirical value based on Feishu API limits)
520
+ const MAX_CONTENT_LENGTH = 50000; // ~50KB
521
+ const MAX_BLOCKS_PER_INSERT = 50; // Maximum blocks per insert API call
522
+
523
+ export async function writeDoc(
524
+ client: Lark.Client,
525
+ docToken: string,
526
+ markdown: string,
527
+ maxBytes: number,
528
+ ) {
260
529
  const deleted = await clearDocumentContent(client, docToken);
261
530
 
262
- const { blocks } = await convertMarkdown(client, markdown);
531
+ // Check content length and warn if too long
532
+ if (markdown.length > MAX_CONTENT_LENGTH) {
533
+ console.warn(`[feishu_doc] Content length (${markdown.length}) exceeds recommended limit (${MAX_CONTENT_LENGTH}). May cause API errors.`);
534
+ }
535
+
536
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
263
537
  if (blocks.length === 0) {
264
538
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
265
539
  }
266
540
 
267
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
268
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
541
+ // Reorder blocks according to firstLevelBlockIds to maintain correct document order.
542
+ // The convertMarkdown API returns blocks in an unordered map; firstLevelBlockIds
543
+ // provides the correct top-level ordering.
544
+ const orderedBlocks = reorderBlocks(blocks, firstLevelBlockIds);
545
+ const blockMap = buildBlockMap(blocks);
546
+
547
+ // Insert blocks while preserving table content when possible.
548
+ const { children: inserted, skipped, warnings } = await insertBlocksPreservingTables(
549
+ client,
550
+ docToken,
551
+ orderedBlocks,
552
+ blockMap,
553
+ );
554
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
555
+
556
+ const warningParts: string[] = [];
557
+ if (skipped.length > 0) {
558
+ warningParts.push(`Skipped unsupported block types: ${skipped.join(", ")}.`);
559
+ }
560
+ if (warnings.length > 0) {
561
+ warningParts.push(...warnings);
562
+ }
269
563
 
270
564
  return {
271
565
  success: true,
272
566
  blocks_deleted: deleted,
273
567
  blocks_added: inserted.length,
274
568
  images_processed: imagesProcessed,
275
- ...(skipped.length > 0 && {
276
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
569
+ ...(warningParts.length > 0 && {
570
+ warning: warningParts.join(" "),
277
571
  }),
278
572
  };
279
573
  }
280
574
 
281
- async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
282
- const { blocks } = await convertMarkdown(client, markdown);
575
+ /**
576
+ * Insert blocks in batches to avoid API limits
577
+ */
578
+ async function insertBlocksInBatches(
579
+ client: Lark.Client,
580
+ docToken: string,
581
+ blocks: any[],
582
+ parentBlockId?: string,
583
+ ): Promise<{ children: any[]; skipped: string[] }> {
584
+ const allInserted: any[] = [];
585
+ const allSkipped: string[] = [];
586
+ const blockId = parentBlockId ?? docToken;
587
+
588
+ // Process blocks in batches
589
+ for (let i = 0; i < blocks.length; i += MAX_BLOCKS_PER_INSERT) {
590
+ const batch = blocks.slice(i, i + MAX_BLOCKS_PER_INSERT);
591
+ const { cleaned, skipped } = cleanBlocksForInsert(batch);
592
+
593
+ allSkipped.push(...skipped);
594
+
595
+ if (cleaned.length === 0) {
596
+ continue;
597
+ }
598
+
599
+ try {
600
+ const res = await createChildrenWithRetry(client, {
601
+ path: { document_id: docToken, block_id: blockId },
602
+ data: { children: cleaned },
603
+ });
604
+
605
+ if (res.code !== 0) {
606
+ // If batch insert fails, try inserting one by one
607
+ console.warn(`[feishu_doc] Batch insert failed: ${res.msg}. Trying individual inserts...`);
608
+ for (const block of cleaned) {
609
+ try {
610
+ const singleRes = await createChildrenWithRetry(client, {
611
+ path: { document_id: docToken, block_id: blockId },
612
+ data: { children: [block] },
613
+ });
614
+ if (singleRes.code === 0) {
615
+ allInserted.push(...(singleRes.data?.children ?? []));
616
+ } else {
617
+ console.error(`[feishu_doc] Failed to insert block: ${singleRes.msg}`);
618
+ }
619
+ } catch (err) {
620
+ console.error(`[feishu_doc] Error inserting block:`, err);
621
+ }
622
+ }
623
+ } else {
624
+ allInserted.push(...(res.data?.children ?? []));
625
+ }
626
+ } catch (err) {
627
+ console.error(`[feishu_doc] Error in batch insert:`, err);
628
+ throw err;
629
+ }
630
+ }
631
+
632
+ return { children: allInserted, skipped: [...new Set(allSkipped)] };
633
+ }
634
+
635
+ async function appendDoc(client: Lark.Client, docToken: string, markdown: string, maxBytes: number) {
636
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
283
637
  if (blocks.length === 0) {
284
638
  throw new Error("Content is empty");
285
639
  }
286
640
 
287
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
288
- const imagesProcessed = await processImages(client, docToken, markdown, inserted);
641
+ // Reorder blocks according to firstLevelBlockIds (same fix as writeDoc)
642
+ const orderedBlocks = reorderBlocks(blocks, firstLevelBlockIds);
643
+
644
+ const blockMap = buildBlockMap(blocks);
645
+ const { children: inserted, skipped, warnings } = await insertBlocksPreservingTables(
646
+ client,
647
+ docToken,
648
+ orderedBlocks,
649
+ blockMap,
650
+ );
651
+ const imagesProcessed = await processImages(client, docToken, markdown, inserted, maxBytes);
652
+
653
+ const warningParts: string[] = [];
654
+ if (skipped.length > 0) {
655
+ warningParts.push(`Skipped unsupported block types: ${skipped.join(", ")}.`);
656
+ }
657
+ if (warnings.length > 0) {
658
+ warningParts.push(...warnings);
659
+ }
289
660
 
290
661
  return {
291
662
  success: true,
292
663
  blocks_added: inserted.length,
293
664
  images_processed: imagesProcessed,
294
665
  block_ids: inserted.map((b: any) => b.block_id),
295
- ...(skipped.length > 0 && {
296
- warning: `Skipped unsupported block types: ${skipped.join(", ")}. Tables are not supported via this API.`,
666
+ ...(warningParts.length > 0 && {
667
+ warning: warningParts.join(" "),
297
668
  }),
298
669
  };
299
670
  }
@@ -393,23 +764,18 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
393
764
  return;
394
765
  }
395
766
 
396
- // Check if any account is configured
397
- const accounts = listEnabledFeishuAccounts(api.config);
398
- if (accounts.length === 0) {
767
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
399
768
  api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
400
769
  return;
401
770
  }
402
771
 
403
- // Use first account's config for tools configuration
404
- const firstAccount = accounts[0];
405
- const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
406
-
407
- // Helper to get client for the default account
408
- const getClient = () => createFeishuClient(firstAccount);
772
+ // Registration happens once; account selection happens per execute() call.
773
+ const docEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "doc");
774
+ const scopesEnabled = hasFeishuToolEnabledForAnyAccount(api.config, "scopes");
409
775
  const registered: string[] = [];
410
776
 
411
777
  // Main document tool with action-based dispatch
412
- if (toolsCfg.doc) {
778
+ if (docEnabled) {
413
779
  api.registerTool(
414
780
  {
415
781
  name: "feishu_doc",
@@ -420,27 +786,34 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
420
786
  async execute(_toolCallId, params) {
421
787
  const p = params as FeishuDocParams;
422
788
  try {
423
- const client = getClient();
424
- switch (p.action) {
425
- case "read":
426
- return json(await readDoc(client, p.doc_token));
427
- case "write":
428
- return json(await writeDoc(client, p.doc_token, p.content));
429
- case "append":
430
- return json(await appendDoc(client, p.doc_token, p.content));
431
- case "create":
432
- return json(await createDoc(client, p.title, p.folder_token));
433
- case "list_blocks":
434
- return json(await listBlocks(client, p.doc_token));
435
- case "get_block":
436
- return json(await getBlock(client, p.doc_token, p.block_id));
437
- case "update_block":
438
- return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
439
- case "delete_block":
440
- return json(await deleteBlock(client, p.doc_token, p.block_id));
441
- default:
442
- return json({ error: `Unknown action: ${(p as any).action}` });
443
- }
789
+ return await withFeishuToolClient({
790
+ api,
791
+ toolName: "feishu_doc",
792
+ requiredTool: "doc",
793
+ run: async ({ client, account }) => {
794
+ const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
795
+ switch (p.action) {
796
+ case "read":
797
+ return json(await readDoc(client, p.doc_token));
798
+ case "write":
799
+ return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
800
+ case "append":
801
+ return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
802
+ case "create":
803
+ return json(await createDoc(client, p.title, p.folder_token));
804
+ case "list_blocks":
805
+ return json(await listBlocks(client, p.doc_token));
806
+ case "get_block":
807
+ return json(await getBlock(client, p.doc_token, p.block_id));
808
+ case "update_block":
809
+ return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
810
+ case "delete_block":
811
+ return json(await deleteBlock(client, p.doc_token, p.block_id));
812
+ default:
813
+ return json({ error: `Unknown action: ${(p as any).action}` });
814
+ }
815
+ },
816
+ });
444
817
  } catch (err) {
445
818
  return json({ error: err instanceof Error ? err.message : String(err) });
446
819
  }
@@ -452,7 +825,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
452
825
  }
453
826
 
454
827
  // Keep feishu_app_scopes as independent tool
455
- if (toolsCfg.scopes) {
828
+ if (scopesEnabled) {
456
829
  api.registerTool(
457
830
  {
458
831
  name: "feishu_app_scopes",
@@ -462,7 +835,12 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
462
835
  parameters: Type.Object({}),
463
836
  async execute() {
464
837
  try {
465
- const result = await listAppScopes(getClient());
838
+ const result = await withFeishuToolClient({
839
+ api,
840
+ toolName: "feishu_app_scopes",
841
+ requiredTool: "scopes",
842
+ run: async ({ client }) => listAppScopes(client),
843
+ });
466
844
  return json(result);
467
845
  } catch (err) {
468
846
  return json({ error: err instanceof Error ? err.message : String(err) });
@@ -475,6 +853,6 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
475
853
  }
476
854
 
477
855
  if (registered.length > 0) {
478
- api.logger.info?.(`feishu_doc: Registered ${registered.join(", ")}`);
856
+ api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
479
857
  }
480
858
  }
@@ -11,6 +11,11 @@ const FileType = Type.Union([
11
11
  Type.Literal("shortcut"),
12
12
  ]);
13
13
 
14
+ const DocType = Type.Union([
15
+ Type.Literal("docx", { description: "New generation document (default)" }),
16
+ Type.Literal("doc", { description: "Legacy document" }),
17
+ ]);
18
+
14
19
  export const FeishuDriveSchema = Type.Union([
15
20
  Type.Object({
16
21
  action: Type.Literal("list"),
@@ -41,6 +46,21 @@ export const FeishuDriveSchema = Type.Union([
41
46
  file_token: Type.String({ description: "File token to delete" }),
42
47
  type: FileType,
43
48
  }),
49
+ Type.Object({
50
+ action: Type.Literal("import_document"),
51
+ title: Type.String({
52
+ description: "Document title",
53
+ }),
54
+ content: Type.String({
55
+ description: "Markdown content to import. Supports full Markdown syntax including tables, lists, code blocks, etc.",
56
+ }),
57
+ folder_token: Type.Optional(
58
+ Type.String({
59
+ description: "Target folder token (optional, defaults to root). Use 'list' to find folder tokens.",
60
+ }),
61
+ ),
62
+ doc_type: Type.Optional(DocType),
63
+ }),
44
64
  ]);
45
65
 
46
66
  export type FeishuDriveParams = Static<typeof FeishuDriveSchema>;