mirror of https://github.com/openclaw/openclaw.git
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:
parent
b9857a2b79
commit
c7f021f70f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue