From 80abb5ab9838b442ca52692e6dd6098f92c07f19 Mon Sep 17 00:00:00 2001 From: yinghaosang Date: Sun, 15 Feb 2026 17:20:55 +0800 Subject: [PATCH] fix(telegram): stop dropping voice messages on getFile network errors (#16136) (#16154) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: fbcd7849e4607d2d1c5038e05b1ae62080e1db7f Co-authored-by: yinghaosang <261132136+yinghaosang@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + .../bot/delivery.resolve-media-retry.test.ts | 137 ++++++++++++++++++ src/telegram/bot/delivery.ts | 20 ++- 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/telegram/bot/delivery.resolve-media-retry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aad7413b3..6adc98933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. - CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. +- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. ## 2026.2.14 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 000000000..79ab06bdc --- /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 732227ed0..34a78b6d9 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"); }