diff --git a/CHANGELOG.md b/CHANGELOG.md index 59959f78f12..8b62101a7fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 5fc58ed3ae3..b5a02ebd03c 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -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(); + 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(); diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index ec54f2a8aa8..c8514f48d87 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -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({ diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 09c4e8f7362..652ddacbad0 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -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; @@ -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 { + 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, }; } - if (kind === "image" && !opts.forceDocument) { + if (kind === "image" && !opts.forceDocument && sendImageAsPhoto) { return { label: "photo", sender: (effectiveParams: Record | undefined) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a393c034001..3c83eb1a6fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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