import fs from
"node:fs/promises" ;
import path from
"node:path" ;
import { resolvePreferredOpenClawTmpDir } from
"openclaw/plugin-sdk/temp-path" ;
import { beforeAll, beforeEach, describe, expect, it, vi } from
"vitest" ;
import type { ClawdbotConfig } from
"../runtime-api.js" ;
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageCreateMock = vi.hoisted(() => vi.fn());
const imageGetMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageResourceGetMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
const FEISHU_MEDIA_HTTP_TIMEOUT_MS =
120 _
000 ;
const emptyConfig: ClawdbotConfig = {};
vi.mock(
"./client.js" , () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock(
"./accounts.js" , () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
}));
vi.mock(
"./targets.js" , () => ({
normalizeFeishuTarget: normalizeFeishuTargetMock,
resolveReceiveIdType: resolveReceiveIdTypeMock,
}));
vi.mock(
"./runtime.js" , () => ({
getFeishuRuntime: () => ({
media: {
loadWebMedia: loadWebMediaMock,
},
}),
}));
vi.mock(
"../../../src/channels/plugins/bundled.js" , () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
let downloadImageFeishu:
typeof import (
"./media.js" ).downloadImageFeishu;
let downloadMessageResourceFeishu:
typeof import (
"./media.js" ).downloadMessageResou
rceFeishu;
let sanitizeFileNameForUpload: typeof import ("./media.js" ).sanitizeFileNameForUpload;
let sendMediaFeishu: typeof import ("./media.js" ).sendMediaFeishu;
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
expect(pathValue).not.toContain(key);
expect(pathValue).not.toContain(".." );
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(pathValue);
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false );
}
function expectMediaTimeoutClientConfigured(): void {
expect(createFeishuClientMock).toHaveBeenCalledWith(
expect.objectContaining({
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
}),
);
}
function mockResolvedFeishuAccount() {
resolveFeishuAccountMock.mockReturnValue({
configured: true ,
accountId: "main" ,
config: {},
appId: "app_id" ,
appSecret: "app_secret" ,
domain: "feishu" ,
});
}
describe("sendMediaFeishu msg_type routing" , () => {
beforeAll(async () => {
({
downloadImageFeishu,
downloadMessageResourceFeishu,
sanitizeFileNameForUpload,
sendMediaFeishu,
} = await import ("./media.js" ));
});
beforeEach(() => {
vi.clearAllMocks();
mockResolvedFeishuAccount();
normalizeFeishuTargetMock.mockReturnValue("ou_target" );
resolveReceiveIdTypeMock.mockReturnValue("open_id" );
createFeishuClientMock.mockReturnValue({
im: {
file: {
create: fileCreateMock,
},
image: {
create: imageCreateMock,
get: imageGetMock,
},
message: {
create: messageCreateMock,
reply: messageReplyMock,
},
messageResource: {
get: messageResourceGetMock,
},
},
});
fileCreateMock.mockResolvedValue({
code: 0 ,
data: { file_key: "file_key_1" },
});
imageCreateMock.mockResolvedValue({
code: 0 ,
data: { image_key: "image_key_1" },
});
messageCreateMock.mockResolvedValue({
code: 0 ,
data: { message_id: "msg_1" },
});
messageReplyMock.mockResolvedValue({
code: 0 ,
data: { message_id: "reply_1" },
});
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("remote-audio" ),
fileName: "remote.opus" ,
kind: "audio" ,
contentType: "audio/ogg" ,
});
imageGetMock.mockResolvedValue(Buffer.from("image-bytes" ));
messageResourceGetMock.mockResolvedValue(Buffer.from("resource-bytes" ));
});
it("uses msg_type=media for mp4 video" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("video" ),
fileName: "clip.mp4" ,
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=audio for opus" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("audio" ),
fileName: "voice.opus" ,
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "opus" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "audio" }),
}),
);
});
it("uses msg_type=file for documents" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("doc" ),
fileName: "paper.pdf" ,
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "pdf" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("uses msg_type=media for remote mp4 content even when the filename is generic" , async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("remote-video" ),
fileName: "download" ,
kind: "video" ,
contentType: "video/mp4" ,
});
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaUrl: "https://example.com/video ",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("falls back to generic file for unsupported audio formats" , async () => {
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("remote-mp3" ),
fileName: "song.mp3" ,
kind: "audio" ,
contentType: "audio/mpeg" ,
});
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaUrl: "https://example.com/song.mp3 ",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "stream" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("configures the media client timeout for image uploads" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("image" ),
fileName: "photo.png" ,
});
expectMediaTimeoutClientConfigured();
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "image" }),
}),
);
});
it("uses msg_type=media when replying with mp4" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("video" ),
fileName: "reply.mp4" ,
replyToMessageId: "om_parent" ,
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({ msg_type: "media" }),
}),
);
expect(messageCreateMock).not.toHaveBeenCalled();
});
it("passes reply_in_thread when replyInThread is true" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("video" ),
fileName: "reply.mp4" ,
replyToMessageId: "om_parent" ,
replyInThread: true ,
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({
msg_type: "media" ,
reply_in_thread: true ,
}),
}),
);
});
it("omits reply_in_thread when replyInThread is false" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("video" ),
fileName: "reply.mp4" ,
replyToMessageId: "om_parent" ,
replyInThread: false ,
});
const callData = messageReplyMock.mock.calls[0 ][0 ].data;
expect(callData).not.toHaveProperty("reply_in_thread" );
});
it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)" , async () => {
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("local-file" ),
fileName: "doc.pdf" ,
kind: "document" ,
contentType: "application/pdf" ,
});
const roots = ["/allowed/workspace" , "/tmp/openclaw" ];
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaUrl: "/allowed/workspace/file.pdf" ,
mediaLocalRoots: roots,
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"/allowed/workspace/file.pdf" ,
expect.objectContaining({
maxBytes: expect.any(Number),
optimizeImages: false ,
localRoots: roots,
}),
);
});
it("fails closed when media URL fetch is blocked" , async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address" ),
);
await expect(
sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaUrl: "https://x/img ",
fileName: "voice.opus" ,
}),
).rejects.toThrow(/private \/internal/i);
expect(fileCreateMock).not.toHaveBeenCalled();
expect(messageCreateMock).not.toHaveBeenCalled();
expect(messageReplyMock).not.toHaveBeenCalled();
});
it("uses isolated temp paths for image downloads" , async () => {
const imageKey = "img_v3_01abc123" ;
let capturedPath: string | undefined;
imageGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("image-data" ));
},
});
const result = await downloadImageFeishu({
cfg: emptyConfig,
imageKey,
});
expect(imageGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { image_key: imageKey },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toEqual(Buffer.from("image-data" ));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
});
it("uses isolated temp paths for message resource downloads" , async () => {
const fileKey = "file_v3_01abc123" ;
let capturedPath: string | undefined;
messageResourceGetMock.mockResolvedValueOnce({
writeFile: async (tmpPath: string) => {
capturedPath = tmpPath;
await fs.writeFile(tmpPath, Buffer.from("resource-data" ));
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123" ,
fileKey,
type: "image" ,
});
expect(result.buffer).toEqual(Buffer.from("resource-data" ));
expect(capturedPath).toBeDefined();
expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
});
it("rejects invalid image keys before calling feishu api" , async () => {
await expect(
downloadImageFeishu({
cfg: emptyConfig,
imageKey: "a/../../bad" ,
}),
).rejects.toThrow("invalid image_key" );
expect(imageGetMock).not.toHaveBeenCalled();
});
it("rejects invalid file keys before calling feishu api" , async () => {
await expect(
downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_123" ,
fileKey: "x/../../bad" ,
type: "file" ,
}),
).rejects.toThrow("invalid file_key" );
expect(messageResourceGetMock).not.toHaveBeenCalled();
});
it("preserves Chinese filenames for file uploads" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("doc" ),
fileName: "测试文档.pdf" ,
});
const createCall = fileCreateMock.mock.calls[0 ][0 ];
expect(createCall.data.file_name).toBe("测试文档.pdf" );
});
it("preserves ASCII filenames unchanged for file uploads" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("doc" ),
fileName: "report-2026.pdf" ,
});
const createCall = fileCreateMock.mock.calls[0 ][0 ];
expect(createCall.data.file_name).toBe("report-2026.pdf" );
});
it("preserves special Unicode characters (em-dash, full-width brackets) in filenames" , async () => {
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target" ,
mediaBuffer: Buffer.from("doc" ),
fileName: "报告—详情(2026).md" ,
});
const createCall = fileCreateMock.mock.calls[0 ][0 ];
expect(createCall.data.file_name).toBe("报告—详情(2026).md" );
});
});
describe("sanitizeFileNameForUpload" , () => {
it("returns ASCII filenames unchanged" , () => {
expect(sanitizeFileNameForUpload("report.pdf" )).toBe("report.pdf" );
expect(sanitizeFileNameForUpload("my-file_v2.txt" )).toBe("my-file_v2.txt" );
});
it("preserves Chinese characters" , () => {
expect(sanitizeFileNameForUpload("测试文件.md" )).toBe("测试文件.md" );
expect(sanitizeFileNameForUpload("武汉15座山登山信息汇总.csv" )).toBe(
"武汉15座山登山信息汇总.csv" ,
);
});
it("preserves em-dash and full-width brackets" , () => {
expect(sanitizeFileNameForUpload("文件—说明(v2).pdf" )).toBe("文件—说明(v2).pdf" );
});
it("preserves single quotes and parentheses" , () => {
expect(sanitizeFileNameForUpload("文件'(test).txt" )).toBe("文件'(test).txt" );
});
it("preserves filenames without extension" , () => {
expect(sanitizeFileNameForUpload("测试文件" )).toBe("测试文件" );
});
it("preserves mixed ASCII and non-ASCII" , () => {
expect(sanitizeFileNameForUpload("Report_报告_2026.xlsx" )).toBe("Report_报告_2026.xlsx" );
});
it("preserves emoji filenames" , () => {
expect(sanitizeFileNameForUpload("report_.txt" )).toBe("report_.txt" );
});
it("strips control characters" , () => {
expect(sanitizeFileNameForUpload("bad\x00file.txt" )).toBe("bad_file.txt" );
expect(sanitizeFileNameForUpload("inject\r\nheader.txt" )).toBe("inject__header.txt" );
});
it("strips quotes and backslashes to prevent header injection" , () => {
expect(sanitizeFileNameForUpload('file"name.txt' )).toBe("file_name.txt" );
expect(sanitizeFileNameForUpload("file\\name.txt" )).toBe("file_name.txt" );
});
});
describe("downloadMessageResourceFeishu" , () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolvedFeishuAccount();
createFeishuClientMock.mockReturnValue({
im: {
messageResource: {
get: messageResourceGetMock,
},
},
});
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-audio-data" ));
});
// Regression: Feishu API only supports type=image|file for messageResource.get.
// Audio/video resources must use type=file, not type=audio (#8746).
it("forwards provided type=file for non-image resources" , async () => {
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_audio_msg" ,
fileKey: "file_key_audio" ,
type: "file" ,
});
expect(messageResourceGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_audio_msg" , file_key: "file_key_audio" },
params: { type: "file" },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
});
it("image uses type=image" , async () => {
messageResourceGetMock.mockResolvedValue(Buffer.from("fake-image-data" ));
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_img_msg" ,
fileKey: "img_key_1" ,
type: "image" ,
});
expect(messageResourceGetMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_img_msg" , file_key: "img_key_1" },
params: { type: "image" },
}),
);
expectMediaTimeoutClientConfigured();
expect(result.buffer).toBeInstanceOf(Buffer);
});
it("extracts content-type and filename metadata from download headers" , async () => {
messageResourceGetMock.mockResolvedValueOnce({
data: Buffer.from("fake-video-data" ),
headers: {
"content-type" : "video/mp4" ,
"content-disposition" : `attachment; filename="clip.mp4" `,
},
});
const result = await downloadMessageResourceFeishu({
cfg: emptyConfig,
messageId: "om_video_msg" ,
fileKey: "file_key_video" ,
type: "file" ,
});
expect(result).toMatchObject({
buffer: Buffer.from("fake-video-data" ),
contentType: "video/mp4" ,
fileName: "clip.mp4" ,
});
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-07)
¤
*© Formatika GbR, Deutschland