@m1heng-clawd/feishu 0.1.10 → 0.1.11
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 +157 -3
- package/package.json +1 -1
- package/skills/feishu-doc/SKILL.md +24 -0
- package/src/bot.ts +3 -3
- package/src/doc-schema.ts +8 -0
- package/src/doc-write-service.ts +711 -0
- package/src/docx.ts +76 -658
- package/src/drive.ts +2 -29
- package/src/onboarding.ts +14 -4
package/src/docx.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
3
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
4
|
-
import { Readable } from "stream";
|
|
5
4
|
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
|
|
6
|
-
import {
|
|
5
|
+
import { appendDoc, createAndWriteDoc, createDoc, writeDoc } from "./doc-write-service.js";
|
|
7
6
|
import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "./tools-common/tool-exec.js";
|
|
8
7
|
|
|
9
8
|
// ============ Helpers ============
|
|
@@ -15,20 +14,6 @@ function json(data: unknown) {
|
|
|
15
14
|
};
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
/** Extract image URLs from markdown content */
|
|
19
|
-
function extractImageUrls(markdown: string): string[] {
|
|
20
|
-
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
21
|
-
const urls: string[] = [];
|
|
22
|
-
let match;
|
|
23
|
-
while ((match = regex.exec(markdown)) !== null) {
|
|
24
|
-
const url = match[1].trim();
|
|
25
|
-
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
26
|
-
urls.push(url);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return urls;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
17
|
const BLOCK_TYPE_NAMES: Record<number, string> = {
|
|
33
18
|
1: "Page",
|
|
34
19
|
2: "Text",
|
|
@@ -50,421 +35,10 @@ const BLOCK_TYPE_NAMES: Record<number, string> = {
|
|
|
50
35
|
32: "TableCell",
|
|
51
36
|
};
|
|
52
37
|
|
|
53
|
-
|
|
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
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Clean blocks for insertion (remove unsupported types and read-only fields) */
|
|
84
|
-
function cleanBlocksForInsert(blocks: any[]): { cleaned: any[]; skipped: string[] } {
|
|
85
|
-
const skipped: string[] = [];
|
|
86
|
-
const cleaned = blocks
|
|
87
|
-
.filter((block) => {
|
|
88
|
-
if (UNSUPPORTED_CREATE_TYPES.has(block.block_type)) {
|
|
89
|
-
const typeName = BLOCK_TYPE_NAMES[block.block_type] || `type_${block.block_type}`;
|
|
90
|
-
skipped.push(typeName);
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
return true;
|
|
94
|
-
})
|
|
95
|
-
.map((block) => {
|
|
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 };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return cleanedBlock;
|
|
109
|
-
});
|
|
110
|
-
return { cleaned, skipped };
|
|
111
|
-
}
|
|
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
|
-
|
|
341
|
-
// ============ Core Functions ============
|
|
342
|
-
|
|
343
|
-
async function convertMarkdown(client: Lark.Client, markdown: string) {
|
|
344
|
-
const res = await client.docx.document.convert({
|
|
345
|
-
data: { content_type: "markdown", content: markdown },
|
|
346
|
-
});
|
|
347
|
-
if (res.code !== 0) throw new Error(res.msg);
|
|
348
|
-
return {
|
|
349
|
-
blocks: res.data?.blocks ?? [],
|
|
350
|
-
firstLevelBlockIds: res.data?.first_level_block_ids ?? [],
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
async function insertBlocks(
|
|
355
|
-
client: Lark.Client,
|
|
356
|
-
docToken: string,
|
|
357
|
-
blocks: any[],
|
|
358
|
-
parentBlockId?: string,
|
|
359
|
-
): Promise<{ children: any[]; skipped: string[] }> {
|
|
360
|
-
const { cleaned, skipped } = cleanBlocksForInsert(blocks);
|
|
361
|
-
const blockId = parentBlockId ?? docToken;
|
|
362
|
-
|
|
363
|
-
if (cleaned.length === 0) {
|
|
364
|
-
return { children: [], skipped };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const res = await createChildrenWithRetry(client, {
|
|
368
|
-
path: { document_id: docToken, block_id: blockId },
|
|
369
|
-
data: { children: cleaned },
|
|
370
|
-
});
|
|
371
|
-
if (res.code !== 0) throw new Error(res.msg);
|
|
372
|
-
return { children: res.data?.children ?? [], skipped };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
async function clearDocumentContent(client: Lark.Client, docToken: string) {
|
|
376
|
-
const existing = await client.docx.documentBlock.list({
|
|
377
|
-
path: { document_id: docToken },
|
|
378
|
-
});
|
|
379
|
-
if (existing.code !== 0) throw new Error(existing.msg);
|
|
380
|
-
|
|
381
|
-
const childIds =
|
|
382
|
-
existing.data?.items
|
|
383
|
-
?.filter((b) => b.parent_id === docToken && b.block_type !== 1)
|
|
384
|
-
.map((b) => b.block_id) ?? [];
|
|
385
|
-
|
|
386
|
-
if (childIds.length > 0) {
|
|
387
|
-
const res = await client.docx.documentBlockChildren.batchDelete({
|
|
388
|
-
path: { document_id: docToken, block_id: docToken },
|
|
389
|
-
data: { start_index: 0, end_index: childIds.length },
|
|
390
|
-
});
|
|
391
|
-
if (res.code !== 0) throw new Error(res.msg);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
return childIds.length;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
async function uploadImageToDocx(
|
|
398
|
-
client: Lark.Client,
|
|
399
|
-
blockId: string,
|
|
400
|
-
imageBuffer: Buffer,
|
|
401
|
-
fileName: string,
|
|
402
|
-
): Promise<string> {
|
|
403
|
-
const res = await client.drive.media.uploadAll({
|
|
404
|
-
data: {
|
|
405
|
-
file_name: fileName,
|
|
406
|
-
parent_type: "docx_image",
|
|
407
|
-
parent_node: blockId,
|
|
408
|
-
size: imageBuffer.length,
|
|
409
|
-
file: Readable.from(imageBuffer) as any,
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
const fileToken = res?.file_token;
|
|
414
|
-
if (!fileToken) {
|
|
415
|
-
throw new Error("Image upload failed: no file_token returned");
|
|
416
|
-
}
|
|
417
|
-
return fileToken;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async function downloadImage(url: string, maxBytes: number): Promise<Buffer> {
|
|
421
|
-
const fetched = await getFeishuRuntime().channel.media.fetchRemoteMedia({ url, maxBytes });
|
|
422
|
-
return fetched.buffer;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
async function processImages(
|
|
426
|
-
client: Lark.Client,
|
|
427
|
-
docToken: string,
|
|
428
|
-
markdown: string,
|
|
429
|
-
insertedBlocks: any[],
|
|
430
|
-
maxBytes: number,
|
|
431
|
-
): Promise<number> {
|
|
432
|
-
const imageUrls = extractImageUrls(markdown);
|
|
433
|
-
if (imageUrls.length === 0) return 0;
|
|
434
|
-
|
|
435
|
-
const imageBlocks = insertedBlocks.filter((b) => b.block_type === 27);
|
|
436
|
-
|
|
437
|
-
let processed = 0;
|
|
438
|
-
for (let i = 0; i < Math.min(imageUrls.length, imageBlocks.length); i++) {
|
|
439
|
-
const url = imageUrls[i];
|
|
440
|
-
const blockId = imageBlocks[i].block_id;
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
const buffer = await downloadImage(url, maxBytes);
|
|
444
|
-
const urlPath = new URL(url).pathname;
|
|
445
|
-
const fileName = urlPath.split("/").pop() || `image_${i}.png`;
|
|
446
|
-
const fileToken = await uploadImageToDocx(client, blockId, buffer, fileName);
|
|
447
|
-
|
|
448
|
-
await client.docx.documentBlock.patch({
|
|
449
|
-
path: { document_id: docToken, block_id: blockId },
|
|
450
|
-
data: {
|
|
451
|
-
replace_image: { token: fileToken },
|
|
452
|
-
},
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
processed++;
|
|
456
|
-
} catch (err) {
|
|
457
|
-
console.error(`Failed to process image ${url}:`, err);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
return processed;
|
|
462
|
-
}
|
|
38
|
+
const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
|
|
463
39
|
|
|
464
40
|
// ============ Actions ============
|
|
465
41
|
|
|
466
|
-
const STRUCTURED_BLOCK_TYPES = new Set([14, 18, 21, 23, 27, 30, 31, 32]);
|
|
467
|
-
|
|
468
42
|
async function readDoc(client: Lark.Client, docToken: string) {
|
|
469
43
|
const [contentRes, infoRes, blocksRes] = await Promise.all([
|
|
470
44
|
client.docx.document.rawContent({ path: { document_id: docToken } }),
|
|
@@ -503,172 +77,6 @@ async function readDoc(client: Lark.Client, docToken: string) {
|
|
|
503
77
|
};
|
|
504
78
|
}
|
|
505
79
|
|
|
506
|
-
async function createDoc(client: Lark.Client, title: string, folderToken?: string) {
|
|
507
|
-
const res = await client.docx.document.create({
|
|
508
|
-
data: { title, folder_token: folderToken },
|
|
509
|
-
});
|
|
510
|
-
if (res.code !== 0) throw new Error(res.msg);
|
|
511
|
-
const doc = res.data?.document;
|
|
512
|
-
return {
|
|
513
|
-
document_id: doc?.document_id,
|
|
514
|
-
title: doc?.title,
|
|
515
|
-
url: `https://feishu.cn/docx/${doc?.document_id}`,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
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
|
-
) {
|
|
529
|
-
const deleted = await clearDocumentContent(client, docToken);
|
|
530
|
-
|
|
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);
|
|
537
|
-
if (blocks.length === 0) {
|
|
538
|
-
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
|
|
539
|
-
}
|
|
540
|
-
|
|
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
|
-
}
|
|
563
|
-
|
|
564
|
-
return {
|
|
565
|
-
success: true,
|
|
566
|
-
blocks_deleted: deleted,
|
|
567
|
-
blocks_added: inserted.length,
|
|
568
|
-
images_processed: imagesProcessed,
|
|
569
|
-
...(warningParts.length > 0 && {
|
|
570
|
-
warning: warningParts.join(" "),
|
|
571
|
-
}),
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
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);
|
|
637
|
-
if (blocks.length === 0) {
|
|
638
|
-
throw new Error("Content is empty");
|
|
639
|
-
}
|
|
640
|
-
|
|
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
|
-
}
|
|
660
|
-
|
|
661
|
-
return {
|
|
662
|
-
success: true,
|
|
663
|
-
blocks_added: inserted.length,
|
|
664
|
-
images_processed: imagesProcessed,
|
|
665
|
-
block_ids: inserted.map((b: any) => b.block_id),
|
|
666
|
-
...(warningParts.length > 0 && {
|
|
667
|
-
warning: warningParts.join(" "),
|
|
668
|
-
}),
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
80
|
async function updateBlock(
|
|
673
81
|
client: Lark.Client,
|
|
674
82
|
docToken: string,
|
|
@@ -777,78 +185,88 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
|
|
777
185
|
// Main document tool with action-based dispatch
|
|
778
186
|
if (docEnabled) {
|
|
779
187
|
api.registerTool(
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
188
|
+
{
|
|
189
|
+
name: "feishu_doc",
|
|
190
|
+
label: "Feishu Doc",
|
|
191
|
+
description:
|
|
192
|
+
'Feishu document operations. Actions: read, write, append, create, create_and_write, list_blocks, get_block, update_block, delete_block. Use "create_and_write" for atomic create + content write.',
|
|
193
|
+
parameters: FeishuDocSchema,
|
|
194
|
+
async execute(_toolCallId, params) {
|
|
195
|
+
const p = params as FeishuDocParams;
|
|
196
|
+
try {
|
|
197
|
+
return await withFeishuToolClient({
|
|
198
|
+
api,
|
|
199
|
+
toolName: "feishu_doc",
|
|
200
|
+
requiredTool: "doc",
|
|
201
|
+
run: async ({ client, account }) => {
|
|
202
|
+
const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
|
|
203
|
+
switch (p.action) {
|
|
204
|
+
case "read":
|
|
205
|
+
return json(await readDoc(client, p.doc_token));
|
|
206
|
+
case "write":
|
|
207
|
+
return json(await writeDoc(client, p.doc_token, p.content, mediaMaxBytes));
|
|
208
|
+
case "append":
|
|
209
|
+
return json(await appendDoc(client, p.doc_token, p.content, mediaMaxBytes));
|
|
210
|
+
case "create":
|
|
211
|
+
return json(await createDoc(client, p.title, p.folder_token));
|
|
212
|
+
case "create_and_write":
|
|
213
|
+
return json(
|
|
214
|
+
await createAndWriteDoc(
|
|
215
|
+
client,
|
|
216
|
+
p.title,
|
|
217
|
+
p.content,
|
|
218
|
+
mediaMaxBytes,
|
|
219
|
+
p.folder_token,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
case "list_blocks":
|
|
223
|
+
return json(await listBlocks(client, p.doc_token));
|
|
224
|
+
case "get_block":
|
|
225
|
+
return json(await getBlock(client, p.doc_token, p.block_id));
|
|
226
|
+
case "update_block":
|
|
227
|
+
return json(await updateBlock(client, p.doc_token, p.block_id, p.content));
|
|
228
|
+
case "delete_block":
|
|
229
|
+
return json(await deleteBlock(client, p.doc_token, p.block_id));
|
|
230
|
+
default:
|
|
231
|
+
return json({ error: `Unknown action: ${(p as any).action}` });
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
237
|
+
}
|
|
238
|
+
},
|
|
820
239
|
},
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
);
|
|
240
|
+
{ name: "feishu_doc" },
|
|
241
|
+
);
|
|
824
242
|
registered.push("feishu_doc");
|
|
825
243
|
}
|
|
826
244
|
|
|
827
245
|
// Keep feishu_app_scopes as independent tool
|
|
828
246
|
if (scopesEnabled) {
|
|
829
247
|
api.registerTool(
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
248
|
+
{
|
|
249
|
+
name: "feishu_app_scopes",
|
|
250
|
+
label: "Feishu App Scopes",
|
|
251
|
+
description:
|
|
252
|
+
"List current app permissions (scopes). Use to debug permission issues or check available capabilities.",
|
|
253
|
+
parameters: Type.Object({}),
|
|
254
|
+
async execute() {
|
|
255
|
+
try {
|
|
256
|
+
const result = await withFeishuToolClient({
|
|
257
|
+
api,
|
|
258
|
+
toolName: "feishu_app_scopes",
|
|
259
|
+
requiredTool: "scopes",
|
|
260
|
+
run: async ({ client }) => listAppScopes(client),
|
|
261
|
+
});
|
|
262
|
+
return json(result);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
265
|
+
}
|
|
266
|
+
},
|
|
848
267
|
},
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
);
|
|
268
|
+
{ name: "feishu_app_scopes" },
|
|
269
|
+
);
|
|
852
270
|
registered.push("feishu_app_scopes");
|
|
853
271
|
}
|
|
854
272
|
|