fix(feishu): encode non-ASCII filenames in file uploads (openclaw#31328) thanks @Kay-051
Verified: - pnpm test extensions/feishu/src/media.test.ts Co-authored-by: Kay-051 <210470990+Kay-051@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -36,7 +36,12 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
|
||||
import {
|
||||
downloadImageFeishu,
|
||||
downloadMessageResourceFeishu,
|
||||
sanitizeFileNameForUpload,
|
||||
sendMediaFeishu,
|
||||
} from "./media.js";
|
||||
|
||||
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
@@ -334,6 +339,104 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
|
||||
expect(messageResourceGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("encodes Chinese filenames for file uploads", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("doc"),
|
||||
fileName: "测试文档.pdf",
|
||||
});
|
||||
|
||||
const createCall = fileCreateMock.mock.calls[0][0];
|
||||
expect(createCall.data.file_name).not.toBe("测试文档.pdf");
|
||||
expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
|
||||
});
|
||||
|
||||
it("preserves ASCII filenames unchanged for file uploads", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
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("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("doc"),
|
||||
fileName: "报告—详情(2026).md",
|
||||
});
|
||||
|
||||
const createCall = fileCreateMock.mock.calls[0][0];
|
||||
expect(createCall.data.file_name).toMatch(/\.md$/);
|
||||
expect(createCall.data.file_name).not.toContain("—");
|
||||
expect(createCall.data.file_name).not.toContain("(");
|
||||
});
|
||||
});
|
||||
|
||||
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("encodes Chinese characters in basename, preserves extension", () => {
|
||||
const result = sanitizeFileNameForUpload("测试文件.md");
|
||||
expect(result).toBe(encodeURIComponent("测试文件") + ".md");
|
||||
expect(result).toMatch(/\.md$/);
|
||||
});
|
||||
|
||||
it("encodes em-dash and full-width brackets", () => {
|
||||
const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
|
||||
expect(result).toMatch(/\.pdf$/);
|
||||
expect(result).not.toContain("—");
|
||||
expect(result).not.toContain("(");
|
||||
expect(result).not.toContain(")");
|
||||
});
|
||||
|
||||
it("encodes single quotes and parentheses per RFC 5987", () => {
|
||||
const result = sanitizeFileNameForUpload("文件'(test).txt");
|
||||
expect(result).toContain("%27");
|
||||
expect(result).toContain("%28");
|
||||
expect(result).toContain("%29");
|
||||
expect(result).toMatch(/\.txt$/);
|
||||
});
|
||||
|
||||
it("handles filenames without extension", () => {
|
||||
const result = sanitizeFileNameForUpload("测试文件");
|
||||
expect(result).toBe(encodeURIComponent("测试文件"));
|
||||
});
|
||||
|
||||
it("handles mixed ASCII and non-ASCII", () => {
|
||||
const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
|
||||
expect(result).toMatch(/\.xlsx$/);
|
||||
expect(result).not.toContain("报告");
|
||||
});
|
||||
|
||||
it("encodes non-ASCII extensions", () => {
|
||||
const result = sanitizeFileNameForUpload("报告.文档");
|
||||
expect(result).toContain("%E6%96%87%E6%A1%A3");
|
||||
expect(result).not.toContain("文档");
|
||||
});
|
||||
|
||||
it("encodes emoji filenames", () => {
|
||||
const result = sanitizeFileNameForUpload("report_😀.txt");
|
||||
expect(result).toContain("%F0%9F%98%80");
|
||||
expect(result).toMatch(/\.txt$/);
|
||||
});
|
||||
|
||||
it("encodes mixed ASCII and non-ASCII extensions", () => {
|
||||
const result = sanitizeFileNameForUpload("notes_总结.v测试");
|
||||
expect(result).toContain("notes_");
|
||||
expect(result).toContain("%E6%B5%8B%E8%AF%95");
|
||||
expect(result).not.toContain("测试");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMessageResourceFeishu", () => {
|
||||
|
||||
@@ -207,6 +207,24 @@ export async function uploadImageFeishu(params: {
|
||||
return { imageKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a filename for safe use in Feishu multipart/form-data uploads.
|
||||
* Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
|
||||
* the upload to silently fail when passed raw through the SDK's form-data
|
||||
* serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
|
||||
* Feishu's server decodes and preserves the original display name.
|
||||
*/
|
||||
export function sanitizeFileNameForUpload(fileName: string): string {
|
||||
const ASCII_ONLY = /^[\x20-\x7E]+$/;
|
||||
if (ASCII_ONLY.test(fileName)) {
|
||||
return fileName;
|
||||
}
|
||||
return encodeURIComponent(fileName)
|
||||
.replace(/'/g, "%27")
|
||||
.replace(/\(/g, "%28")
|
||||
.replace(/\)/g, "%29");
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to Feishu and get a file_key for sending.
|
||||
* Max file size: 30MB
|
||||
@@ -232,10 +250,12 @@ export async function uploadFileFeishu(params: {
|
||||
// See: https://github.com/larksuite/node-sdk/issues/121
|
||||
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
|
||||
|
||||
const safeFileName = sanitizeFileNameForUpload(fileName);
|
||||
|
||||
const response = await client.im.file.create({
|
||||
data: {
|
||||
file_type: fileType,
|
||||
file_name: fileName,
|
||||
file_name: safeFileName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
|
||||
file: fileData as any,
|
||||
...(duration !== undefined && { duration }),
|
||||
|
||||
Reference in New Issue
Block a user