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