fix(telegram): surface media failures in the agent-facing body (#130849)

This commit is contained in:
Peter Steinberger
2026-08-27 02:18:26 -07:00
committed by GitHub
parent c52cf19bc6
commit bdd8f33f5d
8 changed files with 198 additions and 76 deletions
+1
View File
@@ -830,6 +830,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
<Accordion title="Limits and CLI targets">
- `channels.telegram.textChunkLimit` default 4000; `streaming.chunkMode="newline"` prefers paragraph boundaries (blank lines) before length splitting.
- `channels.telegram.mediaMaxMb` (default 100) caps inbound and outbound media size.
- When an inbound attachment cannot be downloaded and the message proceeds to the agent, its body includes a `[media unavailable: ...]` notice. Oversize notices include the effective size limit; partial albums include the failed and total attachment counts. This also applies to admitted channel posts, even when their separate chat warning is suppressed.
- group context history uses `channels.telegram.historyLimit` or `messages.groupChat.historyLimit` (default 50); `0` disables.
- reply/quote/forward supplemental context normalizes into one selected conversation context window when the gateway has observed the parent messages; the observed-message cache lives in OpenClaw SQLite plugin state, and `openclaw doctor --fix` imports legacy sidecars. Telegram only includes one shallow `reply_to_message` per update, so chains older than the cache are limited to that payload.
- Telegram allowlists primarily gate who can trigger the agent, not a full supplemental-context redaction boundary.
@@ -354,10 +354,6 @@ export function createTelegramInboundMedia({
}
// Classic polling cannot replay a failed album; retain its existing partial-delivery path.
runtime.log?.(warn(`media group: skipping photo that failed to fetch: ${String(error)}`));
allMedia.push({ kind: nativeKind, sourceMessageId });
selection.set(sourceMessageId, "exclude");
skippedCount++;
continue;
}
if (media) {
await recordMessageResolvedMedia({ msg, media, botUserId: ctx.me?.id });
@@ -372,7 +368,11 @@ export function createTelegramInboundMedia({
materializedCount++;
selection.set(sourceMessageId, "include");
} else {
allMedia.push({ kind: nativeKind, sourceMessageId });
allMedia.push({
kind: nativeKind,
sourceMessageId,
unavailable: { reason: "download-failed" },
});
selection.set(sourceMessageId, "exclude");
skippedCount++;
}
@@ -30,6 +30,7 @@ import type {
import type {
TelegramAmbientTranscriptWatermark,
TelegramChannelIngressResolver,
TelegramMediaRef,
} from "./bot-message-context.types.js";
import {
isTelegramSpooledReplayUpdate,
@@ -240,6 +241,7 @@ export function createTelegramInboundProcessing({
const nativeMedia = resolveTelegramPrimaryMedia(msg);
const mediaRuntime = resolveMediaRuntime();
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
let unavailable: TelegramMediaRef["unavailable"];
try {
media = await resolveMedia({
ctx,
@@ -268,11 +270,12 @@ export function createTelegramInboundProcessing({
return { kind: "ignored" };
}
if (isMediaSizeLimitError(mediaErr)) {
const limitMb =
mediaErr instanceof TelegramBotApiFileTooLargeError
? Math.min(mediaErr.limitMb, Math.round(mediaMaxBytes / (1024 * 1024)))
: Math.round(mediaMaxBytes / (1024 * 1024));
unavailable = { reason: "oversize", limitMb };
if (sendOversizeWarning && mediaDisposition !== "silent-ingest") {
const limitMb =
mediaErr instanceof TelegramBotApiFileTooLargeError
? Math.min(mediaErr.limitMb, Math.round(mediaMaxBytes / (1024 * 1024)))
: Math.round(mediaMaxBytes / (1024 * 1024));
await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
@@ -295,6 +298,7 @@ export function createTelegramInboundProcessing({
releaseDispatchDedupeClaims(dispatchDedupeClaims, mediaErr);
return { kind: "ignored" };
}
unavailable = { reason: "download-failed" };
if (mediaDisposition !== "silent-ingest") {
await withTelegramApiErrorLogging({
operation: "sendMessage",
@@ -322,7 +326,7 @@ export function createTelegramInboundProcessing({
kind: media.kind,
stickerMetadata: media.stickerMetadata,
}
: { kind: nativeMedia.kind },
: { kind: nativeMedia.kind, unavailable },
]
: [];
const conversationKey = buildTelegramInboundDebounceConversationKey({
@@ -5,6 +5,7 @@ import {
type BuiltChannelInboundEventContext,
formatMediaPlaceholderText,
formatInboundEnvelope,
formatInboundMediaUnavailableText,
resolveEnvelopeFormatOptions,
toLocationContext,
type NormalizedLocation,
@@ -483,6 +484,24 @@ export async function buildTelegramInboundContextPayload(params: {
forwardedFrom: visibleForwardOrigin?.from,
forwardedDate: visibleForwardOrigin?.date ? visibleForwardOrigin.date * 1000 : undefined,
});
// Record terminal download outcomes after body assembly, including buffered forwards.
// Missing paths alone also describe intentionally unsupported media; raw commands stay untouched.
const unavailableMedia = allMedia.flatMap((media) =>
media.unavailable ? [media.unavailable] : [],
);
const unavailableReason =
allMedia.length > 1
? `${unavailableMedia.length} of ${allMedia.length} attachments could not be downloaded`
: unavailableMedia[0]?.reason === "oversize"
? `file exceeds ${unavailableMedia[0].limitMb}MB limit`
: "download failed";
const appendMediaUnavailableNotice = (text: string) =>
unavailableMedia.length > 0
? formatInboundMediaUnavailableText({
body: text,
notice: `[media unavailable: ${unavailableReason}]`,
})
: text;
const replySuffix =
visibleReplyChain.length > 0
? `\n\n[Reply chain - nearest first]\n${visibleReplyChain
@@ -536,7 +555,7 @@ export async function buildTelegramInboundContextPayload(params: {
channel: "Telegram",
from: conversationLabel,
timestamp: msg.date ? msg.date * 1000 : undefined,
body: `${visibleBodyText}${replySuffix}`,
body: `${appendMediaUnavailableNotice(visibleBodyText)}${replySuffix}`,
chatType: isGroup ? "group" : "direct",
sender: {
name: senderName,
@@ -714,7 +733,9 @@ export async function buildTelegramInboundContextPayload(params: {
inboundEventKind,
body,
rawBody,
bodyForAgent: shouldRenderBufferedBody ? visibleBodyText : bodyText,
bodyForAgent: appendMediaUnavailableNotice(
shouldRenderBufferedBody ? visibleBodyText : bodyText,
),
commandBody,
inboundHistory,
sourceModality: msg.voice ? "voice" : undefined,
@@ -27,6 +27,7 @@ export type TelegramMediaRef = {
fileName?: string;
stickerMetadata?: StickerMetadata;
sourceMessageId?: string;
unavailable?: { reason: "oversize"; limitMb: number } | { reason: "download-failed" };
};
export type TelegramChannelIngressResolver = (
@@ -216,10 +216,15 @@ function replyPayload(): Record<string, unknown> {
return call[0] as Record<string, unknown>;
}
function expectTypeOnlyMediaPayload(kind: string, rawBody = "") {
function expectUnavailableMediaPayload(
kind: string,
rawBody = "",
notice = "[media unavailable: download failed]",
) {
const payload = replyPayload();
expect(payload).toMatchObject({
BodyForAgent: rawBody,
Body: expect.stringContaining(notice),
BodyForAgent: [rawBody, notice].filter(Boolean).join("\n\n"),
media: [expect.objectContaining({ kind })],
RawBody: rawBody,
});
@@ -524,7 +529,7 @@ describe("createTelegramBot channel_post media", () => {
expect(replySpy).toHaveBeenCalledOnce();
expect(sendMessageSpy).not.toHaveBeenCalled();
expectTypeOnlyMediaPayload("image");
expectUnavailableMediaPayload("image", "", "[media unavailable: file exceeds 0MB limit]");
fetchSpy.mockRestore();
});
@@ -541,7 +546,7 @@ describe("createTelegramBot channel_post media", () => {
await waitForTelegramMockCalls(sendMessageSpy, 1);
expectTelegramDownloadWarning(411);
expect(replySpy).toHaveBeenCalledOnce();
expectTypeOnlyMediaPayload("image");
expectUnavailableMediaPayload("image");
} finally {
fetchSpy.mockRestore();
}
@@ -564,7 +569,7 @@ describe("createTelegramBot channel_post media", () => {
await waitForTelegramMockCalls(sendMessageSpy, 1);
expectTelegramDownloadWarning(100000);
expect(replySpy).toHaveBeenCalledOnce();
expectTypeOnlyMediaPayload("document");
expectUnavailableMediaPayload("document");
expect(saveRemoteMedia).not.toHaveBeenCalled();
});
@@ -594,7 +599,11 @@ describe("createTelegramBot channel_post media", () => {
`⚠️ File too large. Maximum size is ${expectedLimitMb}MB.`,
);
expect(replySpy).toHaveBeenCalledOnce();
expectTypeOnlyMediaPayload("document");
expectUnavailableMediaPayload(
"document",
"",
`[media unavailable: file exceeds ${expectedLimitMb}MB limit]`,
);
expect(saveRemoteMedia).not.toHaveBeenCalled();
},
);
@@ -615,6 +624,7 @@ describe("createTelegramBot channel_post media", () => {
error: new MediaFetchError("max_bytes", "Failed to fetch media: payload exceeds maxBytes 10"),
result: { kind: "completed" },
warning: "⚠️ File too large. Maximum size is 100MB.",
notice: "[media unavailable: file exceeds 100MB limit]",
},
{
name: "permanent SSRF rejection",
@@ -645,7 +655,7 @@ describe("createTelegramBot channel_post media", () => {
expect(sendMessageSpy.mock.calls[0]?.[1]).toBe(testCase.warning);
if (testCase.warning) {
expectTelegramDownloadWarning(testCase.messageId, testCase.warning);
expectTypeOnlyMediaPayload("document");
expectUnavailableMediaPayload("document", "", testCase.notice);
}
});
@@ -899,7 +909,7 @@ describe("createTelegramBot channel_post media", () => {
}),
);
expect(replySpy).toHaveBeenCalledOnce();
expectTypeOnlyMediaPayload("image", "caption" in testCase ? testCase.caption : "");
expectUnavailableMediaPayload("image", "caption" in testCase ? testCase.caption : "");
} finally {
fetchSpy.mockRestore();
}
@@ -166,7 +166,7 @@ describe("telegram inbound media", () => {
},
},
{
name: "skips when file_path is missing",
name: "reports unavailable media when file_path is missing",
messageId: 2,
getFile: async () => ({}),
setupFetch: () => watchTelegramFetch(),
@@ -178,7 +178,7 @@ describe("telegram inbound media", () => {
expect(params.fetchSpy).not.toHaveBeenCalled();
expect(params.replySpy).toHaveBeenCalledTimes(1);
expect(replyPayload(params.replySpy)).toMatchObject({
BodyForAgent: "",
BodyForAgent: "[media unavailable: download failed]",
MediaTypes: ["image"],
RawBody: "",
});
@@ -1,12 +1,30 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
onSpy,
readRemoteMediaBufferSpy,
telegramBotDepsForTest,
telegramMediaHarnessSendMessageSpy,
} from "./bot.media.e2e.test-harness.js";
import { createBotHandlerWithOptions } from "./bot.media.test-utils.js";
import { createBotHandlerWithOptions, mockTelegramPngDownload } from "./bot.media.test-utils.js";
describe("Telegram single-media warnings", () => {
describe("Telegram media failure notices", () => {
beforeEach(() => {
telegramBotDepsForTest.getRuntimeConfig = () => ({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["777", "-10042"],
groupPolicy: "open",
mediaMaxMb: 8,
groups: {
"-10042": { groupPolicy: "open", requireMention: false },
},
},
},
});
telegramMediaHarnessSendMessageSpy.mockClear();
});
it.each([
{
description: "forum topic",
@@ -15,6 +33,7 @@ describe("Telegram single-media warnings", () => {
expectedThreadId: 202,
failure: new Error("Telegram media exceeds 20 MB limit"),
expectedWarning: "File too large",
expectedNotice: "[media unavailable: file exceeds 8MB limit]",
},
{
description: "bot DM topic",
@@ -23,6 +42,7 @@ describe("Telegram single-media warnings", () => {
expectedThreadId: 303,
failure: new Error("Telegram media exceeds 20 MB limit"),
expectedWarning: "File too large",
expectedNotice: "[media unavailable: file exceeds 8MB limit]",
},
{
description: "forum General topic",
@@ -31,6 +51,7 @@ describe("Telegram single-media warnings", () => {
expectedThreadId: undefined,
failure: new Error("Telegram media exceeds 20 MB limit"),
expectedWarning: "File too large",
expectedNotice: "[media unavailable: file exceeds 8MB limit]",
},
{
description: "forum topic after an ordinary download failure",
@@ -39,63 +60,127 @@ describe("Telegram single-media warnings", () => {
expectedThreadId: 404,
failure: new Error("permanent download failure"),
expectedWarning: "Failed to download media",
expectedNotice: "[media unavailable: download failed]",
},
])(
"keeps $description warnings in their originating Telegram thread",
async ({ chat, threadId, expectedThreadId, failure, expectedWarning }) => {
const originalLoadConfig = telegramBotDepsForTest.getRuntimeConfig;
telegramBotDepsForTest.getRuntimeConfig = (() => ({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["777"],
groupPolicy: "open",
groups: {
"-10042": { allowFrom: ["777"], groupPolicy: "open", requireMention: false },
},
},
async ({ chat, threadId, expectedThreadId, failure, expectedWarning, expectedNotice }) => {
const { handler, replySpy } = await createBotHandlerWithOptions({});
telegramMediaHarnessSendMessageSpy.mockClear();
readRemoteMediaBufferSpy.mockRejectedValueOnce(failure);
await handler({
message: {
chat,
from: { id: 777, is_bot: false, first_name: "Ada" },
message_id: 901,
message_thread_id: threadId,
is_topic_message: true,
caption: "Topic attachment",
date: 1736380800,
photo: [{ file_id: "topic-attachment" }],
},
})) as typeof telegramBotDepsForTest.getRuntimeConfig;
me: { username: "openclaw_bot", has_topics_enabled: true },
getFile: async () => ({ file_path: "photos/topic-attachment.jpg" }),
});
try {
const { handler } = await createBotHandlerWithOptions({});
telegramMediaHarnessSendMessageSpy.mockClear();
readRemoteMediaBufferSpy.mockRejectedValueOnce(failure);
await handler({
message: {
chat,
from: { id: 777, is_bot: false, first_name: "Ada" },
message_id: 901,
message_thread_id: threadId,
is_topic_message: true,
caption: "Topic attachment",
date: 1736380800,
photo: [{ file_id: "topic-attachment" }],
},
me: { username: "openclaw_bot", has_topics_enabled: true },
getFile: async () => ({ file_path: "photos/topic-attachment.jpg" }),
});
expect(telegramMediaHarnessSendMessageSpy).toHaveBeenCalledWith(
chat.id,
expect.stringContaining(expectedWarning),
expect.objectContaining({
reply_parameters: expect.objectContaining({ message_id: 901 }),
}),
);
const warning = telegramMediaHarnessSendMessageSpy.mock.calls.find(
([, text]) => typeof text === "string" && text.includes(expectedWarning),
);
if (!warning) {
throw new Error(`Missing ${expectedWarning} Telegram media warning`);
}
const options = warning[2] as { message_thread_id?: number };
expect(options.message_thread_id).toBe(expectedThreadId);
} finally {
telegramBotDepsForTest.getRuntimeConfig = originalLoadConfig;
expect(telegramMediaHarnessSendMessageSpy).toHaveBeenCalledWith(
chat.id,
expect.stringContaining(expectedWarning),
expect.objectContaining({
reply_parameters: expect.objectContaining({ message_id: 901 }),
}),
);
const warning = telegramMediaHarnessSendMessageSpy.mock.calls.find(
([, text]) => typeof text === "string" && text.includes(expectedWarning),
);
if (!warning) {
throw new Error(`Missing ${expectedWarning} Telegram media warning`);
}
const options = warning[2] as { message_thread_id?: number };
expect(options.message_thread_id).toBe(expectedThreadId);
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls[0]?.[0]).toMatchObject({
Body: expect.stringContaining(expectedNotice),
BodyForAgent: `Topic attachment\n\n${expectedNotice}`,
RawBody: "Topic attachment",
CommandBody: "Topic attachment",
});
},
);
it.each([false, true])(
"keeps channel-post oversize warnings suppressed (failure=%s)",
async (fails) => {
const { replySpy } = await createBotHandlerWithOptions({});
const handler = onSpy.mock.calls.find(([event]) => event === "channel_post")?.[1];
mockTelegramPngDownload();
if (fails) {
readRemoteMediaBufferSpy.mockRejectedValueOnce(
new Error("Telegram media exceeds 8 MB limit"),
);
}
await handler({
channelPost: {
chat: { id: -10042, type: "channel", title: "Announcements" },
sender_chat: { id: -10042, type: "channel", title: "Announcements" },
message_id: 902,
date: 1736380800,
caption: "Channel attachment",
photo: [{ file_id: "channel-attachment" }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: "photos/channel-attachment.jpg" }),
});
expect(telegramMediaHarnessSendMessageSpy).not.toHaveBeenCalled();
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0]?.[0];
expect(payload).toMatchObject({
BodyForAgent: fails
? "Channel attachment\n\n[media unavailable: file exceeds 8MB limit]"
: "Channel attachment",
RawBody: "Channel attachment",
CommandBody: "Channel attachment",
});
expect(payload.Body.includes("[media unavailable:")).toBe(fails);
},
);
it.each([0, 1, 2])("accounts for %s failed attachments in an album", async (failedCount) => {
const { handler, replySpy } = await createBotHandlerWithOptions({});
mockTelegramPngDownload();
for (let index = 0; index < failedCount; index++) {
readRemoteMediaBufferSpy.mockRejectedValueOnce(
new Error("Telegram media exceeds 8 MB limit"),
);
}
for (let index = 0; index < 2; index++) {
await handler({
message: {
chat: { id: 4242, type: "private" },
from: { id: 777, is_bot: false, first_name: "Ada" },
message_id: 910 + index,
date: 1736380800,
media_group_id: "failure-notice-album",
caption: index === 0 ? "Album attachment" : undefined,
photo: [{ file_id: `album-${index}` }],
},
me: { username: "openclaw_bot" },
getFile: async () => ({ file_path: `photos/album-${index}.jpg` }),
});
}
await vi.waitFor(() => expect(replySpy).toHaveBeenCalledTimes(1));
const payload = replySpy.mock.calls[0]?.[0];
expect(payload).toMatchObject({
BodyForAgent: failedCount
? `Album attachment\n\n[media unavailable: ${failedCount} of 2 attachments could not be downloaded]`
: "Album attachment",
RawBody: "Album attachment",
CommandBody: "Album attachment",
});
expect(payload.Body.includes("[media unavailable:")).toBe(failedCount > 0);
expect(payload.media.filter((media: { path?: string }) => media.path)).toHaveLength(
2 - failedCount,
);
});
});