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:
12
2026-03-03 06:56:57 +08:00
committed by GitHub
parent f431f20c48
commit 905c3357eb
3 changed files with 126 additions and 2 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.

View File

@@ -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", () => {

View File

@@ -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 }),