@openclaw/feishu 2026.3.13 → 2026.5.2-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 (187) 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 +1827 -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 +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -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 +406 -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 +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  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 +403 -26
  91. package/src/media.ts +509 -132
  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.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. 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",
@@ -211,9 +384,37 @@ describe("sendMediaFeishu msg_type routing", () => {
211
384
  );
212
385
  });
213
386
 
387
+ it("preserves Feishu diagnostics when media sends reject before response checks", async () => {
388
+ messageCreateMock.mockRejectedValueOnce(
389
+ Object.assign(new Error("Request failed with status code 400"), {
390
+ response: {
391
+ status: 400,
392
+ data: {
393
+ code: 9499,
394
+ msg: "Bad Request",
395
+ error: {
396
+ log_id: "20260429124731MEDIA",
397
+ troubleshooter: "https://open.feishu.cn/search?log_id=20260429124731MEDIA",
398
+ },
399
+ },
400
+ },
401
+ }),
402
+ );
403
+
404
+ const send = sendMediaFeishu({
405
+ cfg: emptyConfig,
406
+ to: "user:ou_target",
407
+ mediaBuffer: Buffer.from("image"),
408
+ fileName: "photo.png",
409
+ });
410
+
411
+ await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/);
412
+ await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/);
413
+ });
414
+
214
415
  it("uses msg_type=media when replying with mp4", async () => {
215
416
  await sendMediaFeishu({
216
- cfg: {} as any,
417
+ cfg: emptyConfig,
217
418
  to: "user:ou_target",
218
419
  mediaBuffer: Buffer.from("video"),
219
420
  fileName: "reply.mp4",
@@ -232,7 +433,7 @@ describe("sendMediaFeishu msg_type routing", () => {
232
433
 
233
434
  it("passes reply_in_thread when replyInThread is true", async () => {
234
435
  await sendMediaFeishu({
235
- cfg: {} as any,
436
+ cfg: emptyConfig,
236
437
  to: "user:ou_target",
237
438
  mediaBuffer: Buffer.from("video"),
238
439
  fileName: "reply.mp4",
@@ -253,7 +454,7 @@ describe("sendMediaFeishu msg_type routing", () => {
253
454
 
254
455
  it("omits reply_in_thread when replyInThread is false", async () => {
255
456
  await sendMediaFeishu({
256
- cfg: {} as any,
457
+ cfg: emptyConfig,
257
458
  to: "user:ou_target",
258
459
  mediaBuffer: Buffer.from("video"),
259
460
  fileName: "reply.mp4",
@@ -275,7 +476,7 @@ describe("sendMediaFeishu msg_type routing", () => {
275
476
 
276
477
  const roots = ["/allowed/workspace", "/tmp/openclaw"];
277
478
  await sendMediaFeishu({
278
- cfg: {} as any,
479
+ cfg: emptyConfig,
279
480
  to: "user:ou_target",
280
481
  mediaUrl: "/allowed/workspace/file.pdf",
281
482
  mediaLocalRoots: roots,
@@ -298,7 +499,7 @@ describe("sendMediaFeishu msg_type routing", () => {
298
499
 
299
500
  await expect(
300
501
  sendMediaFeishu({
301
- cfg: {} as any,
502
+ cfg: emptyConfig,
302
503
  to: "user:ou_target",
303
504
  mediaUrl: "https://x/img",
304
505
  fileName: "voice.opus",
@@ -322,7 +523,7 @@ describe("sendMediaFeishu msg_type routing", () => {
322
523
  });
323
524
 
324
525
  const result = await downloadImageFeishu({
325
- cfg: {} as any,
526
+ cfg: emptyConfig,
326
527
  imageKey,
327
528
  });
328
529
 
@@ -349,7 +550,7 @@ describe("sendMediaFeishu msg_type routing", () => {
349
550
  });
350
551
 
351
552
  const result = await downloadMessageResourceFeishu({
352
- cfg: {} as any,
553
+ cfg: emptyConfig,
353
554
  messageId: "om_123",
354
555
  fileKey,
355
556
  type: "image",
@@ -363,7 +564,7 @@ describe("sendMediaFeishu msg_type routing", () => {
363
564
  it("rejects invalid image keys before calling feishu api", async () => {
364
565
  await expect(
365
566
  downloadImageFeishu({
366
- cfg: {} as any,
567
+ cfg: emptyConfig,
367
568
  imageKey: "a/../../bad",
368
569
  }),
369
570
  ).rejects.toThrow("invalid image_key");
@@ -374,7 +575,7 @@ describe("sendMediaFeishu msg_type routing", () => {
374
575
  it("rejects invalid file keys before calling feishu api", async () => {
375
576
  await expect(
376
577
  downloadMessageResourceFeishu({
377
- cfg: {} as any,
578
+ cfg: emptyConfig,
378
579
  messageId: "om_123",
379
580
  fileKey: "x/../../bad",
380
581
  type: "file",
@@ -386,7 +587,7 @@ describe("sendMediaFeishu msg_type routing", () => {
386
587
 
387
588
  it("preserves Chinese filenames for file uploads", async () => {
388
589
  await sendMediaFeishu({
389
- cfg: {} as any,
590
+ cfg: emptyConfig,
390
591
  to: "user:ou_target",
391
592
  mediaBuffer: Buffer.from("doc"),
392
593
  fileName: "测试文档.pdf",
@@ -398,7 +599,7 @@ describe("sendMediaFeishu msg_type routing", () => {
398
599
 
399
600
  it("preserves ASCII filenames unchanged for file uploads", async () => {
400
601
  await sendMediaFeishu({
401
- cfg: {} as any,
602
+ cfg: emptyConfig,
402
603
  to: "user:ou_target",
403
604
  mediaBuffer: Buffer.from("doc"),
404
605
  fileName: "report-2026.pdf",
@@ -410,7 +611,7 @@ describe("sendMediaFeishu msg_type routing", () => {
410
611
 
411
612
  it("preserves special Unicode characters (em-dash, full-width brackets) in filenames", async () => {
412
613
  await sendMediaFeishu({
413
- cfg: {} as any,
614
+ cfg: emptyConfig,
414
615
  to: "user:ou_target",
415
616
  mediaBuffer: Buffer.from("doc"),
416
617
  fileName: "报告—详情(2026).md",
@@ -466,6 +667,12 @@ describe("sanitizeFileNameForUpload", () => {
466
667
  });
467
668
 
468
669
  describe("downloadMessageResourceFeishu", () => {
670
+ function httpStatusError(status: number): Error & { response: { status: number } } {
671
+ return Object.assign(new Error(`Request failed with status code ${status}`), {
672
+ response: { status },
673
+ });
674
+ }
675
+
469
676
  beforeEach(() => {
470
677
  vi.clearAllMocks();
471
678
  mockResolvedFeishuAccount();
@@ -485,7 +692,7 @@ describe("downloadMessageResourceFeishu", () => {
485
692
  // Audio/video resources must use type=file, not type=audio (#8746).
486
693
  it("forwards provided type=file for non-image resources", async () => {
487
694
  const result = await downloadMessageResourceFeishu({
488
- cfg: {} as any,
695
+ cfg: emptyConfig,
489
696
  messageId: "om_audio_msg",
490
697
  fileKey: "file_key_audio",
491
698
  type: "file",
@@ -505,7 +712,7 @@ describe("downloadMessageResourceFeishu", () => {
505
712
  messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data"));
506
713
 
507
714
  const result = await downloadMessageResourceFeishu({
508
- cfg: {} as any,
715
+ cfg: emptyConfig,
509
716
  messageId: "om_img_msg",
510
717
  fileKey: "img_key_1",
511
718
  type: "image",
@@ -520,4 +727,174 @@ describe("downloadMessageResourceFeishu", () => {
520
727
  expectMediaTimeoutClientConfigured();
521
728
  expect(result.buffer).toBeInstanceOf(Buffer);
522
729
  });
730
+
731
+ it("extracts content-type and filename metadata from download headers", async () => {
732
+ messageResourceGetMock.mockResolvedValueOnce({
733
+ data: Buffer.from("fake-video-data"),
734
+ headers: {
735
+ "content-type": "video/mp4",
736
+ "content-disposition": `attachment; filename="clip.mp4"`,
737
+ },
738
+ });
739
+
740
+ const result = await downloadMessageResourceFeishu({
741
+ cfg: emptyConfig,
742
+ messageId: "om_video_msg",
743
+ fileKey: "file_key_video",
744
+ type: "file",
745
+ });
746
+
747
+ expect(result).toMatchObject({
748
+ buffer: Buffer.from("fake-video-data"),
749
+ contentType: "video/mp4",
750
+ fileName: "clip.mp4",
751
+ });
752
+ });
753
+
754
+ it("retries file resources as media after HTTP 502", async () => {
755
+ const originalError = httpStatusError(502);
756
+ messageResourceGetMock.mockRejectedValueOnce(originalError).mockResolvedValueOnce({
757
+ data: Buffer.from("fake-ios-video-data"),
758
+ headers: {
759
+ "content-type": "video/mp4",
760
+ "content-disposition": `attachment; filename="ios-video.mp4"`,
761
+ },
762
+ });
763
+
764
+ const result = await downloadMessageResourceFeishu({
765
+ cfg: emptyConfig,
766
+ messageId: "om_ios_video_msg",
767
+ fileKey: "file_key_ios_video",
768
+ type: "file",
769
+ });
770
+
771
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
772
+ 1,
773
+ expect.objectContaining({
774
+ path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" },
775
+ params: { type: "file" },
776
+ }),
777
+ );
778
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
779
+ 2,
780
+ expect.objectContaining({
781
+ path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" },
782
+ params: { type: "media" },
783
+ }),
784
+ );
785
+ expect(result).toMatchObject({
786
+ buffer: Buffer.from("fake-ios-video-data"),
787
+ contentType: "video/mp4",
788
+ fileName: "ios-video.mp4",
789
+ });
790
+ });
791
+
792
+ it("rethrows the original HTTP 502 when the media retry fails", async () => {
793
+ const originalError = httpStatusError(502);
794
+ messageResourceGetMock
795
+ .mockRejectedValueOnce(originalError)
796
+ .mockRejectedValueOnce(new Error("media retry failed"));
797
+
798
+ await expect(
799
+ downloadMessageResourceFeishu({
800
+ cfg: emptyConfig,
801
+ messageId: "om_ios_video_msg",
802
+ fileKey: "file_key_ios_video",
803
+ type: "file",
804
+ }),
805
+ ).rejects.toBe(originalError);
806
+
807
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
808
+ 1,
809
+ expect.objectContaining({ params: { type: "file" } }),
810
+ );
811
+ expect(messageResourceGetMock).toHaveBeenNthCalledWith(
812
+ 2,
813
+ expect.objectContaining({ params: { type: "media" } }),
814
+ );
815
+ });
816
+
817
+ it("does not retry non-fallback download failures", async () => {
818
+ for (const scenario of [
819
+ { messageId: "om_image_msg", fileKey: "img_key_502", type: "image" as const, status: 502 },
820
+ { messageId: "om_file_msg", fileKey: "file_key_500", type: "file" as const, status: 500 },
821
+ ]) {
822
+ const originalError = httpStatusError(scenario.status);
823
+ messageResourceGetMock.mockClear();
824
+ messageResourceGetMock.mockRejectedValueOnce(originalError);
825
+
826
+ await expect(
827
+ downloadMessageResourceFeishu({
828
+ cfg: emptyConfig,
829
+ messageId: scenario.messageId,
830
+ fileKey: scenario.fileKey,
831
+ type: scenario.type,
832
+ }),
833
+ ).rejects.toBe(originalError);
834
+
835
+ expect(messageResourceGetMock).toHaveBeenCalledTimes(1);
836
+ expect(messageResourceGetMock).toHaveBeenCalledWith(
837
+ expect.objectContaining({
838
+ path: { message_id: scenario.messageId, file_key: scenario.fileKey },
839
+ params: { type: scenario.type },
840
+ }),
841
+ );
842
+ }
843
+ });
844
+
845
+ it("recovers CJK filenames from plain Content-Disposition headers decoded as Latin-1", async () => {
846
+ const fileName = "武汉15座山登山信息汇总.csv";
847
+ const latin1HeaderFileName = Buffer.from(fileName, "utf8").toString("latin1");
848
+ messageResourceGetMock.mockResolvedValueOnce({
849
+ data: Buffer.from("fake-file-data"),
850
+ headers: {
851
+ "content-disposition": `attachment; filename="${latin1HeaderFileName}"`,
852
+ },
853
+ });
854
+
855
+ const result = await downloadMessageResourceFeishu({
856
+ cfg: emptyConfig,
857
+ messageId: "om_file_msg",
858
+ fileKey: "file_key_csv",
859
+ type: "file",
860
+ });
861
+
862
+ expect(result.fileName).toBe(fileName);
863
+ });
864
+
865
+ it("keeps valid Latin-1 filenames from plain Content-Disposition headers unchanged", async () => {
866
+ messageResourceGetMock.mockResolvedValueOnce({
867
+ data: Buffer.from("fake-file-data"),
868
+ headers: {
869
+ "content-disposition": `attachment; filename="café-©.txt"`,
870
+ },
871
+ });
872
+
873
+ const result = await downloadMessageResourceFeishu({
874
+ cfg: emptyConfig,
875
+ messageId: "om_latin1_msg",
876
+ fileKey: "file_key_latin1",
877
+ type: "file",
878
+ });
879
+
880
+ expect(result.fileName).toBe("café-©.txt");
881
+ });
882
+
883
+ it("keeps JSON-derived file_name metadata unchanged", async () => {
884
+ const fileName = "武汉15座山登山信息汇总.csv";
885
+ const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1");
886
+ messageResourceGetMock.mockResolvedValueOnce({
887
+ data: Buffer.from("fake-file-data"),
888
+ file_name: latin1LookingFileName,
889
+ });
890
+
891
+ const result = await downloadMessageResourceFeishu({
892
+ cfg: emptyConfig,
893
+ messageId: "om_json_file_msg",
894
+ fileKey: "file_key_json",
895
+ type: "file",
896
+ });
897
+
898
+ expect(result.fileName).toBe(latin1LookingFileName);
899
+ });
523
900
  });