diff --git a/CHANGELOG.md b/CHANGELOG.md index d35225153f4..d420e218ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Memory/Dreaming: retry Dream Diary once with the session default when a configured dreaming model is unavailable, while leaving subagent trust and allowlist errors visible instead of silently masking configuration problems. Refs #67409 and #69209. Thanks @Ghiggins18 and @everySympathy. +- Feishu/inbound files: recover CJK filenames from plain `Content-Disposition: filename=` download headers when Feishu exposes UTF-8 bytes through Latin-1 header decoding, while leaving valid Latin-1 and JSON-derived names unchanged. (#48578, #50435, #59431) Thanks @alex-xuweilong, @lishuaigit, and @DoChaoing. ## 2026.4.27 diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 9f572b21076..3292a0bf2bf 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -716,4 +716,60 @@ describe("downloadMessageResourceFeishu", () => { fileName: "clip.mp4", }); }); + + it("recovers CJK filenames from plain Content-Disposition headers decoded as Latin-1", async () => { + const fileName = "武汉15座山登山信息汇总.csv"; + const latin1HeaderFileName = Buffer.from(fileName, "utf8").toString("latin1"); + messageResourceGetMock.mockResolvedValueOnce({ + data: Buffer.from("fake-file-data"), + headers: { + "content-disposition": `attachment; filename="${latin1HeaderFileName}"`, + }, + }); + + const result = await downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: "om_file_msg", + fileKey: "file_key_csv", + type: "file", + }); + + expect(result.fileName).toBe(fileName); + }); + + it("keeps valid Latin-1 filenames from plain Content-Disposition headers unchanged", async () => { + messageResourceGetMock.mockResolvedValueOnce({ + data: Buffer.from("fake-file-data"), + headers: { + "content-disposition": `attachment; filename="café-©.txt"`, + }, + }); + + const result = await downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: "om_latin1_msg", + fileKey: "file_key_latin1", + type: "file", + }); + + expect(result.fileName).toBe("café-©.txt"); + }); + + it("keeps JSON-derived file_name metadata unchanged", async () => { + const fileName = "武汉15座山登山信息汇总.csv"; + const latin1LookingFileName = Buffer.from(fileName, "utf8").toString("latin1"); + messageResourceGetMock.mockResolvedValueOnce({ + data: Buffer.from("fake-file-data"), + file_name: latin1LookingFileName, + }); + + const result = await downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: "om_json_file_msg", + fileKey: "file_key_json", + type: "file", + }); + + expect(result.fileName).toBe(latin1LookingFileName); + }); }); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 529dba5b616..ce62d5cf035 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -146,6 +146,18 @@ function readHeaderValue( return undefined; } +function containsEastAsianScript(value: string): boolean { + return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value); +} + +function recoverUtf8FileNameFromLatin1Header(value: string): string { + const recovered = Buffer.from(value, "latin1").toString("utf8"); + if (recovered !== value && !recovered.includes("\uFFFD") && containsEastAsianScript(recovered)) { + return recovered; + } + return value; +} + function decodeDispositionFileName(value: string): string | undefined { const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); if (utf8Match?.[1]) { @@ -157,7 +169,8 @@ function decodeDispositionFileName(value: string): string | undefined { } const plainMatch = value.match(/filename="?([^";]+)"?/i); - return plainMatch?.[1]?.trim(); + const plainFileName = plainMatch?.[1]?.trim(); + return plainFileName ? recoverUtf8FileNameFromLatin1Header(plainFileName) : undefined; } function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {