mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(feishu): fallback to media resource download (#73986) (thanks @alex-xuweilong)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user