@openclaw/feishu 2026.3.12 → 2026.5.1-beta.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.
Files changed (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +115 -22
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +798 -786
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +413 -87
  91. package/src/media.ts +488 -154
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +220 -313
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
package/src/media.test.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
4
- import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
3
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
4
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { ClawdbotConfig } from "../runtime-api.js";
5
6
 
6
7
  const createFeishuClientMock = vi.hoisted(() => vi.fn());
7
8
  const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
8
9
  const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
9
10
  const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
11
  const loadWebMediaMock = vi.hoisted(() => vi.fn());
12
+ const runFfmpegMock = vi.hoisted(() => vi.fn());
11
13
 
12
14
  const fileCreateMock = vi.hoisted(() => vi.fn());
13
15
  const imageCreateMock = vi.hoisted(() => vi.fn());
@@ -17,6 +19,7 @@ const messageResourceGetMock = vi.hoisted(() => vi.fn());
17
19
  const messageReplyMock = vi.hoisted(() => vi.fn());
18
20
 
19
21
  const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
22
+ const emptyConfig: ClawdbotConfig = {};
20
23
 
21
24
  vi.mock("./client.js", () => ({
22
25
  createFeishuClient: createFeishuClientMock,
@@ -24,6 +27,7 @@ vi.mock("./client.js", () => ({
24
27
 
25
28
  vi.mock("./accounts.js", () => ({
26
29
  resolveFeishuAccount: resolveFeishuAccountMock,
30
+ resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
27
31
  }));
28
32
 
29
33
  vi.mock("./targets.js", () => ({
@@ -39,12 +43,18 @@ vi.mock("./runtime.js", () => ({
39
43
  }),
40
44
  }));
41
45
 
42
- import {
43
- downloadImageFeishu,
44
- downloadMessageResourceFeishu,
45
- sanitizeFileNameForUpload,
46
- sendMediaFeishu,
47
- } from "./media.js";
46
+ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
47
+ const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
48
+ return {
49
+ ...actual,
50
+ runFfmpeg: runFfmpegMock,
51
+ };
52
+ });
53
+
54
+ let downloadImageFeishu: typeof import("./media.js").downloadImageFeishu;
55
+ let downloadMessageResourceFeishu: typeof import("./media.js").downloadMessageResourceFeishu;
56
+ let sanitizeFileNameForUpload: typeof import("./media.js").sanitizeFileNameForUpload;
57
+ let sendMediaFeishu: typeof import("./media.js").sendMediaFeishu;
48
58
 
49
59
  function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
50
60
  expect(pathValue).not.toContain(key);
@@ -64,18 +74,30 @@ function expectMediaTimeoutClientConfigured(): void {
64
74
  );
65
75
  }
66
76
 
77
+ function mockResolvedFeishuAccount() {
78
+ resolveFeishuAccountMock.mockReturnValue({
79
+ configured: true,
80
+ accountId: "main",
81
+ config: {},
82
+ appId: "app_id",
83
+ appSecret: "app_secret",
84
+ domain: "feishu",
85
+ });
86
+ }
87
+
67
88
  describe("sendMediaFeishu msg_type routing", () => {
89
+ beforeAll(async () => {
90
+ ({
91
+ downloadImageFeishu,
92
+ downloadMessageResourceFeishu,
93
+ sanitizeFileNameForUpload,
94
+ sendMediaFeishu,
95
+ } = await import("./media.js"));
96
+ });
97
+
68
98
  beforeEach(() => {
69
99
  vi.clearAllMocks();
70
-
71
- resolveFeishuAccountMock.mockReturnValue({
72
- configured: true,
73
- accountId: "main",
74
- config: {},
75
- appId: "app_id",
76
- appSecret: "app_secret",
77
- domain: "feishu",
78
- });
100
+ mockResolvedFeishuAccount();
79
101
 
80
102
  normalizeFeishuTargetMock.mockReturnValue("ou_target");
81
103
  resolveReceiveIdTypeMock.mockReturnValue("open_id");
@@ -127,11 +149,15 @@ describe("sendMediaFeishu msg_type routing", () => {
127
149
 
128
150
  imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
129
151
  messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes"));
152
+ runFfmpegMock.mockImplementation(async (args: string[]) => {
153
+ await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
154
+ return "";
155
+ });
130
156
  });
131
157
 
132
158
  it("uses msg_type=media for mp4 video", async () => {
133
159
  await sendMediaFeishu({
134
- cfg: {} as any,
160
+ cfg: emptyConfig,
135
161
  to: "user:ou_target",
136
162
  mediaBuffer: Buffer.from("video"),
137
163
  fileName: "clip.mp4",
@@ -152,7 +178,7 @@ describe("sendMediaFeishu msg_type routing", () => {
152
178
 
153
179
  it("uses msg_type=audio for opus", async () => {
154
180
  await sendMediaFeishu({
155
- cfg: {} as any,
181
+ cfg: emptyConfig,
156
182
  to: "user:ou_target",
157
183
  mediaBuffer: Buffer.from("audio"),
158
184
  fileName: "voice.opus",
@@ -173,7 +199,7 @@ describe("sendMediaFeishu msg_type routing", () => {
173
199
 
174
200
  it("uses msg_type=file for documents", async () => {
175
201
  await sendMediaFeishu({
176
- cfg: {} as any,
202
+ cfg: emptyConfig,
177
203
  to: "user:ou_target",
178
204
  mediaBuffer: Buffer.from("doc"),
179
205
  fileName: "paper.pdf",
@@ -192,9 +218,159 @@ describe("sendMediaFeishu msg_type routing", () => {
192
218
  );
193
219
  });
194
220
 
221
+ it("uses msg_type=media for remote mp4 content even when the filename is generic", async () => {
222
+ loadWebMediaMock.mockResolvedValueOnce({
223
+ buffer: Buffer.from("remote-video"),
224
+ fileName: "download",
225
+ kind: "video",
226
+ contentType: "video/mp4",
227
+ });
228
+
229
+ await sendMediaFeishu({
230
+ cfg: emptyConfig,
231
+ to: "user:ou_target",
232
+ mediaUrl: "https://example.com/video",
233
+ });
234
+
235
+ expect(fileCreateMock).toHaveBeenCalledWith(
236
+ expect.objectContaining({
237
+ data: expect.objectContaining({ file_type: "mp4" }),
238
+ }),
239
+ );
240
+ expect(messageCreateMock).toHaveBeenCalledWith(
241
+ expect.objectContaining({
242
+ data: expect.objectContaining({ msg_type: "media" }),
243
+ }),
244
+ );
245
+ });
246
+
247
+ it("falls back to generic file for unsupported audio formats", async () => {
248
+ loadWebMediaMock.mockResolvedValueOnce({
249
+ buffer: Buffer.from("remote-mp3"),
250
+ fileName: "song.mp3",
251
+ kind: "audio",
252
+ contentType: "audio/mpeg",
253
+ });
254
+
255
+ await sendMediaFeishu({
256
+ cfg: emptyConfig,
257
+ to: "user:ou_target",
258
+ mediaUrl: "https://example.com/song.mp3",
259
+ });
260
+
261
+ expect(fileCreateMock).toHaveBeenCalledWith(
262
+ expect.objectContaining({
263
+ data: expect.objectContaining({ file_type: "stream" }),
264
+ }),
265
+ );
266
+ expect(messageCreateMock).toHaveBeenCalledWith(
267
+ expect.objectContaining({
268
+ data: expect.objectContaining({ msg_type: "file" }),
269
+ }),
270
+ );
271
+ expect(runFfmpegMock).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("transcodes voice-intent mp3 to msg_type=audio", async () => {
275
+ loadWebMediaMock.mockResolvedValueOnce({
276
+ buffer: Buffer.from("remote-mp3"),
277
+ fileName: "reply.mp3",
278
+ kind: "audio",
279
+ contentType: "audio/mpeg",
280
+ });
281
+
282
+ await sendMediaFeishu({
283
+ cfg: emptyConfig,
284
+ to: "user:ou_target",
285
+ mediaUrl: "https://example.com/reply.mp3",
286
+ audioAsVoice: true,
287
+ });
288
+
289
+ expect(runFfmpegMock).toHaveBeenCalledWith(
290
+ expect.arrayContaining(["-c:a", "libopus", "-ar", "48000", "-b:a", "64k"]),
291
+ );
292
+ expect(fileCreateMock).toHaveBeenCalledWith(
293
+ expect.objectContaining({
294
+ data: expect.objectContaining({
295
+ file_type: "opus",
296
+ file_name: "voice.ogg",
297
+ file: Buffer.from("opus-output"),
298
+ }),
299
+ }),
300
+ );
301
+ expect(messageCreateMock).toHaveBeenCalledWith(
302
+ expect.objectContaining({
303
+ data: expect.objectContaining({ msg_type: "audio" }),
304
+ }),
305
+ );
306
+ });
307
+
308
+ it("leaves native voice audio unchanged when audioAsVoice is true", async () => {
309
+ await sendMediaFeishu({
310
+ cfg: emptyConfig,
311
+ to: "user:ou_target",
312
+ mediaBuffer: Buffer.from("opus"),
313
+ fileName: "reply.ogg",
314
+ audioAsVoice: true,
315
+ });
316
+
317
+ expect(runFfmpegMock).not.toHaveBeenCalled();
318
+ expect(fileCreateMock).toHaveBeenCalledWith(
319
+ expect.objectContaining({
320
+ data: expect.objectContaining({
321
+ file_type: "opus",
322
+ file_name: "reply.ogg",
323
+ }),
324
+ }),
325
+ );
326
+ expect(messageCreateMock).toHaveBeenCalledWith(
327
+ expect.objectContaining({
328
+ data: expect.objectContaining({ msg_type: "audio" }),
329
+ }),
330
+ );
331
+ });
332
+
333
+ it("falls back to file when voice-intent audio cannot be transcoded", async () => {
334
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
335
+ runFfmpegMock.mockRejectedValueOnce(new Error("ffmpeg missing"));
336
+ loadWebMediaMock.mockResolvedValueOnce({
337
+ buffer: Buffer.from("remote-mp3"),
338
+ fileName: "reply.mp3",
339
+ kind: "audio",
340
+ contentType: "audio/mpeg",
341
+ });
342
+
343
+ await sendMediaFeishu({
344
+ cfg: emptyConfig,
345
+ to: "user:ou_target",
346
+ mediaUrl: "https://example.com/reply.mp3",
347
+ audioAsVoice: true,
348
+ });
349
+
350
+ expect(fileCreateMock).toHaveBeenCalledWith(
351
+ expect.objectContaining({
352
+ data: expect.objectContaining({
353
+ file_type: "stream",
354
+ file_name: "reply.mp3",
355
+ file: Buffer.from("remote-mp3"),
356
+ }),
357
+ }),
358
+ );
359
+ expect(messageCreateMock).toHaveBeenCalledWith(
360
+ expect.objectContaining({
361
+ data: expect.objectContaining({ msg_type: "file" }),
362
+ }),
363
+ );
364
+ expect(warnSpy).toHaveBeenCalledWith(
365
+ expect.stringContaining("audioAsVoice transcode failed"),
366
+ expect.any(Error),
367
+ );
368
+ warnSpy.mockRestore();
369
+ });
370
+
195
371
  it("configures the media client timeout for image uploads", async () => {
196
372
  await sendMediaFeishu({
197
- cfg: {} as any,
373
+ cfg: emptyConfig,
198
374
  to: "user:ou_target",
199
375
  mediaBuffer: Buffer.from("image"),
200
376
  fileName: "photo.png",
@@ -210,7 +386,7 @@ describe("sendMediaFeishu msg_type routing", () => {
210
386
 
211
387
  it("uses msg_type=media when replying with mp4", async () => {
212
388
  await sendMediaFeishu({
213
- cfg: {} as any,
389
+ cfg: emptyConfig,
214
390
  to: "user:ou_target",
215
391
  mediaBuffer: Buffer.from("video"),
216
392
  fileName: "reply.mp4",
@@ -229,7 +405,7 @@ describe("sendMediaFeishu msg_type routing", () => {
229
405
 
230
406
  it("passes reply_in_thread when replyInThread is true", async () => {
231
407
  await sendMediaFeishu({
232
- cfg: {} as any,
408
+ cfg: emptyConfig,
233
409
  to: "user:ou_target",
234
410
  mediaBuffer: Buffer.from("video"),
235
411
  fileName: "reply.mp4",
@@ -250,7 +426,7 @@ describe("sendMediaFeishu msg_type routing", () => {
250
426
 
251
427
  it("omits reply_in_thread when replyInThread is false", async () => {
252
428
  await sendMediaFeishu({
253
- cfg: {} as any,
429
+ cfg: emptyConfig,
254
430
  to: "user:ou_target",
255
431
  mediaBuffer: Buffer.from("video"),
256
432
  fileName: "reply.mp4",
@@ -272,7 +448,7 @@ describe("sendMediaFeishu msg_type routing", () => {
272
448
 
273
449
  const roots = ["/allowed/workspace", "/tmp/openclaw"];
274
450
  await sendMediaFeishu({
275
- cfg: {} as any,
451
+ cfg: emptyConfig,
276
452
  to: "user:ou_target",
277
453
  mediaUrl: "/allowed/workspace/file.pdf",
278
454
  mediaLocalRoots: roots,
@@ -295,7 +471,7 @@ describe("sendMediaFeishu msg_type routing", () => {
295
471
 
296
472
  await expect(
297
473
  sendMediaFeishu({
298
- cfg: {} as any,
474
+ cfg: emptyConfig,
299
475
  to: "user:ou_target",
300
476
  mediaUrl: "https://x/img",
301
477
  fileName: "voice.opus",
@@ -319,7 +495,7 @@ describe("sendMediaFeishu msg_type routing", () => {
319
495
  });
320
496
 
321
497
  const result = await downloadImageFeishu({
322
- cfg: {} as any,
498
+ cfg: emptyConfig,
323
499
  imageKey,
324
500
  });
325
501
 
@@ -346,7 +522,7 @@ describe("sendMediaFeishu msg_type routing", () => {
346
522
  });
347
523
 
348
524
  const result = await downloadMessageResourceFeishu({
349
- cfg: {} as any,
525
+ cfg: emptyConfig,
350
526
  messageId: "om_123",
351
527
  fileKey,
352
528
  type: "image",
@@ -360,7 +536,7 @@ describe("sendMediaFeishu msg_type routing", () => {
360
536
  it("rejects invalid image keys before calling feishu api", async () => {
361
537
  await expect(
362
538
  downloadImageFeishu({
363
- cfg: {} as any,
539
+ cfg: emptyConfig,
364
540
  imageKey: "a/../../bad",
365
541
  }),
366
542
  ).rejects.toThrow("invalid image_key");
@@ -371,7 +547,7 @@ describe("sendMediaFeishu msg_type routing", () => {
371
547
  it("rejects invalid file keys before calling feishu api", async () => {
372
548
  await expect(
373
549
  downloadMessageResourceFeishu({
374
- cfg: {} as any,
550
+ cfg: emptyConfig,
375
551
  messageId: "om_123",
376
552
  fileKey: "x/../../bad",
377
553
  type: "file",
@@ -381,22 +557,21 @@ describe("sendMediaFeishu msg_type routing", () => {
381
557
  expect(messageResourceGetMock).not.toHaveBeenCalled();
382
558
  });
383
559
 
384
- it("encodes Chinese filenames for file uploads", async () => {
560
+ it("preserves Chinese filenames for file uploads", async () => {
385
561
  await sendMediaFeishu({
386
- cfg: {} as any,
562
+ cfg: emptyConfig,
387
563
  to: "user:ou_target",
388
564
  mediaBuffer: Buffer.from("doc"),
389
565
  fileName: "测试文档.pdf",
390
566
  });
391
567
 
392
568
  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");
569
+ expect(createCall.data.file_name).toBe("测试文档.pdf");
395
570
  });
396
571
 
397
572
  it("preserves ASCII filenames unchanged for file uploads", async () => {
398
573
  await sendMediaFeishu({
399
- cfg: {} as any,
574
+ cfg: emptyConfig,
400
575
  to: "user:ou_target",
401
576
  mediaBuffer: Buffer.from("doc"),
402
577
  fileName: "report-2026.pdf",
@@ -406,18 +581,16 @@ describe("sendMediaFeishu msg_type routing", () => {
406
581
  expect(createCall.data.file_name).toBe("report-2026.pdf");
407
582
  });
408
583
 
409
- it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
584
+ it("preserves special Unicode characters (em-dash, full-width brackets) in filenames", async () => {
410
585
  await sendMediaFeishu({
411
- cfg: {} as any,
586
+ cfg: emptyConfig,
412
587
  to: "user:ou_target",
413
588
  mediaBuffer: Buffer.from("doc"),
414
589
  fileName: "报告—详情(2026).md",
415
590
  });
416
591
 
417
592
  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("(");
593
+ expect(createCall.data.file_name).toBe("报告—详情(2026).md");
421
594
  });
422
595
  });
423
596
 
@@ -427,71 +600,54 @@ describe("sanitizeFileNameForUpload", () => {
427
600
  expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
428
601
  });
429
602
 
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$/);
603
+ it("preserves Chinese characters", () => {
604
+ expect(sanitizeFileNameForUpload("测试文件.md")).toBe("测试文件.md");
605
+ expect(sanitizeFileNameForUpload("武汉15座山登山信息汇总.csv")).toBe(
606
+ "武汉15座山登山信息汇总.csv",
607
+ );
434
608
  });
435
609
 
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(")");
610
+ it("preserves em-dash and full-width brackets", () => {
611
+ expect(sanitizeFileNameForUpload("文件—说明(v2).pdf")).toBe("文件—说明(v2).pdf");
442
612
  });
443
613
 
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$/);
614
+ it("preserves single quotes and parentheses", () => {
615
+ expect(sanitizeFileNameForUpload("文件'(test).txt")).toBe("文件'(test).txt");
450
616
  });
451
617
 
452
- it("handles filenames without extension", () => {
453
- const result = sanitizeFileNameForUpload("测试文件");
454
- expect(result).toBe(encodeURIComponent("测试文件"));
618
+ it("preserves filenames without extension", () => {
619
+ expect(sanitizeFileNameForUpload("测试文件")).toBe("测试文件");
455
620
  });
456
621
 
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("报告");
622
+ it("preserves mixed ASCII and non-ASCII", () => {
623
+ expect(sanitizeFileNameForUpload("Report_报告_2026.xlsx")).toBe("Report_报告_2026.xlsx");
461
624
  });
462
625
 
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("文档");
626
+ it("preserves emoji filenames", () => {
627
+ expect(sanitizeFileNameForUpload("report_😀.txt")).toBe("report_😀.txt");
467
628
  });
468
629
 
469
- it("encodes emoji filenames", () => {
470
- const result = sanitizeFileNameForUpload("report_😀.txt");
471
- expect(result).toContain("%F0%9F%98%80");
472
- expect(result).toMatch(/\.txt$/);
630
+ it("strips control characters", () => {
631
+ expect(sanitizeFileNameForUpload("bad\x00file.txt")).toBe("bad_file.txt");
632
+ expect(sanitizeFileNameForUpload("inject\r\nheader.txt")).toBe("inject__header.txt");
473
633
  });
474
634
 
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("测试");
635
+ it("strips quotes and backslashes to prevent header injection", () => {
636
+ expect(sanitizeFileNameForUpload('file"name.txt')).toBe("file_name.txt");
637
+ expect(sanitizeFileNameForUpload("file\\name.txt")).toBe("file_name.txt");
480
638
  });
481
639
  });
482
640
 
483
641
  describe("downloadMessageResourceFeishu", () => {
642
+ function httpStatusError(status: number): Error & { response: { status: number } } {
643
+ return Object.assign(new Error(`Request failed with status code ${status}`), {
644
+ response: { status },
645
+ });
646
+ }
647
+
484
648
  beforeEach(() => {
485
649
  vi.clearAllMocks();
486
-
487
- resolveFeishuAccountMock.mockReturnValue({
488
- configured: true,
489
- accountId: "main",
490
- config: {},
491
- appId: "app_id",
492
- appSecret: "app_secret",
493
- domain: "feishu",
494
- });
650
+ mockResolvedFeishuAccount();
495
651
 
496
652
  createFeishuClientMock.mockReturnValue({
497
653
  im: {
@@ -508,7 +664,7 @@ describe("downloadMessageResourceFeishu", () => {
508
664
  // Audio/video resources must use type=file, not type=audio (#8746).
509
665
  it("forwards provided type=file for non-image resources", async () => {
510
666
  const result = await downloadMessageResourceFeishu({
511
- cfg: {} as any,
667
+ cfg: emptyConfig,
512
668
  messageId: "om_audio_msg",
513
669
  fileKey: "file_key_audio",
514
670
  type: "file",
@@ -528,7 +684,7 @@ describe("downloadMessageResourceFeishu", () => {
528
684
  messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
529
685
 
530
686
  const result = await downloadMessageResourceFeishu({
531
- cfg: {} as any,
687
+ cfg: emptyConfig,
532
688
  messageId: "om_img_msg",
533
689
  fileKey: "img_key_1",
534
690
  type: "image",
@@ -543,4 +699,174 @@ describe("downloadMessageResourceFeishu", () => {
543
699
  expectMediaTimeoutClientConfigured();
544
700
  expect(result.buffer).toBeInstanceOf(Buffer);
545
701
  });
702
+
703
+ it("extracts content-type and filename metadata from download headers", async () => {
704
+ messageResourceGetMock.mockResolvedValueOnce({
705
+ data: Buffer.from("fake-video-data"),
706
+ headers: {
707
+ "content-type": "video/mp4",
708
+ "content-disposition": `attachment; filename="clip.mp4"`,
709
+ },
710
+ });
711
+
712
+ const result = await downloadMessageResourceFeishu({
713
+ cfg: emptyConfig,
714
+ messageId: "om_video_msg",
715
+ fileKey: "file_key_video",
716
+ type: "file",
717
+ });
718
+
719
+ expect(result).toMatchObject({
720
+ buffer: Buffer.from("fake-video-data"),
721
+ contentType: "video/mp4",
722
+ fileName: "clip.mp4",
723
+ });
724
+ });
725
+
726
+ it("retries file resources as media after HTTP 502", async () => {
727
+ const originalError = httpStatusError(502);
728
+ messageResourceGetMock.mockRejectedValueOnce(originalError).mockResolvedValueOnce({
729
+ data: Buffer.from("fake-ios-video-data"),
730
+ headers: {
731
+ "content-type": "video/mp4",
732
+ "content-disposition": `attachment; filename="ios-video.mp4"`,
733
+ },
734
+ });
735
+
736
+ const result = await downloadMessageResourceFeishu({
737
+ cfg: emptyConfig,
738
+ messageId: "om_ios_video_msg",
739
+ fileKey: "file_key_ios_video",
740
+ type: "file",
741
+ });
742
+
743
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
744
+ 1,
745
+ expect.objectContaining({
746
+ path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" },
747
+ params: { type: "file" },
748
+ }),
749
+ );
750
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
751
+ 2,
752
+ expect.objectContaining({
753
+ path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" },
754
+ params: { type: "media" },
755
+ }),
756
+ );
757
+ expect(result).toMatchObject({
758
+ buffer: Buffer.from("fake-ios-video-data"),
759
+ contentType: "video/mp4",
760
+ fileName: "ios-video.mp4",
761
+ });
762
+ });
763
+
764
+ it("rethrows the original HTTP 502 when the media retry fails", async () => {
765
+ const originalError = httpStatusError(502);
766
+ messageResourceGetMock
767
+ .mockRejectedValueOnce(originalError)
768
+ .mockRejectedValueOnce(new Error("media retry failed"));
769
+
770
+ await expect(
771
+ downloadMessageResourceFeishu({
772
+ cfg: emptyConfig,
773
+ messageId: "om_ios_video_msg",
774
+ fileKey: "file_key_ios_video",
775
+ type: "file",
776
+ }),
777
+ ).rejects.toBe(originalError);
778
+
779
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
780
+ 1,
781
+ expect.objectContaining({ params: { type: "file" } }),
782
+ );
783
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
784
+ 2,
785
+ expect.objectContaining({ params: { type: "media" } }),
786
+ );
787
+ });
788
+
789
+ it("does not retry non-fallback download failures", async () => {
790
+ for (const scenario of [
791
+ { messageId: "om_image_msg", fileKey: "img_key_502", type: "image" as const, status: 502 },
792
+ { messageId: "om_file_msg", fileKey: "file_key_500", type: "file" as const, status: 500 },
793
+ ]) {
794
+ const originalError = httpStatusError(scenario.status);
795
+ messageResourceGetMock.mockClear();
796
+ messageResourceGetMock.mockRejectedValueOnce(originalError);
797
+
798
+ await expect(
799
+ downloadMessageResourceFeishu({
800
+ cfg: emptyConfig,
801
+ messageId: scenario.messageId,
802
+ fileKey: scenario.fileKey,
803
+ type: scenario.type,
804
+ }),
805
+ ).rejects.toBe(originalError);
806
+
807
+ expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
808
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
809
+ expect.objectContaining({
810
+ path: { message_id: scenario.messageId, file_key: scenario.fileKey },
811
+ params: { type: scenario.type },
812
+ }),
813
+ );
814
+ }
815
+ });
816
+
817
+ it("recovers CJK filenames from plain Content-Disposition headers decoded as Latin-1", async () => {
818
+ const fileName = "武汉15座山登山信息汇总.csv";
819
+ const latin1HeaderFileName = Buffer.from(fileName, "utf8").toString("latin1");
820
+ messageResourceGetMock.mockResolvedValueOnce({
821
+ data: Buffer.from("fake-file-data"),
822
+ headers: {
823
+ "content-disposition": `attachment; filename="${latin1HeaderFileName}"`,
824
+ },
825
+ });
826
+
827
+ const result = await downloadMessageResourceFeishu({
828
+ cfg: emptyConfig,
829
+ messageId: "om_file_msg",
830
+ fileKey: "file_key_csv",
831
+ type: "file",
832
+ });
833
+
834
+ expect(result.fileName).toBe(fileName);
835
+ });
836
+
837
+ it("keeps valid Latin-1 filenames from plain Content-Disposition headers unchanged", async () => {
838
+ messageResourceGetMock.mockResolvedValueOnce({
839
+ data: Buffer.from("fake-file-data"),
840
+ headers: {
841
+ "content-disposition": `attachment; filename="café-©.txt"`,
842
+ },
843
+ });
844
+
845
+ const result = await downloadMessageResourceFeishu({
846
+ cfg: emptyConfig,
847
+ messageId: "om_latin1_msg",
848
+ fileKey: "file_key_latin1",
849
+ type: "file",
850
+ });
851
+
852
+ expect(result.fileName).toBe("café-©.txt");
853
+ });
854
+
855
+ it("keeps JSON-derived file_name metadata unchanged", async () => {
856
+ const fileName = "武汉15座山登山信息汇总.csv";
857
+ const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
858
+ messageResourceGetMock.mockResolvedValueOnce({
859
+ data: Buffer.from("fake-file-data"),
860
+ file_name: latin1LookingFileName,
861
+ });
862
+
863
+ const result = await downloadMessageResourceFeishu({
864
+ cfg: emptyConfig,
865
+ messageId: "om_json_file_msg",
866
+ fileKey: "file_key_json",
867
+ type: "file",
868
+ });
869
+
870
+ expect(result.fileName).toBe(latin1LookingFileName);
871
+ });
546
872
  });