mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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