From 5d8f4d87676ecdecc9c108c87e5fed6a17dd4a39 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 30 Apr 2026 03:35:23 +0100 Subject: [PATCH] fix(feishu): fallback to media resource download (#73986) (thanks @alex-xuweilong) --- CHANGELOG.md | 1 + extensions/feishu/src/media.test.ts | 97 +++++++++++++++++++++++++++++ extensions/feishu/src/media.ts | 74 ++++++++++++++++++---- 3 files changed, 161 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ad6f26d04..576c3ad93e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/Feishu: retry file-typed iOS video resource downloads as `media` after a Feishu/Lark HTTP 502 and preserve the original 502 when the fallback also fails. Fixes #49855; carries forward #50164 and #73986. Thanks @alex-xuweilong. - Plugins/tokenjuice: compile the bundled plugin against tokenjuice 0.7.0's published OpenClaw host types instead of a local compatibility shim, so package contract drift fails in OpenClaw validation before release. Thanks @vincentkoc. - OAuth/secrets: ignore root-level Google OAuth `client_secret_*.json` downloads so local client-secret files do not appear as commit candidates. (#74689) Thanks @jeongdulee. - Memory: mirror `sqlite-vec` into packaged bundled-plugin runtime deps for the default memory plugin, so builtin vector search does not lose its SQLite extension after upgrading to 2026.4.27. Fixes #74692. Thanks @mozi1924. diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 3292a0bf2bf..bfba1e1fde1 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -639,6 +639,12 @@ describe("sanitizeFileNameForUpload", () => { }); describe("downloadMessageResourceFeishu", () => { + function httpStatusError(status: number): Error & { response: { status: number } } { + return Object.assign(new Error(`Request failed with status code ${status}`), { + response: { status }, + }); + } + beforeEach(() => { vi.clearAllMocks(); mockResolvedFeishuAccount(); @@ -717,6 +723,97 @@ describe("downloadMessageResourceFeishu", () => { }); }); + it("retries file resources as media after HTTP 502", async () => { + const originalError = httpStatusError(502); + messageResourceGetMock.mockRejectedValueOnce(originalError).mockResolvedValueOnce({ + data: Buffer.from("fake-ios-video-data"), + headers: { + "content-type": "video/mp4", + "content-disposition": `attachment; filename="ios-video.mp4"`, + }, + }); + + const result = await downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: "om_ios_video_msg", + fileKey: "file_key_ios_video", + type: "file", + }); + + expect(messageResourceGetMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" }, + params: { type: "file" }, + }), + ); + expect(messageResourceGetMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + path: { message_id: "om_ios_video_msg", file_key: "file_key_ios_video" }, + params: { type: "media" }, + }), + ); + expect(result).toMatchObject({ + buffer: Buffer.from("fake-ios-video-data"), + contentType: "video/mp4", + fileName: "ios-video.mp4", + }); + }); + + it("rethrows the original HTTP 502 when the media retry fails", async () => { + const originalError = httpStatusError(502); + messageResourceGetMock + .mockRejectedValueOnce(originalError) + .mockRejectedValueOnce(new Error("media retry failed")); + + await expect( + downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: "om_ios_video_msg", + fileKey: "file_key_ios_video", + type: "file", + }), + ).rejects.toBe(originalError); + + expect(messageResourceGetMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ params: { type: "file" } }), + ); + expect(messageResourceGetMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ params: { type: "media" } }), + ); + }); + + it("does not retry non-fallback download failures", async () => { + for (const scenario of [ + { messageId: "om_image_msg", fileKey: "img_key_502", type: "image" as const, status: 502 }, + { messageId: "om_file_msg", fileKey: "file_key_500", type: "file" as const, status: 500 }, + ]) { + const originalError = httpStatusError(scenario.status); + messageResourceGetMock.mockClear(); + messageResourceGetMock.mockRejectedValueOnce(originalError); + + await expect( + downloadMessageResourceFeishu({ + cfg: emptyConfig, + messageId: scenario.messageId, + fileKey: scenario.fileKey, + type: scenario.type, + }), + ).rejects.toBe(originalError); + + expect(messageResourceGetMock).toHaveBeenCalledTimes(1); + expect(messageResourceGetMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: { message_id: scenario.messageId, file_key: scenario.fileKey }, + params: { type: scenario.type }, + }), + ); + } + }); + 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"); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index ce62d5cf035..5fccab567b2 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -76,6 +76,7 @@ type FeishuDownloadResponse = | Awaited>; type FeishuHeaderMap = Record; +type FeishuMessageResourceDownloadType = "image" | "file" | "media"; function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined { if (!value) { @@ -146,6 +147,27 @@ function readHeaderValue( return undefined; } +function readHttpStatusFromError(error: unknown): number | undefined { + if (!error || typeof error !== "object") { + return undefined; + } + + const response = (error as { response?: unknown }).response; + if (response && typeof response === "object") { + const status = (response as { status?: unknown }).status; + if (typeof status === "number") { + return status; + } + } + + const status = (error as { status?: unknown }).status; + return typeof status === "number" ? status : undefined; +} + +function isHttpStatusError(error: unknown, status: number): boolean { + return readHttpStatusFromError(error) === status; +} + function containsEastAsianScript(value: string): boolean { return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value); } @@ -304,6 +326,25 @@ export async function downloadImageFeishu(params: { return { buffer, contentType: meta.contentType }; } +async function downloadMessageResourceWithType(params: { + client: ReturnType; + messageId: string; + fileKey: string; + type: FeishuMessageResourceDownloadType; +}): Promise { + const response = await params.client.im.messageResource.get({ + path: { message_id: params.messageId, file_key: params.fileKey }, + params: { type: params.type }, + }); + + const buffer = await readFeishuResponseBuffer({ + response, + tmpDirPrefix: "openclaw-feishu-resource-", + errorPrefix: "Feishu message resource download failed", + }); + return { buffer, ...extractFeishuDownloadMetadata(response) }; +} + /** * Download a message resource (file/image/audio/video) from Feishu. * Used for downloading files, audio, and video from messages. @@ -322,17 +363,28 @@ export async function downloadMessageResourceFeishu(params: { } const { client } = createConfiguredFeishuMediaClient({ cfg, accountId }); - const response = await client.im.messageResource.get({ - path: { message_id: messageId, file_key: normalizedFileKey }, - params: { type }, - }); - - const buffer = await readFeishuResponseBuffer({ - response, - tmpDirPrefix: "openclaw-feishu-resource-", - errorPrefix: "Feishu message resource download failed", - }); - return { buffer, ...extractFeishuDownloadMetadata(response) }; + try { + return await downloadMessageResourceWithType({ + client, + messageId, + fileKey: normalizedFileKey, + type, + }); + } catch (err) { + if (type !== "file" || !isHttpStatusError(err, 502)) { + throw err; + } + try { + return await downloadMessageResourceWithType({ + client, + messageId, + fileKey: normalizedFileKey, + type: "media", + }); + } catch { + throw err; + } + } } export type UploadImageResult = {