@openclaw/feishu 2026.2.25 → 2026.3.1
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/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +90 -0
- package/src/accounts.ts +11 -2
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +55 -0
- package/src/bot.test.ts +863 -9
- package/src/bot.ts +414 -200
- package/src/card-action.ts +79 -0
- package/src/channel.ts +6 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +107 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +82 -1
- package/src/config-schema.ts +54 -3
- package/src/doc-schema.ts +141 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +76 -0
- package/src/docx.test.ts +470 -0
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +123 -6
- package/src/media.ts +31 -10
- package/src/monitor.account.ts +286 -0
- package/src/monitor.reaction.test.ts +235 -0
- package/src/monitor.startup.test.ts +187 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.ts +76 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +27 -1
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +253 -0
- package/src/probe.ts +99 -7
- package/src/reply-dispatcher.test.ts +259 -0
- package/src/reply-dispatcher.ts +139 -45
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +26 -1
- package/src/targets.ts +11 -6
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +1 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/docx.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
168
|
-
|
|
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(
|
|
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:
|
|
797
|
+
document_id: docToken,
|
|
281
798
|
title: doc?.title,
|
|
282
|
-
url: `https://feishu.cn/docx/${
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
//
|
|
458
|
-
const
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
}
|
|
501
|
-
|
|
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");
|