@openclaw/feishu 2026.2.25 → 2026.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- 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 +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- 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 +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -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 +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- 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 +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- 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 +55 -1
- package/src/targets.ts +32 -7
- 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 +10 -1
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/drive.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
2
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
3
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
4
|
-
import { createFeishuClient } from "./client.js";
|
|
5
4
|
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
|
6
|
-
import {
|
|
5
|
+
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
7
6
|
|
|
8
7
|
// ============ Helpers ============
|
|
9
8
|
|
|
@@ -180,45 +179,51 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
|
|
180
179
|
return;
|
|
181
180
|
}
|
|
182
181
|
|
|
183
|
-
const
|
|
184
|
-
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
|
182
|
+
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
|
185
183
|
if (!toolsCfg.drive) {
|
|
186
184
|
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
|
187
185
|
return;
|
|
188
186
|
}
|
|
189
187
|
|
|
190
|
-
|
|
188
|
+
type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
|
|
191
189
|
|
|
192
190
|
api.registerTool(
|
|
193
|
-
{
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"Feishu
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
191
|
+
(ctx) => {
|
|
192
|
+
const defaultAccountId = ctx.agentAccountId;
|
|
193
|
+
return {
|
|
194
|
+
name: "feishu_drive",
|
|
195
|
+
label: "Feishu Drive",
|
|
196
|
+
description:
|
|
197
|
+
"Feishu cloud storage operations. Actions: list, info, create_folder, move, delete",
|
|
198
|
+
parameters: FeishuDriveSchema,
|
|
199
|
+
async execute(_toolCallId, params) {
|
|
200
|
+
const p = params as FeishuDriveExecuteParams;
|
|
201
|
+
try {
|
|
202
|
+
const client = createFeishuToolClient({
|
|
203
|
+
api,
|
|
204
|
+
executeParams: p,
|
|
205
|
+
defaultAccountId,
|
|
206
|
+
});
|
|
207
|
+
switch (p.action) {
|
|
208
|
+
case "list":
|
|
209
|
+
return json(await listFolder(client, p.folder_token));
|
|
210
|
+
case "info":
|
|
211
|
+
return json(await getFileInfo(client, p.file_token));
|
|
212
|
+
case "create_folder":
|
|
213
|
+
return json(await createFolder(client, p.name, p.folder_token));
|
|
214
|
+
case "move":
|
|
215
|
+
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
|
|
216
|
+
case "delete":
|
|
217
|
+
return json(await deleteFile(client, p.file_token, p.type));
|
|
218
|
+
default:
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
|
220
|
+
return json({ error: `Unknown action: ${(p as any).action}` });
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
217
224
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
},
|
|
225
|
+
},
|
|
226
|
+
};
|
|
222
227
|
},
|
|
223
228
|
{ name: "feishu_drive" },
|
|
224
229
|
);
|
package/src/media.test.ts
CHANGED
|
@@ -36,7 +36,12 @@ vi.mock("./runtime.js", () => ({
|
|
|
36
36
|
}),
|
|
37
37
|
}));
|
|
38
38
|
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
downloadImageFeishu,
|
|
41
|
+
downloadMessageResourceFeishu,
|
|
42
|
+
sanitizeFileNameForUpload,
|
|
43
|
+
sendMediaFeishu,
|
|
44
|
+
} from "./media.js";
|
|
40
45
|
|
|
41
46
|
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
|
42
47
|
expect(pathValue).not.toContain(key);
|
|
@@ -108,7 +113,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
108
113
|
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
|
|
109
114
|
});
|
|
110
115
|
|
|
111
|
-
it("uses msg_type=
|
|
116
|
+
it("uses msg_type=file for mp4", async () => {
|
|
112
117
|
await sendMediaFeishu({
|
|
113
118
|
cfg: {} as any,
|
|
114
119
|
to: "user:ou_target",
|
|
@@ -124,12 +129,12 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
124
129
|
|
|
125
130
|
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
126
131
|
expect.objectContaining({
|
|
127
|
-
data: expect.objectContaining({ msg_type: "
|
|
132
|
+
data: expect.objectContaining({ msg_type: "file" }),
|
|
128
133
|
}),
|
|
129
134
|
);
|
|
130
135
|
});
|
|
131
136
|
|
|
132
|
-
it("uses msg_type=
|
|
137
|
+
it("uses msg_type=audio for opus", async () => {
|
|
133
138
|
await sendMediaFeishu({
|
|
134
139
|
cfg: {} as any,
|
|
135
140
|
to: "user:ou_target",
|
|
@@ -145,7 +150,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
145
150
|
|
|
146
151
|
expect(messageCreateMock).toHaveBeenCalledWith(
|
|
147
152
|
expect.objectContaining({
|
|
148
|
-
data: expect.objectContaining({ msg_type: "
|
|
153
|
+
data: expect.objectContaining({ msg_type: "audio" }),
|
|
149
154
|
}),
|
|
150
155
|
);
|
|
151
156
|
});
|
|
@@ -171,7 +176,7 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
171
176
|
);
|
|
172
177
|
});
|
|
173
178
|
|
|
174
|
-
it("uses msg_type=
|
|
179
|
+
it("uses msg_type=file when replying with mp4", async () => {
|
|
175
180
|
await sendMediaFeishu({
|
|
176
181
|
cfg: {} as any,
|
|
177
182
|
to: "user:ou_target",
|
|
@@ -183,13 +188,71 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
183
188
|
expect(messageReplyMock).toHaveBeenCalledWith(
|
|
184
189
|
expect.objectContaining({
|
|
185
190
|
path: { message_id: "om_parent" },
|
|
186
|
-
data: expect.objectContaining({ msg_type: "
|
|
191
|
+
data: expect.objectContaining({ msg_type: "file" }),
|
|
187
192
|
}),
|
|
188
193
|
);
|
|
189
194
|
|
|
190
195
|
expect(messageCreateMock).not.toHaveBeenCalled();
|
|
191
196
|
});
|
|
192
197
|
|
|
198
|
+
it("passes reply_in_thread when replyInThread is true", async () => {
|
|
199
|
+
await sendMediaFeishu({
|
|
200
|
+
cfg: {} as any,
|
|
201
|
+
to: "user:ou_target",
|
|
202
|
+
mediaBuffer: Buffer.from("video"),
|
|
203
|
+
fileName: "reply.mp4",
|
|
204
|
+
replyToMessageId: "om_parent",
|
|
205
|
+
replyInThread: true,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(messageReplyMock).toHaveBeenCalledWith(
|
|
209
|
+
expect.objectContaining({
|
|
210
|
+
path: { message_id: "om_parent" },
|
|
211
|
+
data: expect.objectContaining({ msg_type: "file", reply_in_thread: true }),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("omits reply_in_thread when replyInThread is false", async () => {
|
|
217
|
+
await sendMediaFeishu({
|
|
218
|
+
cfg: {} as any,
|
|
219
|
+
to: "user:ou_target",
|
|
220
|
+
mediaBuffer: Buffer.from("video"),
|
|
221
|
+
fileName: "reply.mp4",
|
|
222
|
+
replyToMessageId: "om_parent",
|
|
223
|
+
replyInThread: false,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const callData = messageReplyMock.mock.calls[0][0].data;
|
|
227
|
+
expect(callData).not.toHaveProperty("reply_in_thread");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)", async () => {
|
|
231
|
+
loadWebMediaMock.mockResolvedValue({
|
|
232
|
+
buffer: Buffer.from("local-file"),
|
|
233
|
+
fileName: "doc.pdf",
|
|
234
|
+
kind: "document",
|
|
235
|
+
contentType: "application/pdf",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const roots = ["/allowed/workspace", "/tmp/openclaw"];
|
|
239
|
+
await sendMediaFeishu({
|
|
240
|
+
cfg: {} as any,
|
|
241
|
+
to: "user:ou_target",
|
|
242
|
+
mediaUrl: "/allowed/workspace/file.pdf",
|
|
243
|
+
mediaLocalRoots: roots,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
|
247
|
+
"/allowed/workspace/file.pdf",
|
|
248
|
+
expect.objectContaining({
|
|
249
|
+
maxBytes: expect.any(Number),
|
|
250
|
+
optimizeImages: false,
|
|
251
|
+
localRoots: roots,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
193
256
|
it("fails closed when media URL fetch is blocked", async () => {
|
|
194
257
|
loadWebMediaMock.mockRejectedValueOnce(
|
|
195
258
|
new Error("Blocked: resolves to private/internal IP address"),
|
|
@@ -276,4 +339,161 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
276
339
|
|
|
277
340
|
expect(messageResourceGetMock).not.toHaveBeenCalled();
|
|
278
341
|
});
|
|
342
|
+
|
|
343
|
+
it("encodes Chinese filenames for file uploads", async () => {
|
|
344
|
+
await sendMediaFeishu({
|
|
345
|
+
cfg: {} as any,
|
|
346
|
+
to: "user:ou_target",
|
|
347
|
+
mediaBuffer: Buffer.from("doc"),
|
|
348
|
+
fileName: "测试文档.pdf",
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const createCall = fileCreateMock.mock.calls[0][0];
|
|
352
|
+
expect(createCall.data.file_name).not.toBe("测试文档.pdf");
|
|
353
|
+
expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("preserves ASCII filenames unchanged for file uploads", async () => {
|
|
357
|
+
await sendMediaFeishu({
|
|
358
|
+
cfg: {} as any,
|
|
359
|
+
to: "user:ou_target",
|
|
360
|
+
mediaBuffer: Buffer.from("doc"),
|
|
361
|
+
fileName: "report-2026.pdf",
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const createCall = fileCreateMock.mock.calls[0][0];
|
|
365
|
+
expect(createCall.data.file_name).toBe("report-2026.pdf");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
|
|
369
|
+
await sendMediaFeishu({
|
|
370
|
+
cfg: {} as any,
|
|
371
|
+
to: "user:ou_target",
|
|
372
|
+
mediaBuffer: Buffer.from("doc"),
|
|
373
|
+
fileName: "报告—详情(2026).md",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const createCall = fileCreateMock.mock.calls[0][0];
|
|
377
|
+
expect(createCall.data.file_name).toMatch(/\.md$/);
|
|
378
|
+
expect(createCall.data.file_name).not.toContain("—");
|
|
379
|
+
expect(createCall.data.file_name).not.toContain("(");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("sanitizeFileNameForUpload", () => {
|
|
384
|
+
it("returns ASCII filenames unchanged", () => {
|
|
385
|
+
expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf");
|
|
386
|
+
expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("encodes Chinese characters in basename, preserves extension", () => {
|
|
390
|
+
const result = sanitizeFileNameForUpload("测试文件.md");
|
|
391
|
+
expect(result).toBe(encodeURIComponent("测试文件") + ".md");
|
|
392
|
+
expect(result).toMatch(/\.md$/);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("encodes em-dash and full-width brackets", () => {
|
|
396
|
+
const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
|
|
397
|
+
expect(result).toMatch(/\.pdf$/);
|
|
398
|
+
expect(result).not.toContain("—");
|
|
399
|
+
expect(result).not.toContain("(");
|
|
400
|
+
expect(result).not.toContain(")");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("encodes single quotes and parentheses per RFC 5987", () => {
|
|
404
|
+
const result = sanitizeFileNameForUpload("文件'(test).txt");
|
|
405
|
+
expect(result).toContain("%27");
|
|
406
|
+
expect(result).toContain("%28");
|
|
407
|
+
expect(result).toContain("%29");
|
|
408
|
+
expect(result).toMatch(/\.txt$/);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("handles filenames without extension", () => {
|
|
412
|
+
const result = sanitizeFileNameForUpload("测试文件");
|
|
413
|
+
expect(result).toBe(encodeURIComponent("测试文件"));
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("handles mixed ASCII and non-ASCII", () => {
|
|
417
|
+
const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
|
|
418
|
+
expect(result).toMatch(/\.xlsx$/);
|
|
419
|
+
expect(result).not.toContain("报告");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("encodes non-ASCII extensions", () => {
|
|
423
|
+
const result = sanitizeFileNameForUpload("报告.文档");
|
|
424
|
+
expect(result).toContain("%E6%96%87%E6%A1%A3");
|
|
425
|
+
expect(result).not.toContain("文档");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("encodes emoji filenames", () => {
|
|
429
|
+
const result = sanitizeFileNameForUpload("report_😀.txt");
|
|
430
|
+
expect(result).toContain("%F0%9F%98%80");
|
|
431
|
+
expect(result).toMatch(/\.txt$/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("encodes mixed ASCII and non-ASCII extensions", () => {
|
|
435
|
+
const result = sanitizeFileNameForUpload("notes_总结.v测试");
|
|
436
|
+
expect(result).toContain("notes_");
|
|
437
|
+
expect(result).toContain("%E6%B5%8B%E8%AF%95");
|
|
438
|
+
expect(result).not.toContain("测试");
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("downloadMessageResourceFeishu", () => {
|
|
443
|
+
beforeEach(() => {
|
|
444
|
+
vi.clearAllMocks();
|
|
445
|
+
|
|
446
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
447
|
+
configured: true,
|
|
448
|
+
accountId: "main",
|
|
449
|
+
config: {},
|
|
450
|
+
appId: "app_id",
|
|
451
|
+
appSecret: "app_secret",
|
|
452
|
+
domain: "feishu",
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
createFeishuClientMock.mockReturnValue({
|
|
456
|
+
im: {
|
|
457
|
+
messageResource: {
|
|
458
|
+
get: messageResourceGetMock,
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-audio-data"));
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Regression: Feishu API only supports type=image|file for messageResource.get.
|
|
467
|
+
// Audio/video resources must use type=file, not type=audio (#8746).
|
|
468
|
+
it("forwards provided type=file for non-image resources", async () => {
|
|
469
|
+
const result = await downloadMessageResourceFeishu({
|
|
470
|
+
cfg: {} as any,
|
|
471
|
+
messageId: "om_audio_msg",
|
|
472
|
+
fileKey: "file_key_audio",
|
|
473
|
+
type: "file",
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(messageResourceGetMock).toHaveBeenCalledWith({
|
|
477
|
+
path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
|
|
478
|
+
params: { type: "file" },
|
|
479
|
+
});
|
|
480
|
+
expect(result.buffer).toBeInstanceOf(Buffer);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("image uses type=image", async () => {
|
|
484
|
+
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
|
|
485
|
+
|
|
486
|
+
const result = await downloadMessageResourceFeishu({
|
|
487
|
+
cfg: {} as any,
|
|
488
|
+
messageId: "om_img_msg",
|
|
489
|
+
fileKey: "img_key_1",
|
|
490
|
+
type: "image",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
expect(messageResourceGetMock).toHaveBeenCalledWith({
|
|
494
|
+
path: { message_id: "om_img_msg", file_key: "img_key_1" },
|
|
495
|
+
params: { type: "image" },
|
|
496
|
+
});
|
|
497
|
+
expect(result.buffer).toBeInstanceOf(Buffer);
|
|
498
|
+
});
|
|
279
499
|
});
|
package/src/media.ts
CHANGED
|
@@ -207,6 +207,24 @@ export async function uploadImageFeishu(params: {
|
|
|
207
207
|
return { imageKey };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Encode a filename for safe use in Feishu multipart/form-data uploads.
|
|
212
|
+
* Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
|
|
213
|
+
* the upload to silently fail when passed raw through the SDK's form-data
|
|
214
|
+
* serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
|
|
215
|
+
* Feishu's server decodes and preserves the original display name.
|
|
216
|
+
*/
|
|
217
|
+
export function sanitizeFileNameForUpload(fileName: string): string {
|
|
218
|
+
const ASCII_ONLY = /^[\x20-\x7E]+$/;
|
|
219
|
+
if (ASCII_ONLY.test(fileName)) {
|
|
220
|
+
return fileName;
|
|
221
|
+
}
|
|
222
|
+
return encodeURIComponent(fileName)
|
|
223
|
+
.replace(/'/g, "%27")
|
|
224
|
+
.replace(/\(/g, "%28")
|
|
225
|
+
.replace(/\)/g, "%29");
|
|
226
|
+
}
|
|
227
|
+
|
|
210
228
|
/**
|
|
211
229
|
* Upload a file to Feishu and get a file_key for sending.
|
|
212
230
|
* Max file size: 30MB
|
|
@@ -232,10 +250,12 @@ export async function uploadFileFeishu(params: {
|
|
|
232
250
|
// See: https://github.com/larksuite/node-sdk/issues/121
|
|
233
251
|
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
|
|
234
252
|
|
|
253
|
+
const safeFileName = sanitizeFileNameForUpload(fileName);
|
|
254
|
+
|
|
235
255
|
const response = await client.im.file.create({
|
|
236
256
|
data: {
|
|
237
257
|
file_type: fileType,
|
|
238
|
-
file_name:
|
|
258
|
+
file_name: safeFileName,
|
|
239
259
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
|
|
240
260
|
file: fileData as any,
|
|
241
261
|
...(duration !== undefined && { duration }),
|
|
@@ -265,9 +285,10 @@ export async function sendImageFeishu(params: {
|
|
|
265
285
|
to: string;
|
|
266
286
|
imageKey: string;
|
|
267
287
|
replyToMessageId?: string;
|
|
288
|
+
replyInThread?: boolean;
|
|
268
289
|
accountId?: string;
|
|
269
290
|
}): Promise<SendMediaResult> {
|
|
270
|
-
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
|
291
|
+
const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params;
|
|
271
292
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
272
293
|
cfg,
|
|
273
294
|
to,
|
|
@@ -281,6 +302,7 @@ export async function sendImageFeishu(params: {
|
|
|
281
302
|
data: {
|
|
282
303
|
content,
|
|
283
304
|
msg_type: "image",
|
|
305
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
284
306
|
},
|
|
285
307
|
});
|
|
286
308
|
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
|
|
@@ -306,12 +328,13 @@ export async function sendFileFeishu(params: {
|
|
|
306
328
|
cfg: ClawdbotConfig;
|
|
307
329
|
to: string;
|
|
308
330
|
fileKey: string;
|
|
309
|
-
/** Use "
|
|
310
|
-
msgType?: "file" | "
|
|
331
|
+
/** Use "audio" for audio files, "file" for documents and video */
|
|
332
|
+
msgType?: "file" | "audio";
|
|
311
333
|
replyToMessageId?: string;
|
|
334
|
+
replyInThread?: boolean;
|
|
312
335
|
accountId?: string;
|
|
313
336
|
}): Promise<SendMediaResult> {
|
|
314
|
-
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
|
337
|
+
const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params;
|
|
315
338
|
const msgType = params.msgType ?? "file";
|
|
316
339
|
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
317
340
|
cfg,
|
|
@@ -326,6 +349,7 @@ export async function sendFileFeishu(params: {
|
|
|
326
349
|
data: {
|
|
327
350
|
content,
|
|
328
351
|
msg_type: msgType,
|
|
352
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
329
353
|
},
|
|
330
354
|
});
|
|
331
355
|
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
|
|
@@ -376,7 +400,9 @@ export function detectFileType(
|
|
|
376
400
|
}
|
|
377
401
|
|
|
378
402
|
/**
|
|
379
|
-
* Upload and send media (image or file) from URL, local path, or buffer
|
|
403
|
+
* Upload and send media (image or file) from URL, local path, or buffer.
|
|
404
|
+
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
|
|
405
|
+
* must be passed so loadWebMedia allows the path (post CVE-2026-26321).
|
|
380
406
|
*/
|
|
381
407
|
export async function sendMediaFeishu(params: {
|
|
382
408
|
cfg: ClawdbotConfig;
|
|
@@ -385,9 +411,22 @@ export async function sendMediaFeishu(params: {
|
|
|
385
411
|
mediaBuffer?: Buffer;
|
|
386
412
|
fileName?: string;
|
|
387
413
|
replyToMessageId?: string;
|
|
414
|
+
replyInThread?: boolean;
|
|
388
415
|
accountId?: string;
|
|
416
|
+
/** Allowed roots for local path reads; required for local filePath to work. */
|
|
417
|
+
mediaLocalRoots?: readonly string[];
|
|
389
418
|
}): Promise<SendMediaResult> {
|
|
390
|
-
const {
|
|
419
|
+
const {
|
|
420
|
+
cfg,
|
|
421
|
+
to,
|
|
422
|
+
mediaUrl,
|
|
423
|
+
mediaBuffer,
|
|
424
|
+
fileName,
|
|
425
|
+
replyToMessageId,
|
|
426
|
+
replyInThread,
|
|
427
|
+
accountId,
|
|
428
|
+
mediaLocalRoots,
|
|
429
|
+
} = params;
|
|
391
430
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
392
431
|
if (!account.configured) {
|
|
393
432
|
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
@@ -404,6 +443,7 @@ export async function sendMediaFeishu(params: {
|
|
|
404
443
|
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
|
|
405
444
|
maxBytes: mediaMaxBytes,
|
|
406
445
|
optimizeImages: false,
|
|
446
|
+
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
|
|
407
447
|
});
|
|
408
448
|
buffer = loaded.buffer;
|
|
409
449
|
name = fileName ?? loaded.fileName ?? "file";
|
|
@@ -417,7 +457,7 @@ export async function sendMediaFeishu(params: {
|
|
|
417
457
|
|
|
418
458
|
if (isImage) {
|
|
419
459
|
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
|
420
|
-
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
|
|
460
|
+
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
|
|
421
461
|
} else {
|
|
422
462
|
const fileType = detectFileType(name);
|
|
423
463
|
const { fileKey } = await uploadFileFeishu({
|
|
@@ -427,14 +467,15 @@ export async function sendMediaFeishu(params: {
|
|
|
427
467
|
fileType,
|
|
428
468
|
accountId,
|
|
429
469
|
});
|
|
430
|
-
// Feishu
|
|
431
|
-
const
|
|
470
|
+
// Feishu API: opus -> "audio", everything else (including video) -> "file"
|
|
471
|
+
const msgType = fileType === "opus" ? "audio" : "file";
|
|
432
472
|
return sendFileFeishu({
|
|
433
473
|
cfg,
|
|
434
474
|
to,
|
|
435
475
|
fileKey,
|
|
436
|
-
msgType
|
|
476
|
+
msgType,
|
|
437
477
|
replyToMessageId,
|
|
478
|
+
replyInThread,
|
|
438
479
|
accountId,
|
|
439
480
|
});
|
|
440
481
|
}
|
package/src/mention.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s
|
|
|
53
53
|
return false;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const isDirectMessage = event.message.chat_type
|
|
56
|
+
const isDirectMessage = event.message.chat_type !== "group";
|
|
57
57
|
const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
|
|
58
58
|
|
|
59
59
|
if (isDirectMessage) {
|