diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts new file mode 100644 index 00000000000..79ab06bdc44 --- /dev/null +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -0,0 +1,137 @@ +import type { Message } from "@grammyjs/types"; +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("../../media/store.js", () => ({ + saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), +})); + +vi.mock("../../media/fetch.js", () => ({ + fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), +})); + +vi.mock("../../globals.js", () => ({ + danger: (s: string) => s, + logVerbose: () => {}, +})); + +vi.mock("../sticker-cache.js", () => ({ + cacheSticker: () => {}, + getCachedSticker: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +const { resolveMedia } = await import("./delivery.js"); + +function makeCtx( + mediaField: "voice" | "audio" | "photo" | "video", + getFile: TelegramContext["getFile"], +): TelegramContext { + const msg: Record = { + 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" }; + } + 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" }; + } + return { + message: msg as Message, + me: { id: 1, is_bot: true, first_name: "bot", username: "bot" }, + getFile, + }; +} + +describe("resolveMedia getFile retry", () => { + beforeEach(() => { + vi.useFakeTimers(); + fetchRemoteMedia.mockReset(); + saveMediaBuffer.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries getFile on transient failure and succeeds on second attempt", async () => { + 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", + }); + + const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + await vi.advanceTimersByTimeAsync(5000); + const result = await promise; + + expect(getFile).toHaveBeenCalledTimes(2); + expect(result).toEqual( + expect.objectContaining({ path: "/tmp/file_0.oga", placeholder: "" }), + ); + }); + + it("returns null when all getFile retries fail so message is not dropped", async () => { + const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!")); + + const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + await vi.advanceTimersByTimeAsync(15000); + 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), 10_000_000, "tok123")).rejects.toThrow( + "download failed", + ); + + expect(getFile).toHaveBeenCalledTimes(1); + }); + + it("returns null for photo when getFile exhausts retries", async () => { + const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error")); + + const promise = resolveMedia(makeCtx("photo", getFile), 10_000_000, "tok123"); + await vi.advanceTimersByTimeAsync(15000); + const result = await promise; + + expect(getFile).toHaveBeenCalledTimes(3); + expect(result).toBeNull(); + }); + + it("returns null for video when getFile exhausts retries", async () => { + const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error")); + + const promise = resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123"); + await vi.advanceTimersByTimeAsync(15000); + const result = await promise; + + expect(getFile).toHaveBeenCalledTimes(3); + expect(result).toBeNull(); + }); +}); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 732227ed023..34a78b6d98d 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -7,6 +7,7 @@ import type { StickerMetadata, TelegramContext } from "./types.js"; import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; import { danger, logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { retryAsync } from "../../infra/retry.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { isGifMedia } from "../../media/mime.js"; @@ -402,7 +403,24 @@ export async function resolveMedia( if (!m?.file_id) { return null; } - const file = await ctx.getFile(); + + let file: { file_path?: string }; + try { + file = await retryAsync(() => ctx.getFile(), { + attempts: 3, + minDelayMs: 1000, + maxDelayMs: 4000, + jitter: 0.2, + label: "telegram:getFile", + onRetry: ({ attempt, maxAttempts }) => + logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`), + }); + } catch (err) { + // All retries exhausted — return null so the message still reaches the agent + // with a type-based placeholder (e.g. ) instead of being dropped. + logVerbose(`telegram: getFile failed after retries: ${String(err)}`); + return null; + } if (!file.file_path) { throw new Error("Telegram getFile returned no file_path"); }