fix: preflight invalid telegram photos (#52545) (thanks @hnshah)

* fix(telegram): validate photo dimensions before sendPhoto

Prevents PHOTO_INVALID_DIMENSIONS errors by checking image dimensions
against Telegram Bot API requirements before calling sendPhoto.

If dimensions exceed limits (width + height > 10,000px), automatically
falls back to sending as document instead of crashing with 400 error.

Tested in production (openclaw 2026.3.13) where this error occurred:
  [telegram] tool reply failed: GrammyError: Call to 'sendPhoto' failed!
  (400: Bad Request: PHOTO_INVALID_DIMENSIONS)

Uses existing sharp dependency to read image metadata. Gracefully
degrades if sharp fails (lets Telegram handle validation, backward
compatible behavior).

Closes: #XXXXX (will reference OpenClaw issue if one exists)

* fix(telegram): validate photo aspect ratio

* refactor: use shared telegram image metadata

* fix: fail closed on telegram image metadata

* fix: preflight invalid telegram photos (#52545) (thanks @hnshah)

---------

Co-authored-by: Bob Shah <bobshah@Macs-Mac-Studio.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
hnshah 2026-03-24 23:30:20 -07:00 committed by GitHub
parent b9857a2b79
commit c7f021f70f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 154 additions and 9 deletions

View File

@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
- Telegram/pairing: render pairing codes and approval commands as Telegram-only code blocks while keeping shared pairing replies plain text for other channels. (#52784) Thanks @sumukhj1219.
- Telegram/native commands: run native slash-command execution against the resolved runtime snapshot so DM commands still reply when fresh config reads surface unresolved SecretRefs. (#53179) Thanks @nimbleenigma.
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
- Feishu/groups: when `groupPolicy` is `open`, stop implicitly requiring @mentions for unset `requireMention`, so image, file, audio, and other non-text group messages reach the bot unless operators explicitly keep mention gating on. (#54058) Thanks @byungsker.
## 2026.3.23

View File

@ -28,6 +28,13 @@ const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
const { imageMetadata } = vi.hoisted(() => ({
imageMetadata: {
width: 1200 as number | undefined,
height: 800 as number | undefined,
},
}));
const { loadConfig } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
@ -71,12 +78,21 @@ type TelegramSendTestMocks = {
loadConfig: MockFn;
loadWebMedia: MockFn;
maybePersistResolvedTelegramTarget: MockFn;
imageMetadata: { width: number | undefined; height: number | undefined };
};
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
getImageMetadata: vi.fn(async () => ({ ...imageMetadata })),
};
});
vi.mock("grammy", () => ({
API_CONSTANTS: {
DEFAULT_UPDATE_TYPES: ["message"],
@ -129,13 +145,22 @@ vi.mock("./target-writeback.js", () => ({
}));
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget };
return {
botApi,
botCtorSpy,
loadConfig,
loadWebMedia,
maybePersistResolvedTelegramTarget,
imageMetadata,
};
}
export function installTelegramSendTestHooks() {
beforeEach(() => {
loadConfig.mockReturnValue({});
loadWebMedia.mockReset();
imageMetadata.width = 1200;
imageMetadata.height = 800;
maybePersistResolvedTelegramTarget.mockReset();
maybePersistResolvedTelegramTarget.mockResolvedValue(undefined);
undiciFetch.mockReset();

View File

@ -10,8 +10,14 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m
installTelegramSendTestHooks();
const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } =
getTelegramSendTestMocks();
const {
botApi,
botCtorSpy,
imageMetadata,
loadConfig,
loadWebMedia,
maybePersistResolvedTelegramTarget,
} = getTelegramSendTestMocks();
const {
buildInlineKeyboard,
createForumTopicTelegram,
@ -1051,6 +1057,77 @@ describe("sendMessageTelegram", () => {
expect(res.messageId).toBe("10");
});
it.each([
{ name: "oversized dimensions", width: 6000, height: 5001 },
{ name: "oversized aspect ratio", width: 4000, height: 100 },
])("sends images as documents when Telegram rejects $name", async ({ width, height }) => {
const chatId = "123";
const sendDocument = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: chatId },
});
const sendPhoto = vi.fn();
const api = { sendDocument, sendPhoto } as unknown as {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
};
imageMetadata.width = width;
imageMetadata.height = height;
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/png",
fileName: "photo.png",
});
const res = await sendMessageTelegram(chatId, "caption", {
token: "tok",
api,
mediaUrl: "https://example.com/photo.png",
});
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "caption",
parse_mode: "HTML",
});
expect(sendPhoto).not.toHaveBeenCalled();
expect(res.messageId).toBe("10");
});
it("sends images as documents when metadata dimensions are unavailable", async () => {
const chatId = "123";
const sendDocument = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: chatId },
});
const sendPhoto = vi.fn();
const api = { sendDocument, sendPhoto } as unknown as {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
};
imageMetadata.width = undefined;
imageMetadata.height = undefined;
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/png",
fileName: "photo.png",
});
const res = await sendMessageTelegram(chatId, "caption", {
token: "tok",
api,
mediaUrl: "https://example.com/photo.png",
});
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "caption",
parse_mode: "HTML",
});
expect(sendPhoto).not.toHaveBeenCalled();
expect(res.messageId).toBe("10");
});
it("keeps regular document sends on the default Telegram params", async () => {
const chatId = "123";
const sendDocument = vi.fn().mockResolvedValue({

View File

@ -15,6 +15,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/infra-runtime";
import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime";
import type { MediaKind } from "openclaw/plugin-sdk/media-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { getImageMetadata } from "openclaw/plugin-sdk/media-runtime";
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
@ -54,6 +55,8 @@ const InputFileCtor: typeof grammy.InputFile =
public readonly fileName?: string,
) {}
} as unknown as typeof grammy.InputFile);
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20;
type TelegramSendOpts = {
cfg?: ReturnType<typeof loadConfig>;
@ -788,6 +791,39 @@ export async function sendMessageTelegram(
const sendChunkedText = async (rawText: string, context: string) =>
await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
try {
const metadata = await getImageMetadata(buffer);
const width = metadata?.width;
const height = metadata?.height;
if (typeof width !== "number" || typeof height !== "number") {
sendLogger.warn("Photo dimensions are unavailable. Sending as document instead.");
return false;
}
const shorterSide = Math.min(width, height);
const longerSide = Math.max(width, height);
const isValidPhoto =
width + height <= MAX_TELEGRAM_PHOTO_DIMENSION_SUM &&
shorterSide > 0 &&
longerSide <= shorterSide * MAX_TELEGRAM_PHOTO_ASPECT_RATIO;
if (!isValidPhoto) {
sendLogger.warn(
`Photo dimensions (${width}x${height}) are not valid for Telegram photos. Sending as document instead.`,
);
return false;
}
return true;
} catch (err) {
sendLogger.warn(
`Failed to validate photo dimensions: ${formatErrorMessage(err)}. Sending as document instead.`,
);
return false;
}
}
if (mediaUrl) {
const media = await loadWebMedia(
mediaUrl,
@ -802,6 +838,12 @@ export async function sendMessageTelegram(
contentType: media.contentType,
fileName: media.fileName,
});
// Validate photo dimensions before attempting sendPhoto
let sendImageAsPhoto = true;
if (kind === "image" && !isGif && !opts.forceDocument) {
sendImageAsPhoto = await shouldSendTelegramImageAsPhoto(media.buffer);
}
const isVideoNote = kind === "video" && opts.asVideoNote === true;
const fileName =
media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind ?? "document")) ?? "file";
@ -858,7 +900,7 @@ export async function sendMessageTelegram(
) as Promise<TelegramMessageLike>,
};
}
if (kind === "image" && !opts.forceDocument) {
if (kind === "image" && !opts.forceDocument && sendImageAsPhoto) {
return {
label: "photo",
sender: (effectiveParams: Record<string, unknown> | undefined) =>

View File

@ -1176,8 +1176,8 @@ packages:
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
'@emnapi/runtime@1.8.1':
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
'@emnapi/runtime@1.9.1':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@ -7755,7 +7755,7 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.8.1':
'@emnapi/runtime@1.9.1':
dependencies:
tslib: 2.8.1
optional: true
@ -7992,7 +7992,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.8.1
'@emnapi/runtime': 1.9.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@ -8701,7 +8701,7 @@ snapshots:
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
'@emnapi/runtime': 1.8.1
'@emnapi/runtime': 1.9.1
'@tybys/wasm-util': 0.10.1
optional: true