From 4a9c5f7af7c136952bf5dd396acee7f9f4e3c5d1 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 22 Feb 2026 20:51:15 +0530 Subject: [PATCH] fix: tighten telegram media-group error handling --- CHANGELOG.md | 1 + src/telegram/bot-handlers.ts | 18 ++++- src/telegram/bot.create-telegram-bot.test.ts | 83 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7fa5b6e02..8ee39602d5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,7 @@ Docs: https://docs.openclaw.ai - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. - iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Telegram: keep media-group processing resilient by skipping recoverable per-item download failures while still failing loud on non-recoverable media errors. (#20598) thanks @mcaxtr. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. - Telegram/DM routing: prevent DM inbound origin metadata from leaking into main-session `lastRoute` updates and normalize DM `lastRoute.to` to provider-prefixed `telegram:`. (#19491) thanks @guirguispierre. diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 0e81bf36aa4..b625eb1630f 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -20,6 +20,7 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js"; import { danger, logVerbose, warn } from "../globals.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; +import { MediaFetchError } from "../media/fetch.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; @@ -61,6 +62,15 @@ import { import { buildInlineKeyboard } from "./send.js"; import { wasSentByBot } from "./sent-message-cache.js"; +function isMediaSizeLimitError(err: unknown): boolean { + const errMsg = String(err); + return errMsg.includes("exceeds") && errMsg.includes("MB limit"); +} + +function isRecoverableMediaGroupError(err: unknown): boolean { + return err instanceof MediaFetchError || isMediaSizeLimitError(err); +} + export const registerTelegramHandlers = ({ cfg, accountId, @@ -274,6 +284,9 @@ export const registerTelegramHandlers = ({ try { media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); } catch (mediaErr) { + if (!isRecoverableMediaGroupError(mediaErr)) { + throw mediaErr; + } runtime.log?.( warn(`media group: skipping photo that failed to fetch: ${String(mediaErr)}`), ); @@ -671,8 +684,7 @@ export const registerTelegramHandlers = ({ try { media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch); } catch (mediaErr) { - const errMsg = String(mediaErr); - if (errMsg.includes("exceeds") && errMsg.includes("MB limit")) { + if (isMediaSizeLimitError(mediaErr)) { if (sendOversizeWarning) { const limitMb = Math.round(mediaMaxBytes / (1024 * 1024)); await withTelegramApiErrorLogging({ @@ -684,7 +696,7 @@ export const registerTelegramHandlers = ({ }), }).catch(() => {}); } - logger.warn({ chatId, error: errMsg }, oversizeLogMessage); + logger.warn({ chatId, error: String(mediaErr) }, oversizeLogMessage); return; } throw mediaErr; diff --git a/src/telegram/bot.create-telegram-bot.test.ts b/src/telegram/bot.create-telegram-bot.test.ts index 1826b69889c..a76a8bb0b16 100644 --- a/src/telegram/bot.create-telegram-bot.test.ts +++ b/src/telegram/bot.create-telegram-bot.test.ts @@ -1973,6 +1973,89 @@ describe("createTelegramBot", () => { fetchSpy.mockRestore(); } }); + it("drops the media group when a non-recoverable media error occurs", async () => { + onSpy.mockReset(); + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { + "-100777111222": { + enabled: true, + requireMention: false, + }, + }, + }, + }, + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + try { + createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS }); + const handler = getOnHandler("channel_post") as ( + ctx: Record, + ) => Promise; + + const first = handler({ + channelPost: { + chat: { id: -100777111222, type: "channel", title: "Wake Channel" }, + message_id: 501, + caption: "fatal album", + date: 1736380800, + media_group_id: "fatal-album-1", + photo: [{ file_id: "p1" }], + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ file_path: "photos/p1.jpg" }), + }); + + const second = handler({ + channelPost: { + chat: { id: -100777111222, type: "channel", title: "Wake Channel" }, + message_id: 502, + date: 1736380801, + media_group_id: "fatal-album-1", + photo: [{ file_id: "p2" }], + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({}), + }); + + await Promise.all([first, second]); + expect(replySpy).not.toHaveBeenCalled(); + + const flushTimerCallIndex = setTimeoutSpy.mock.calls.findLastIndex( + (call) => call[1] === TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs, + ); + const flushTimer = + flushTimerCallIndex >= 0 + ? (setTimeoutSpy.mock.calls[flushTimerCallIndex]?.[0] as (() => unknown) | undefined) + : undefined; + // Cancel the real timer so it cannot fire a second time after we manually invoke it. + if (flushTimerCallIndex >= 0) { + clearTimeout( + setTimeoutSpy.mock.results[flushTimerCallIndex]?.value as ReturnType, + ); + } + expect(flushTimer).toBeTypeOf("function"); + await flushTimer?.(); + + expect(replySpy).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + fetchSpy.mockRestore(); + } + }); it("dedupes duplicate message updates by update_id", async () => { onSpy.mockReset(); replySpy.mockReset();