mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 22:50:26 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user