@openclaw/feishu 2026.3.1 → 2026.3.7

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 (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. package/src/wiki.ts +15 -19
package/src/media.test.ts CHANGED
@@ -10,11 +10,14 @@ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
10
  const loadWebMediaMock = vi.hoisted(() => vi.fn());
11
11
 
12
12
  const fileCreateMock = vi.hoisted(() => vi.fn());
13
+ const imageCreateMock = vi.hoisted(() => vi.fn());
13
14
  const imageGetMock = vi.hoisted(() => vi.fn());
14
15
  const messageCreateMock = vi.hoisted(() => vi.fn());
15
16
  const messageResourceGetMock = vi.hoisted(() => vi.fn());
16
17
  const messageReplyMock = vi.hoisted(() => vi.fn());
17
18
 
19
+ const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
20
+
18
21
  vi.mock("./client.js", () => ({
19
22
  createFeishuClient: createFeishuClientMock,
20
23
  }));
@@ -36,7 +39,12 @@ vi.mock("./runtime.js", () => ({
36
39
  }),
37
40
  }));
38
41
 
39
- import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
42
+ import {
43
+ downloadImageFeishu,
44
+ downloadMessageResourceFeishu,
45
+ sanitizeFileNameForUpload,
46
+ sendMediaFeishu,
47
+ } from "./media.js";
40
48
 
41
49
  function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
42
50
  expect(pathValue).not.toContain(key);
@@ -48,6 +56,14 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
48
56
  expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
49
57
  }
50
58
 
59
+ function expectMediaTimeoutClientConfigured(): void {
60
+ expect(createFeishuClientMock).toHaveBeenCalledWith(
61
+ expect.objectContaining({
62
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
63
+ }),
64
+ );
65
+ }
66
+
51
67
  describe("sendMediaFeishu msg_type routing", () => {
52
68
  beforeEach(() => {
53
69
  vi.clearAllMocks();
@@ -70,6 +86,7 @@ describe("sendMediaFeishu msg_type routing", () => {
70
86
  create: fileCreateMock,
71
87
  },
72
88
  image: {
89
+ create: imageCreateMock,
73
90
  get: imageGetMock,
74
91
  },
75
92
  message: {
@@ -86,6 +103,10 @@ describe("sendMediaFeishu msg_type routing", () => {
86
103
  code: 0,
87
104
  data: { file_key: "file_key_1" },
88
105
  });
106
+ imageCreateMock.mockResolvedValue({
107
+ code: 0,
108
+ data: { image_key: "image_key_1" },
109
+ });
89
110
 
90
111
  messageCreateMock.mockResolvedValue({
91
112
  code: 0,
@@ -108,7 +129,7 @@ describe("sendMediaFeishu msg_type routing", () => {
108
129
  messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
109
130
  });
110
131
 
111
- it("uses msg_type=file for mp4", async () => {
132
+ it("uses msg_type=media for mp4 video", async () => {
112
133
  await sendMediaFeishu({
113
134
  cfg: {} as any,
114
135
  to: "user:ou_target",
@@ -124,7 +145,7 @@ describe("sendMediaFeishu msg_type routing", () => {
124
145
 
125
146
  expect(messageCreateMock).toHaveBeenCalledWith(
126
147
  expect.objectContaining({
127
- data: expect.objectContaining({ msg_type: "file" }),
148
+ data: expect.objectContaining({ msg_type: "media" }),
128
149
  }),
129
150
  );
130
151
  });
@@ -171,7 +192,23 @@ describe("sendMediaFeishu msg_type routing", () => {
171
192
  );
172
193
  });
173
194
 
174
- it("uses msg_type=file when replying with mp4", async () => {
195
+ it("configures the media client timeout for image uploads", async () => {
196
+ await sendMediaFeishu({
197
+ cfg: {} as any,
198
+ to: "user:ou_target",
199
+ mediaBuffer: Buffer.from("image"),
200
+ fileName: "photo.png",
201
+ });
202
+
203
+ expectMediaTimeoutClientConfigured();
204
+ expect(messageCreateMock).toHaveBeenCalledWith(
205
+ expect.objectContaining({
206
+ data: expect.objectContaining({ msg_type: "image" }),
207
+ }),
208
+ );
209
+ });
210
+
211
+ it("uses msg_type=media when replying with mp4", async () => {
175
212
  await sendMediaFeishu({
176
213
  cfg: {} as any,
177
214
  to: "user:ou_target",
@@ -183,7 +220,7 @@ describe("sendMediaFeishu msg_type routing", () => {
183
220
  expect(messageReplyMock).toHaveBeenCalledWith(
184
221
  expect.objectContaining({
185
222
  path: { message_id: "om_parent" },
186
- data: expect.objectContaining({ msg_type: "file" }),
223
+ data: expect.objectContaining({ msg_type: "media" }),
187
224
  }),
188
225
  );
189
226
 
@@ -203,7 +240,10 @@ describe("sendMediaFeishu msg_type routing", () => {
203
240
  expect(messageReplyMock).toHaveBeenCalledWith(
204
241
  expect.objectContaining({
205
242
  path: { message_id: "om_parent" },
206
- data: expect.objectContaining({ msg_type: "file", reply_in_thread: true }),
243
+ data: expect.objectContaining({
244
+ msg_type: "media",
245
+ reply_in_thread: true,
246
+ }),
207
247
  }),
208
248
  );
209
249
  });
@@ -283,6 +323,12 @@ describe("sendMediaFeishu msg_type routing", () => {
283
323
  imageKey,
284
324
  });
285
325
 
326
+ expect(imageGetMock).toHaveBeenCalledWith(
327
+ expect.objectContaining({
328
+ path: { image_key: imageKey },
329
+ }),
330
+ );
331
+ expectMediaTimeoutClientConfigured();
286
332
  expect(result.buffer).toEqual(Buffer.from("image-data"));
287
333
  expect(capturedPath).toBeDefined();
288
334
  expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
@@ -334,6 +380,104 @@ describe("sendMediaFeishu msg_type routing", () => {
334
380
 
335
381
  expect(messageResourceGetMock).not.toHaveBeenCalled();
336
382
  });
383
+
384
+ it("encodes Chinese filenames for file uploads", async () => {
385
+ await sendMediaFeishu({
386
+ cfg: {} as any,
387
+ to: "user:ou_target",
388
+ mediaBuffer: Buffer.from("doc"),
389
+ fileName: "测试文档.pdf",
390
+ });
391
+
392
+ const createCall = fileCreateMock.mock.calls[0][0];
393
+ expect(createCall.data.file_name).not.toBe("测试文档.pdf");
394
+ expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
395
+ });
396
+
397
+ it("preserves ASCII filenames unchanged for file uploads", async () => {
398
+ await sendMediaFeishu({
399
+ cfg: {} as any,
400
+ to: "user:ou_target",
401
+ mediaBuffer: Buffer.from("doc"),
402
+ fileName: "report-2026.pdf",
403
+ });
404
+
405
+ const createCall = fileCreateMock.mock.calls[0][0];
406
+ expect(createCall.data.file_name).toBe("report-2026.pdf");
407
+ });
408
+
409
+ it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
410
+ await sendMediaFeishu({
411
+ cfg: {} as any,
412
+ to: "user:ou_target",
413
+ mediaBuffer: Buffer.from("doc"),
414
+ fileName: "报告—详情(2026).md",
415
+ });
416
+
417
+ const createCall = fileCreateMock.mock.calls[0][0];
418
+ expect(createCall.data.file_name).toMatch(/\.md$/);
419
+ expect(createCall.data.file_name).not.toContain("—");
420
+ expect(createCall.data.file_name).not.toContain("(");
421
+ });
422
+ });
423
+
424
+ describe("sanitizeFileNameForUpload", () => {
425
+ it("returns ASCII filenames unchanged", () => {
426
+ expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf");
427
+ expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
428
+ });
429
+
430
+ it("encodes Chinese characters in basename, preserves extension", () => {
431
+ const result = sanitizeFileNameForUpload("测试文件.md");
432
+ expect(result).toBe(encodeURIComponent("测试文件") + ".md");
433
+ expect(result).toMatch(/\.md$/);
434
+ });
435
+
436
+ it("encodes em-dash and full-width brackets", () => {
437
+ const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
438
+ expect(result).toMatch(/\.pdf$/);
439
+ expect(result).not.toContain("—");
440
+ expect(result).not.toContain("(");
441
+ expect(result).not.toContain(")");
442
+ });
443
+
444
+ it("encodes single quotes and parentheses per RFC 5987", () => {
445
+ const result = sanitizeFileNameForUpload("文件'(test).txt");
446
+ expect(result).toContain("%27");
447
+ expect(result).toContain("%28");
448
+ expect(result).toContain("%29");
449
+ expect(result).toMatch(/\.txt$/);
450
+ });
451
+
452
+ it("handles filenames without extension", () => {
453
+ const result = sanitizeFileNameForUpload("测试文件");
454
+ expect(result).toBe(encodeURIComponent("测试文件"));
455
+ });
456
+
457
+ it("handles mixed ASCII and non-ASCII", () => {
458
+ const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
459
+ expect(result).toMatch(/\.xlsx$/);
460
+ expect(result).not.toContain("报告");
461
+ });
462
+
463
+ it("encodes non-ASCII extensions", () => {
464
+ const result = sanitizeFileNameForUpload("报告.文档");
465
+ expect(result).toContain("%E6%96%87%E6%A1%A3");
466
+ expect(result).not.toContain("文档");
467
+ });
468
+
469
+ it("encodes emoji filenames", () => {
470
+ const result = sanitizeFileNameForUpload("report_😀.txt");
471
+ expect(result).toContain("%F0%9F%98%80");
472
+ expect(result).toMatch(/\.txt$/);
473
+ });
474
+
475
+ it("encodes mixed ASCII and non-ASCII extensions", () => {
476
+ const result = sanitizeFileNameForUpload("notes_总结.v测试");
477
+ expect(result).toContain("notes_");
478
+ expect(result).toContain("%E6%B5%8B%E8%AF%95");
479
+ expect(result).not.toContain("测试");
480
+ });
337
481
  });
338
482
 
339
483
  describe("downloadMessageResourceFeishu", () => {
@@ -370,10 +514,13 @@ describe("downloadMessageResourceFeishu", () => {
370
514
  type: "file",
371
515
  });
372
516
 
373
- expect(messageResourceGetMock).toHaveBeenCalledWith({
374
- path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
375
- params: { type: "file" },
376
- });
517
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
518
+ expect.objectContaining({
519
+ path: { message_id: "om_audio_msg", file_key: "file_key_audio" },
520
+ params: { type: "file" },
521
+ }),
522
+ );
523
+ expectMediaTimeoutClientConfigured();
377
524
  expect(result.buffer).toBeInstanceOf(Buffer);
378
525
  });
379
526
 
@@ -387,10 +534,13 @@ describe("downloadMessageResourceFeishu", () => {
387
534
  type: "image",
388
535
  });
389
536
 
390
- expect(messageResourceGetMock).toHaveBeenCalledWith({
391
- path: { message_id: "om_img_msg", file_key: "img_key_1" },
392
- params: { type: "image" },
393
- });
537
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
538
+ expect.objectContaining({
539
+ path: { message_id: "om_img_msg", file_key: "img_key_1" },
540
+ params: { type: "image" },
541
+ }),
542
+ );
543
+ expectMediaTimeoutClientConfigured();
394
544
  expect(result.buffer).toBeInstanceOf(Buffer);
395
545
  });
396
546
  });
package/src/media.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { Readable } from "stream";
4
- import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk";
4
+ import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
5
5
  import { resolveFeishuAccount } from "./accounts.js";
6
6
  import { createFeishuClient } from "./client.js";
7
7
  import { normalizeFeishuExternalKey } from "./external-keys.js";
@@ -9,6 +9,8 @@ import { getFeishuRuntime } from "./runtime.js";
9
9
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
10
10
  import { resolveFeishuSendTarget } from "./send-target.js";
11
11
 
12
+ const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
13
+
12
14
  export type DownloadImageResult = {
13
15
  buffer: Buffer;
14
16
  contentType?: string;
@@ -97,7 +99,10 @@ export async function downloadImageFeishu(params: {
97
99
  throw new Error(`Feishu account "${account.accountId}" not configured`);
98
100
  }
99
101
 
100
- const client = createFeishuClient(account);
102
+ const client = createFeishuClient({
103
+ ...account,
104
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
105
+ });
101
106
 
102
107
  const response = await client.im.image.get({
103
108
  path: { image_key: normalizedImageKey },
@@ -132,7 +137,10 @@ export async function downloadMessageResourceFeishu(params: {
132
137
  throw new Error(`Feishu account "${account.accountId}" not configured`);
133
138
  }
134
139
 
135
- const client = createFeishuClient(account);
140
+ const client = createFeishuClient({
141
+ ...account,
142
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
143
+ });
136
144
 
137
145
  const response = await client.im.messageResource.get({
138
146
  path: { message_id: messageId, file_key: normalizedFileKey },
@@ -176,7 +184,10 @@ export async function uploadImageFeishu(params: {
176
184
  throw new Error(`Feishu account "${account.accountId}" not configured`);
177
185
  }
178
186
 
179
- const client = createFeishuClient(account);
187
+ const client = createFeishuClient({
188
+ ...account,
189
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
190
+ });
180
191
 
181
192
  // SDK accepts Buffer directly or fs.ReadStream for file paths
182
193
  // Using Readable.from(buffer) causes issues with form-data library
@@ -207,6 +218,24 @@ export async function uploadImageFeishu(params: {
207
218
  return { imageKey };
208
219
  }
209
220
 
221
+ /**
222
+ * Encode a filename for safe use in Feishu multipart/form-data uploads.
223
+ * Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
224
+ * the upload to silently fail when passed raw through the SDK's form-data
225
+ * serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
226
+ * Feishu's server decodes and preserves the original display name.
227
+ */
228
+ export function sanitizeFileNameForUpload(fileName: string): string {
229
+ const ASCII_ONLY = /^[\x20-\x7E]+$/;
230
+ if (ASCII_ONLY.test(fileName)) {
231
+ return fileName;
232
+ }
233
+ return encodeURIComponent(fileName)
234
+ .replace(/'/g, "%27")
235
+ .replace(/\(/g, "%28")
236
+ .replace(/\)/g, "%29");
237
+ }
238
+
210
239
  /**
211
240
  * Upload a file to Feishu and get a file_key for sending.
212
241
  * Max file size: 30MB
@@ -225,17 +254,22 @@ export async function uploadFileFeishu(params: {
225
254
  throw new Error(`Feishu account "${account.accountId}" not configured`);
226
255
  }
227
256
 
228
- const client = createFeishuClient(account);
257
+ const client = createFeishuClient({
258
+ ...account,
259
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
260
+ });
229
261
 
230
262
  // SDK accepts Buffer directly or fs.ReadStream for file paths
231
263
  // Using Readable.from(buffer) causes issues with form-data library
232
264
  // See: https://github.com/larksuite/node-sdk/issues/121
233
265
  const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
234
266
 
267
+ const safeFileName = sanitizeFileNameForUpload(fileName);
268
+
235
269
  const response = await client.im.file.create({
236
270
  data: {
237
271
  file_type: fileType,
238
- file_name: fileName,
272
+ file_name: safeFileName,
239
273
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
240
274
  file: fileData as any,
241
275
  ...(duration !== undefined && { duration }),
@@ -308,8 +342,8 @@ export async function sendFileFeishu(params: {
308
342
  cfg: ClawdbotConfig;
309
343
  to: string;
310
344
  fileKey: string;
311
- /** Use "audio" for audio files, "file" for documents and video */
312
- msgType?: "file" | "audio";
345
+ /** Use "audio" for audio, "media" for video (mp4), "file" for documents */
346
+ msgType?: "file" | "audio" | "media";
313
347
  replyToMessageId?: string;
314
348
  replyInThread?: boolean;
315
349
  accountId?: string;
@@ -447,8 +481,8 @@ export async function sendMediaFeishu(params: {
447
481
  fileType,
448
482
  accountId,
449
483
  });
450
- // Feishu API: opus -> "audio", everything else (including video) -> "file"
451
- const msgType = fileType === "opus" ? "audio" : "file";
484
+ // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
485
+ const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
452
486
  return sendFileFeishu({
453
487
  cfg,
454
488
  to,
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) {