@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.
- 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 +1653 -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 +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -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 +365 -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 +32 -94
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- 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 +375 -26
- package/src/media.ts +434 -88
- 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.reply-once.lifecycle.test-support.ts +190 -0
- 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 +77 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +399 -86
- 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 +479 -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",
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
});
|