fix(feishu): fallback to media resource download (#73986) (thanks @alex-xuweilong)

This commit is contained in:
Peter Steinberger
2026-04-30 03:35:23 +01:00
parent 6421e1f36a
commit 5d8f4d8767
3 changed files with 161 additions and 11 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -76,6 +76,7 @@ type FeishuDownloadResponse =
| Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
type FeishuHeaderMap = Record<string, string | string[]>;
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<typeof createFeishuClient>;
messageId: string;
fileKey: string;
type: FeishuMessageResourceDownloadType;
}): Promise<DownloadMessageResourceResult> {
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 = {