@m1heng-clawd/feishu 0.1.9 → 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/README.md +42 -28
- package/index.ts +3 -1
- package/package.json +4 -4
- package/src/bitable-tools/actions.ts +199 -0
- package/src/bitable-tools/common.ts +90 -0
- package/src/bitable-tools/meta.ts +80 -0
- package/src/bitable-tools/register.ts +195 -0
- package/src/bitable-tools/schemas.ts +221 -0
- package/src/bitable.ts +1 -441
- package/src/bot.ts +129 -67
- package/src/channel.ts +6 -8
- package/src/config-schema.ts +7 -0
- package/src/dedup.ts +31 -0
- package/src/docx.ts +440 -62
- package/src/drive-schema.ts +20 -0
- package/src/drive.ts +84 -27
- package/src/dynamic-agent.ts +8 -4
- package/src/media.ts +25 -40
- package/src/monitor.ts +29 -2
- package/src/onboarding.ts +186 -105
- package/src/perm.ts +23 -24
- package/src/probe.ts +108 -4
- package/src/reply-dispatcher.ts +137 -73
- package/src/streaming-card.ts +211 -0
- package/src/targets.ts +1 -1
- package/src/task-tools/actions.ts +137 -0
- package/src/task-tools/common.ts +18 -0
- package/src/task-tools/register.ts +101 -0
- package/src/task-tools/schemas.ts +138 -0
- package/src/task.ts +1 -0
- package/src/tools-common/feishu-api.ts +184 -0
- package/src/tools-common/tool-context.ts +23 -0
- package/src/tools-common/tool-exec.ts +73 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +1 -0
- package/src/wiki.ts +42 -43
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 {
|
|
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([
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
...(
|
|
276
|
-
warning:
|
|
569
|
+
...(warningParts.length > 0 && {
|
|
570
|
+
warning: warningParts.join(" "),
|
|
277
571
|
}),
|
|
278
572
|
};
|
|
279
573
|
}
|
|
280
574
|
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
288
|
-
const
|
|
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
|
-
...(
|
|
296
|
-
warning:
|
|
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
|
-
|
|
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
|
-
//
|
|
404
|
-
const
|
|
405
|
-
const
|
|
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 (
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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 (
|
|
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
|
|
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.
|
|
856
|
+
api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
|
|
479
857
|
}
|
|
480
858
|
}
|
package/src/drive-schema.ts
CHANGED
|
@@ -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>;
|