fix(telegram): stop dropping voice messages on getFile network errors (#16136) (#16154)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: fbcd7849e4
Co-authored-by: yinghaosang <261132136+yinghaosang@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
yinghaosang
2026-02-15 17:20:55 +08:00
committed by GitHub
parent 2fc479b427
commit 80abb5ab98
3 changed files with 157 additions and 1 deletions

View File

@@ -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 `<media:audio>` 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

View File

@@ -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<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" };
}
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: "<media:audio>" }),
);
});
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();
});
});

View File

@@ -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. <media:audio>) 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");
}