@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.
Files changed (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. 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 { resolveToolsConfig } from "./tools-config.js";
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 firstAccount = accounts[0];
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
- const getClient = () => createFeishuClient(firstAccount);
188
+ type FeishuDriveExecuteParams = FeishuDriveParams & { accountId?: string };
191
189
 
192
190
  api.registerTool(
193
- {
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 FeishuDriveParams;
201
- try {
202
- const client = getClient();
203
- switch (p.action) {
204
- case "list":
205
- return json(await listFolder(client, p.folder_token));
206
- case "info":
207
- return json(await getFileInfo(client, p.file_token));
208
- case "create_folder":
209
- return json(await createFolder(client, p.name, p.folder_token));
210
- case "move":
211
- return json(await moveFile(client, p.file_token, p.type, p.folder_token));
212
- case "delete":
213
- return json(await deleteFile(client, p.file_token, p.type));
214
- default:
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
216
- return json({ error: `Unknown action: ${(p as any).action}` });
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
- } catch (err) {
219
- return json({ error: err instanceof Error ? err.message : String(err) });
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 { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
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=media for mp4", async () => {
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: "media" }),
132
+ data: expect.objectContaining({ msg_type: "file" }),
128
133
  }),
129
134
  );
130
135
  });
131
136
 
132
- it("uses msg_type=media for opus", async () => {
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: "media" }),
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=media when replying with mp4", async () => {
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: "media" }),
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: fileName,
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 "media" for audio/video files, "file" for documents */
310
- msgType?: "file" | "media";
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 { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId } = params;
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 requires msg_type "media" for audio/video, "file" for documents
431
- const isMedia = fileType === "mp4" || fileType === "opus";
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: isMedia ? "media" : "file",
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 === "p2p";
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) {