Files
openclaw/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts
Bob ea15819ecf ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-17 17:27:52 +01:00

483 lines
15 KiB
TypeScript

import type { Message } from "@grammyjs/types";
import { GrammyError } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramContext } from "./types.js";
const saveMediaBuffer = vi.fn();
const fetchRemoteMedia = vi.fn();
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
};
});
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
return {
...actual,
logVerbose: () => {},
warn: (s: string) => s,
danger: (s: string) => s,
};
});
vi.mock("../sticker-cache.js", () => ({
cacheSticker: () => {},
getCachedSticker: () => null,
}));
let resolveMedia: typeof import("./delivery.js").resolveMedia;
const MAX_MEDIA_BYTES = 10_000_000;
const BOT_TOKEN = "tok123";
function makeCtx(
mediaField: "voice" | "audio" | "photo" | "video" | "document" | "animation" | "sticker",
getFile: TelegramContext["getFile"],
opts?: { file_name?: string },
): TelegramContext {
const msg: Record<string, unknown> = {
message_id: 1,
date: 0,
chat: { id: 1, type: "private" },
};
if (mediaField === "voice") {
msg.voice = { file_id: "v1", duration: 5, file_unique_id: "u1" };
}
if (mediaField === "audio") {
msg.audio = {
file_id: "a1",
duration: 5,
file_unique_id: "u2",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "photo") {
msg.photo = [{ file_id: "p1", width: 100, height: 100 }];
}
if (mediaField === "video") {
msg.video = {
file_id: "vid1",
duration: 10,
file_unique_id: "u3",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "document") {
msg.document = {
file_id: "d1",
file_unique_id: "u4",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "animation") {
msg.animation = {
file_id: "an1",
duration: 3,
file_unique_id: "u5",
width: 200,
height: 200,
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "sticker") {
msg.sticker = {
file_id: "stk1",
file_unique_id: "ustk1",
type: "regular",
width: 512,
height: 512,
is_animated: false,
is_video: false,
};
}
return {
message: msg as unknown as Message,
me: {
id: 1,
is_bot: true,
first_name: "bot",
username: "bot",
} as unknown as TelegramContext["me"],
getFile,
};
}
function setupTransientGetFileRetry() {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio"),
contentType: "audio/ogg",
fileName: "file_0.oga",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.oga",
contentType: "audio/ogg",
});
return getFile;
}
function mockPdfFetchAndSave(fileName: string | undefined) {
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName,
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
}
function createFileTooBigError(): Error {
return new Error("GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)");
}
async function expectTransientGetFileRetrySuccess() {
const getFile = setupTransientGetFileRetry();
const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`,
ssrfPolicy: {
allowRfc2544BenchmarkRange: true,
allowedHostnames: ["api.telegram.org"],
},
}),
);
return result;
}
async function flushRetryTimers() {
await vi.runAllTimersAsync();
}
describe("resolveMedia getFile retry", () => {
beforeEach(async () => {
vi.resetModules();
({ resolveMedia } = await import("./delivery.js"));
vi.useFakeTimers();
fetchRemoteMedia.mockReset();
saveMediaBuffer.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("retries getFile on transient failure and succeeds on second attempt", async () => {
const result = await expectTransientGetFileRetrySuccess();
expect(result).toEqual(
expect.objectContaining({ path: "/tmp/file_0.oga", placeholder: "<media:audio>" }),
);
});
it.each(["voice", "photo", "video"] as const)(
"returns null for %s when getFile exhausts retries so message is not dropped",
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const promise = resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
},
);
it("does not catch errors from fetchRemoteMedia (only getFile is retried)", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" });
fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed"));
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("download failed");
expect(getFile).toHaveBeenCalledTimes(1);
});
it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => {
// Simulate Telegram Bot API error when file exceeds 20MB limit.
const fileTooBigError = createFileTooBigError();
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
// Should NOT retry - "file is too big" is a permanent error, not transient.
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it("does not retry 'file is too big' GrammyError instances and returns null", async () => {
const fileTooBigError = new GrammyError(
"Call to 'getFile' failed!",
{ ok: false, error_code: 400, description: "Bad Request: file is too big" },
"getFile",
{},
);
const getFile = vi.fn().mockRejectedValue(fileTooBigError);
const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
});
it.each(["audio", "voice"] as const)(
"returns null for %s when file is too big",
async (mediaField) => {
const getFile = vi.fn().mockRejectedValue(createFileTooBigError());
const result = await resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
expect(getFile).toHaveBeenCalledTimes(1);
expect(result).toBeNull();
},
);
it("throws when getFile returns no file_path", async () => {
const getFile = vi.fn().mockResolvedValue({});
await expect(
resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN),
).rejects.toThrow("Telegram getFile returned no file_path");
expect(getFile).toHaveBeenCalledTimes(1);
});
it("still retries transient errors even after encountering file too big in different call", async () => {
const result = await expectTransientGetFileRetrySuccess();
// Should retry transient errors.
expect(result).not.toBeNull();
});
it("retries getFile for stickers on transient failure", async () => {
const getFile = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'getFile' failed!"))
.mockResolvedValueOnce({ file_path: "stickers/file_0.webp" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.webp",
contentType: "image/webp",
});
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(2);
expect(result).toEqual(
expect.objectContaining({ path: "/tmp/file_0.webp", placeholder: "<media:sticker>" }),
);
});
it("returns null for sticker when getFile exhausts retries", async () => {
const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!"));
const ctx = makeCtx("sticker", getFile);
const promise = resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
await flushRetryTimers();
const result = await promise;
expect(getFile).toHaveBeenCalledTimes(3);
expect(result).toBeNull();
});
it("uses caller-provided fetch impl for file downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
const result = await resolveMedia(
makeCtx("document", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
fetchImpl: callerFetch,
}),
);
});
it("uses caller-provided fetch impl for sticker downloads", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "stickers/file_0.webp" });
const callerFetch = vi.fn() as unknown as typeof fetch;
const callerTransport = { fetch: callerFetch, sourceFetch: callerFetch };
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("sticker-data"),
contentType: "image/webp",
fileName: "file_0.webp",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_0.webp",
contentType: "image/webp",
});
const result = await resolveMedia(
makeCtx("sticker", getFile),
MAX_MEDIA_BYTES,
BOT_TOKEN,
callerTransport,
);
expect(result).not.toBeNull();
expect(fetchRemoteMedia).toHaveBeenCalledWith(
expect.objectContaining({
fetchImpl: callerFetch,
}),
);
});
});
describe("resolveMedia original filename preservation", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockClear();
saveMediaBuffer.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/business-plan---uuid.pdf",
contentType: "application/pdf",
});
const ctx = makeCtx("document", getFile, { file_name: "business-plan.pdf" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"business-plan.pdf",
);
expect(result).toEqual(expect.objectContaining({ path: "/tmp/business-plan---uuid.pdf" }));
});
it("passes audio.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio-data"),
contentType: "audio/mpeg",
fileName: "file_99.mp3",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/my-song---uuid.mp3",
contentType: "audio/mpeg",
});
const ctx = makeCtx("audio", getFile, { file_name: "my-song.mp3" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"audio/mpeg",
"inbound",
MAX_MEDIA_BYTES,
"my-song.mp3",
);
expect(result).not.toBeNull();
});
it("passes video.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("video-data"),
contentType: "video/mp4",
fileName: "file_55.mp4",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/presentation---uuid.mp4",
contentType: "video/mp4",
});
const ctx = makeCtx("video", getFile, { file_name: "presentation.mp4" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
MAX_MEDIA_BYTES,
"presentation.mp4",
);
expect(result).not.toBeNull();
});
it("falls back to fetched.fileName when telegram file_name is absent", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave("file_42.pdf");
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"file_42.pdf",
);
expect(result).not.toBeNull();
});
it("falls back to filePath when neither telegram nor fetched fileName is available", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
mockPdfFetchAndSave(undefined);
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"documents/file_42.pdf",
);
expect(result).not.toBeNull();
});
});