Skip to content

Commit c7f021f

Browse files
hnshahBob Shahobviyus
authored
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 <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent b9857a2 commit c7f021f

File tree

5 files changed

+154
-9
lines changed

5 files changed

+154
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai
5454
- 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.
5555
- 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.
5656
- 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.
57+
- 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.
5758
- 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.
5859

5960
## 2026.3.23

extensions/telegram/src/send.test-harness.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ const { loadWebMedia } = vi.hoisted(() => ({
2828
loadWebMedia: vi.fn(),
2929
}));
3030

31+
const { imageMetadata } = vi.hoisted(() => ({
32+
imageMetadata: {
33+
width: 1200 as number | undefined,
34+
height: 800 as number | undefined,
35+
},
36+
}));
37+
3138
const { loadConfig } = vi.hoisted(() => ({
3239
loadConfig: vi.fn(() => ({})),
3340
}));
@@ -71,12 +78,21 @@ type TelegramSendTestMocks = {
7178
loadConfig: MockFn;
7279
loadWebMedia: MockFn;
7380
maybePersistResolvedTelegramTarget: MockFn;
81+
imageMetadata: { width: number | undefined; height: number | undefined };
7482
};
7583

7684
vi.mock("openclaw/plugin-sdk/web-media", () => ({
7785
loadWebMedia,
7886
}));
7987

88+
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
89+
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
90+
return {
91+
...actual,
92+
getImageMetadata: vi.fn(async () => ({ ...imageMetadata })),
93+
};
94+
});
95+
8096
vi.mock("grammy", () => ({
8197
API_CONSTANTS: {
8298
DEFAULT_UPDATE_TYPES: ["message"],
@@ -129,13 +145,22 @@ vi.mock("./target-writeback.js", () => ({
129145
}));
130146

131147
export function getTelegramSendTestMocks(): TelegramSendTestMocks {
132-
return { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget };
148+
return {
149+
botApi,
150+
botCtorSpy,
151+
loadConfig,
152+
loadWebMedia,
153+
maybePersistResolvedTelegramTarget,
154+
imageMetadata,
155+
};
133156
}
134157

135158
export function installTelegramSendTestHooks() {
136159
beforeEach(() => {
137160
loadConfig.mockReturnValue({});
138161
loadWebMedia.mockReset();
162+
imageMetadata.width = 1200;
163+
imageMetadata.height = 800;
139164
maybePersistResolvedTelegramTarget.mockReset();
140165
maybePersistResolvedTelegramTarget.mockResolvedValue(undefined);
141166
undiciFetch.mockReset();

extensions/telegram/src/send.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-m
1010

1111
installTelegramSendTestHooks();
1212

13-
const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } =
14-
getTelegramSendTestMocks();
13+
const {
14+
botApi,
15+
botCtorSpy,
16+
imageMetadata,
17+
loadConfig,
18+
loadWebMedia,
19+
maybePersistResolvedTelegramTarget,
20+
} = getTelegramSendTestMocks();
1521
const {
1622
buildInlineKeyboard,
1723
createForumTopicTelegram,
@@ -1051,6 +1057,77 @@ describe("sendMessageTelegram", () => {
10511057
expect(res.messageId).toBe("10");
10521058
});
10531059

1060+
it.each([
1061+
{ name: "oversized dimensions", width: 6000, height: 5001 },
1062+
{ name: "oversized aspect ratio", width: 4000, height: 100 },
1063+
])("sends images as documents when Telegram rejects $name", async ({ width, height }) => {
1064+
const chatId = "123";
1065+
const sendDocument = vi.fn().mockResolvedValue({
1066+
message_id: 10,
1067+
chat: { id: chatId },
1068+
});
1069+
const sendPhoto = vi.fn();
1070+
const api = { sendDocument, sendPhoto } as unknown as {
1071+
sendDocument: typeof sendDocument;
1072+
sendPhoto: typeof sendPhoto;
1073+
};
1074+
1075+
imageMetadata.width = width;
1076+
imageMetadata.height = height;
1077+
mockLoadedMedia({
1078+
buffer: Buffer.from("fake-image"),
1079+
contentType: "image/png",
1080+
fileName: "photo.png",
1081+
});
1082+
1083+
const res = await sendMessageTelegram(chatId, "caption", {
1084+
token: "tok",
1085+
api,
1086+
mediaUrl: "https://example.com/photo.png",
1087+
});
1088+
1089+
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
1090+
caption: "caption",
1091+
parse_mode: "HTML",
1092+
});
1093+
expect(sendPhoto).not.toHaveBeenCalled();
1094+
expect(res.messageId).toBe("10");
1095+
});
1096+
1097+
it("sends images as documents when metadata dimensions are unavailable", async () => {
1098+
const chatId = "123";
1099+
const sendDocument = vi.fn().mockResolvedValue({
1100+
message_id: 10,
1101+
chat: { id: chatId },
1102+
});
1103+
const sendPhoto = vi.fn();
1104+
const api = { sendDocument, sendPhoto } as unknown as {
1105+
sendDocument: typeof sendDocument;
1106+
sendPhoto: typeof sendPhoto;
1107+
};
1108+
1109+
imageMetadata.width = undefined;
1110+
imageMetadata.height = undefined;
1111+
mockLoadedMedia({
1112+
buffer: Buffer.from("fake-image"),
1113+
contentType: "image/png",
1114+
fileName: "photo.png",
1115+
});
1116+
1117+
const res = await sendMessageTelegram(chatId, "caption", {
1118+
token: "tok",
1119+
api,
1120+
mediaUrl: "https://example.com/photo.png",
1121+
});
1122+
1123+
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
1124+
caption: "caption",
1125+
parse_mode: "HTML",
1126+
});
1127+
expect(sendPhoto).not.toHaveBeenCalled();
1128+
expect(res.messageId).toBe("10");
1129+
});
1130+
10541131
it("keeps regular document sends on the default Telegram params", async () => {
10551132
const chatId = "123";
10561133
const sendDocument = vi.fn().mockResolvedValue({

extensions/telegram/src/send.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/infra-runtime";
1515
import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime";
1616
import type { MediaKind } from "openclaw/plugin-sdk/media-runtime";
1717
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
18+
import { getImageMetadata } from "openclaw/plugin-sdk/media-runtime";
1819
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
1920
import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/media-runtime";
2021
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -54,6 +55,8 @@ const InputFileCtor: typeof grammy.InputFile =
5455
public readonly fileName?: string,
5556
) {}
5657
} as unknown as typeof grammy.InputFile);
58+
const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000;
59+
const MAX_TELEGRAM_PHOTO_ASPECT_RATIO = 20;
5760

5861
type TelegramSendOpts = {
5962
cfg?: ReturnType<typeof loadConfig>;
@@ -788,6 +791,39 @@ export async function sendMessageTelegram(
788791
const sendChunkedText = async (rawText: string, context: string) =>
789792
await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context);
790793

794+
async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise<boolean> {
795+
try {
796+
const metadata = await getImageMetadata(buffer);
797+
const width = metadata?.width;
798+
const height = metadata?.height;
799+
800+
if (typeof width !== "number" || typeof height !== "number") {
801+
sendLogger.warn("Photo dimensions are unavailable. Sending as document instead.");
802+
return false;
803+
}
804+
805+
const shorterSide = Math.min(width, height);
806+
const longerSide = Math.max(width, height);
807+
const isValidPhoto =
808+
width + height <= MAX_TELEGRAM_PHOTO_DIMENSION_SUM &&
809+
shorterSide > 0 &&
810+
longerSide <= shorterSide * MAX_TELEGRAM_PHOTO_ASPECT_RATIO;
811+
812+
if (!isValidPhoto) {
813+
sendLogger.warn(
814+
`Photo dimensions (${width}x${height}) are not valid for Telegram photos. Sending as document instead.`,
815+
);
816+
return false;
817+
}
818+
return true;
819+
} catch (err) {
820+
sendLogger.warn(
821+
`Failed to validate photo dimensions: ${formatErrorMessage(err)}. Sending as document instead.`,
822+
);
823+
return false;
824+
}
825+
}
826+
791827
if (mediaUrl) {
792828
const media = await loadWebMedia(
793829
mediaUrl,
@@ -802,6 +838,12 @@ export async function sendMessageTelegram(
802838
contentType: media.contentType,
803839
fileName: media.fileName,
804840
});
841+
842+
// Validate photo dimensions before attempting sendPhoto
843+
let sendImageAsPhoto = true;
844+
if (kind === "image" && !isGif && !opts.forceDocument) {
845+
sendImageAsPhoto = await shouldSendTelegramImageAsPhoto(media.buffer);
846+
}
805847
const isVideoNote = kind === "video" && opts.asVideoNote === true;
806848
const fileName =
807849
media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind ?? "document")) ?? "file";
@@ -858,7 +900,7 @@ export async function sendMessageTelegram(
858900
) as Promise<TelegramMessageLike>,
859901
};
860902
}
861-
if (kind === "image" && !opts.forceDocument) {
903+
if (kind === "image" && !opts.forceDocument && sendImageAsPhoto) {
862904
return {
863905
label: "photo",
864906
sender: (effectiveParams: Record<string, unknown> | undefined) =>

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)