@openclaw/feishu 2026.3.13 → 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 +95 -7
  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 +778 -775
  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 +63 -1
  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 +32 -94
  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 +375 -26
  91. package/src/media.ts +434 -88
  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 +218 -312
  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 +108 -48
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  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 +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  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 +14 -9
  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 +4 -34
  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 +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  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 +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  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);
@@ -76,6 +86,15 @@ function mockResolvedFeishuAccount() {
76
86
  }
77
87
 
78
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
+
79
98
  beforeEach(() => {
80
99
  vi.clearAllMocks();
81
100
  mockResolvedFeishuAccount();
@@ -130,11 +149,15 @@ describe("sendMediaFeishu msg_type routing", () => {
130
149
 
131
150
  imageGetMock.mockResolvedValue(Buffer.from("image-bytes"));
132
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
+ });
133
156
  });
134
157
 
135
158
  it("uses msg_type=media for mp4 video", async () => {
136
159
  await sendMediaFeishu({
137
- cfg: {} as any,
160
+ cfg: emptyConfig,
138
161
  to: "user:ou_target",
139
162
  mediaBuffer: Buffer.from("video"),
140
163
  fileName: "clip.mp4",
@@ -155,7 +178,7 @@ describe("sendMediaFeishu msg_type routing", () => {
155
178
 
156
179
  it("uses msg_type=audio for opus", async () => {
157
180
  await sendMediaFeishu({
158
- cfg: {} as any,
181
+ cfg: emptyConfig,
159
182
  to: "user:ou_target",
160
183
  mediaBuffer: Buffer.from("audio"),
161
184
  fileName: "voice.opus",
@@ -176,7 +199,7 @@ describe("sendMediaFeishu msg_type routing", () => {
176
199
 
177
200
  it("uses msg_type=file for documents", async () => {
178
201
  await sendMediaFeishu({
179
- cfg: {} as any,
202
+ cfg: emptyConfig,
180
203
  to: "user:ou_target",
181
204
  mediaBuffer: Buffer.from("doc"),
182
205
  fileName: "paper.pdf",
@@ -195,9 +218,159 @@ describe("sendMediaFeishu msg_type routing", () => {
195
218
  );
196
219
  });
197
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
+
198
371
  it("configures the media client timeout for image uploads", async () => {
199
372
  await sendMediaFeishu({
200
- cfg: {} as any,
373
+ cfg: emptyConfig,
201
374
  to: "user:ou_target",
202
375
  mediaBuffer: Buffer.from("image"),
203
376
  fileName: "photo.png",
@@ -213,7 +386,7 @@ describe("sendMediaFeishu msg_type routing", () => {
213
386
 
214
387
  it("uses msg_type=media when replying with mp4", async () => {
215
388
  await sendMediaFeishu({
216
- cfg: {} as any,
389
+ cfg: emptyConfig,
217
390
  to: "user:ou_target",
218
391
  mediaBuffer: Buffer.from("video"),
219
392
  fileName: "reply.mp4",
@@ -232,7 +405,7 @@ describe("sendMediaFeishu msg_type routing", () => {
232
405
 
233
406
  it("passes reply_in_thread when replyInThread is true", async () => {
234
407
  await sendMediaFeishu({
235
- cfg: {} as any,
408
+ cfg: emptyConfig,
236
409
  to: "user:ou_target",
237
410
  mediaBuffer: Buffer.from("video"),
238
411
  fileName: "reply.mp4",
@@ -253,7 +426,7 @@ describe("sendMediaFeishu msg_type routing", () => {
253
426
 
254
427
  it("omits reply_in_thread when replyInThread is false", async () => {
255
428
  await sendMediaFeishu({
256
- cfg: {} as any,
429
+ cfg: emptyConfig,
257
430
  to: "user:ou_target",
258
431
  mediaBuffer: Buffer.from("video"),
259
432
  fileName: "reply.mp4",
@@ -275,7 +448,7 @@ describe("sendMediaFeishu msg_type routing", () => {
275
448
 
276
449
  const roots = ["/allowed/workspace", "/tmp/openclaw"];
277
450
  await sendMediaFeishu({
278
- cfg: {} as any,
451
+ cfg: emptyConfig,
279
452
  to: "user:ou_target",
280
453
  mediaUrl: "/allowed/workspace/file.pdf",
281
454
  mediaLocalRoots: roots,
@@ -298,7 +471,7 @@ describe("sendMediaFeishu msg_type routing", () => {
298
471
 
299
472
  await expect(
300
473
  sendMediaFeishu({
301
- cfg: {} as any,
474
+ cfg: emptyConfig,
302
475
  to: "user:ou_target",
303
476
  mediaUrl: "https://x/img",
304
477
  fileName: "voice.opus",
@@ -322,7 +495,7 @@ describe("sendMediaFeishu msg_type routing", () => {
322
495
  });
323
496
 
324
497
  const result = await downloadImageFeishu({
325
- cfg: {} as any,
498
+ cfg: emptyConfig,
326
499
  imageKey,
327
500
  });
328
501
 
@@ -349,7 +522,7 @@ describe("sendMediaFeishu msg_type routing", () => {
349
522
  });
350
523
 
351
524
  const result = await downloadMessageResourceFeishu({
352
- cfg: {} as any,
525
+ cfg: emptyConfig,
353
526
  messageId: "om_123",
354
527
  fileKey,
355
528
  type: "image",
@@ -363,7 +536,7 @@ describe("sendMediaFeishu msg_type routing", () => {
363
536
  it("rejects invalid image keys before calling feishu api", async () => {
364
537
  await expect(
365
538
  downloadImageFeishu({
366
- cfg: {} as any,
539
+ cfg: emptyConfig,
367
540
  imageKey: "a/../../bad",
368
541
  }),
369
542
  ).rejects.toThrow("invalid image_key");
@@ -374,7 +547,7 @@ describe("sendMediaFeishu msg_type routing", () => {
374
547
  it("rejects invalid file keys before calling feishu api", async () => {
375
548
  await expect(
376
549
  downloadMessageResourceFeishu({
377
- cfg: {} as any,
550
+ cfg: emptyConfig,
378
551
  messageId: "om_123",
379
552
  fileKey: "x/../../bad",
380
553
  type: "file",
@@ -386,7 +559,7 @@ describe("sendMediaFeishu msg_type routing", () => {
386
559
 
387
560
  it("preserves Chinese filenames for file uploads", async () => {
388
561
  await sendMediaFeishu({
389
- cfg: {} as any,
562
+ cfg: emptyConfig,
390
563
  to: "user:ou_target",
391
564
  mediaBuffer: Buffer.from("doc"),
392
565
  fileName: "测试文档.pdf",
@@ -398,7 +571,7 @@ describe("sendMediaFeishu msg_type routing", () => {
398
571
 
399
572
  it("preserves ASCII filenames unchanged for file uploads", async () => {
400
573
  await sendMediaFeishu({
401
- cfg: {} as any,
574
+ cfg: emptyConfig,
402
575
  to: "user:ou_target",
403
576
  mediaBuffer: Buffer.from("doc"),
404
577
  fileName: "report-2026.pdf",
@@ -410,7 +583,7 @@ describe("sendMediaFeishu msg_type routing", () => {
410
583
 
411
584
  it("preserves special Unicode characters (em-dash, full-width brackets) in filenames", async () => {
412
585
  await sendMediaFeishu({
413
- cfg: {} as any,
586
+ cfg: emptyConfig,
414
587
  to: "user:ou_target",
415
588
  mediaBuffer: Buffer.from("doc"),
416
589
  fileName: "报告—详情(2026).md",
@@ -466,6 +639,12 @@ describe("sanitizeFileNameForUpload", () => {
466
639
  });
467
640
 
468
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
+
469
648
  beforeEach(() => {
470
649
  vi.clearAllMocks();
471
650
  mockResolvedFeishuAccount();
@@ -485,7 +664,7 @@ describe("downloadMessageResourceFeishu", () => {
485
664
  // Audio/video resources must use type=file, not type=audio (#8746).
486
665
  it("forwards provided type=file for non-image resources", async () => {
487
666
  const result = await downloadMessageResourceFeishu({
488
- cfg: {} as any,
667
+ cfg: emptyConfig,
489
668
  messageId: "om_audio_msg",
490
669
  fileKey: "file_key_audio",
491
670
  type: "file",
@@ -505,7 +684,7 @@ describe("downloadMessageResourceFeishu", () => {
505
684
  messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
506
685
 
507
686
  const result = await downloadMessageResourceFeishu({
508
- cfg: {} as any,
687
+ cfg: emptyConfig,
509
688
  messageId: "om_img_msg",
510
689
  fileKey: "img_key_1",
511
690
  type: "image",
@@ -520,4 +699,174 @@ describe("downloadMessageResourceFeishu", () => {
520
699
  expectMediaTimeoutClientConfigured();
521
700
  expect(result.buffer).toBeInstanceOf(Buffer);
522
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
+ });
523
872
  });