@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.
- package/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1827 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +95 -7
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +778 -775
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1253 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +135 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +406 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +63 -1
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +33 -95
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +116 -20
- package/src/directory.ts +60 -92
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +403 -26
- package/src/media.ts +509 -132
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +218 -312
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +108 -48
- package/src/monitor.startup.test.ts +11 -9
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +220 -60
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +65 -7
- package/src/monitor.webhook-security.test.ts +122 -0
- package/src/monitor.webhook.test-helpers.ts +44 -26
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +616 -37
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +14 -9
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +4 -34
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +660 -29
- package/src/reply-dispatcher.ts +407 -154
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +105 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +414 -95
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +453 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- 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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
});
|