diff --git a/CHANGELOG.md b/CHANGELOG.md index 446b8b7489c..9ba4346c35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei. - Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`. - Cron/sessions: add `sessionTarget: "current"` and `session:` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF. +- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent. ### Breaking diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 998e0b5d266..29095e7bc7c 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -38,6 +38,7 @@ function readTelegramSendParams(params: Record) { const buttons = params.buttons; const asVoice = readBooleanParam(params, "asVoice"); const silent = readBooleanParam(params, "silent"); + const forceDocument = readBooleanParam(params, "forceDocument"); const quoteText = readStringParam(params, "quoteText"); return { to, @@ -48,6 +49,7 @@ function readTelegramSendParams(params: Record) { buttons, asVoice, silent, + forceDocument, quoteText: quoteText ?? undefined, }; } diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 4309fc4b882..52700ba61dc 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -115,6 +115,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { deps, replyToId, threadId, + forceDocument, }) => { const { send, baseOpts } = resolveTelegramSendContext({ cfg, @@ -127,6 +128,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { ...baseOpts, mediaUrl, mediaLocalRoots, + forceDocument: forceDocument ?? false, }); return { channel: "telegram", ...result }; }, diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 6e6472f9531..8dc4aff0c2d 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -877,6 +877,87 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("9"); }); + it.each([ + { + name: "images", + buffer: Buffer.from("fake-image"), + contentType: "image/png", + fileName: "photo.png", + mediaUrl: "https://example.com/photo.png", + }, + { + name: "GIFs", + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + mediaUrl: "https://example.com/fun.gif", + }, + ])("sends $name as documents when forceDocument is true", async (testCase) => { + const chatId = "123"; + const sendAnimation = vi.fn(); + const sendDocument = vi.fn().mockResolvedValue({ + message_id: 10, + chat: { id: chatId }, + }); + const sendPhoto = vi.fn(); + const api = { sendAnimation, sendDocument, sendPhoto } as unknown as { + sendAnimation: typeof sendAnimation; + sendDocument: typeof sendDocument; + sendPhoto: typeof sendPhoto; + }; + + mockLoadedMedia({ + buffer: testCase.buffer, + contentType: testCase.contentType, + fileName: testCase.fileName, + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: testCase.mediaUrl, + forceDocument: true, + }); + + expect(sendDocument, testCase.name).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + parse_mode: "HTML", + disable_content_type_detection: true, + }); + expect(sendPhoto, testCase.name).not.toHaveBeenCalled(); + expect(sendAnimation, testCase.name).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({ + message_id: 11, + chat: { id: chatId }, + }); + const api = { sendDocument } as unknown as { + sendDocument: typeof sendDocument; + }; + + mockLoadedMedia({ + buffer: Buffer.from("%PDF-1.7"), + contentType: "application/pdf", + fileName: "report.pdf", + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/report.pdf", + }); + + expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + parse_mode: "HTML", + }); + expect(res.messageId).toBe("11"); + }); + it("routes audio media to sendAudio/sendVoice based on voice compatibility", async () => { const cases: Array<{ name: string; diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index a6698e8897f..e7d2c48e9fc 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -71,6 +71,8 @@ type TelegramSendOpts = { messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ buttons?: TelegramInlineButtons; + /** Send image as document to avoid Telegram compression. Defaults to false. */ + forceDocument?: boolean; }; type TelegramSendResult = { @@ -763,6 +765,7 @@ export async function sendMessageTelegram( buildOutboundMediaLoadOptions({ maxBytes: mediaMaxBytes, mediaLocalRoots: opts.mediaLocalRoots, + optimizeImages: opts.forceDocument ? false : undefined, }), ); const kind = kindFromMime(media.contentType ?? undefined); @@ -815,7 +818,7 @@ export async function sendMessageTelegram( ); const mediaSender = (() => { - if (isGif) { + if (isGif && !opts.forceDocument) { return { label: "animation", sender: (effectiveParams: Record | undefined) => @@ -826,7 +829,7 @@ export async function sendMessageTelegram( ) as Promise, }; } - if (kind === "image") { + if (kind === "image" && !opts.forceDocument) { return { label: "photo", sender: (effectiveParams: Record | undefined) => @@ -893,7 +896,11 @@ export async function sendMessageTelegram( api.sendDocument( chatId, file, - effectiveParams as Parameters[2], + // Only force Telegram to keep the uploaded media type when callers explicitly + // opt into document delivery for image/GIF uploads. + (opts.forceDocument + ? { ...effectiveParams, disable_content_type_detection: true } + : effectiveParams) as Parameters[2], ) as Promise, }; })(); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 96b2702f065..63963ab5f38 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -201,6 +201,11 @@ function buildSendSchema(options: { ), bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), + forceDocument: Type.Optional( + Type.Boolean({ + description: "Send image/GIF as document to avoid Telegram compression (Telegram only).", + }), + ), buttons: Type.Optional( Type.Array( Type.Array( diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 49e40a074dd..6c8d4f84204 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -252,6 +252,7 @@ export async function handleTelegramAction( quoteText: quoteText ?? undefined, asVoice: readBooleanParam(params, "asVoice"), silent: readBooleanParam(params, "silent"), + forceDocument: readBooleanParam(params, "forceDocument") ?? false, }); return jsonResult({ ok: true, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index df84ee4d3d2..257985e133c 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -93,6 +93,8 @@ export type ChannelOutboundContext = { mediaUrl?: string; mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; + /** Send image as document to avoid Telegram compression. */ + forceDocument?: boolean; replyToId?: string | null; threadId?: string | number | null; accountId?: string | null; diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index 6c3d709f96d..f33bd2a24a8 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -24,6 +24,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli .option("--reply-to ", "Reply-to message id") .option("--thread-id ", "Thread id (Telegram forum thread)") .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false) + .option( + "--force-document", + "Send media as document to avoid Telegram compression (Telegram only). Applies to images and GIFs.", + false, + ) .option( "--silent", "Send message silently without notification (Telegram + Discord)", diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index cb3e718486c..b67f1b7d2a0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -146,6 +146,7 @@ type ChannelHandlerParams = { identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; + forceDocument?: boolean; silent?: boolean; mediaLocalRoots?: readonly string[]; }; @@ -226,6 +227,7 @@ function createChannelOutboundContextBase( threadId: params.threadId, identity: params.identity, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, deps: params.deps, silent: params.silent, mediaLocalRoots: params.mediaLocalRoots, @@ -245,6 +247,7 @@ type DeliverOutboundPayloadsCoreParams = { identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; + forceDocument?: boolean; abortSignal?: AbortSignal; bestEffort?: boolean; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; @@ -489,6 +492,7 @@ export async function deliverOutboundPayloads( replyToId: params.replyToId, bestEffort: params.bestEffort, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mirror: params.mirror, }).catch(() => null); // Best-effort — don't block delivery if queue write fails. @@ -557,6 +561,7 @@ async function deliverOutboundPayloadsCore( threadId: params.threadId, identity: params.identity, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mediaLocalRoots, }); diff --git a/src/infra/outbound/delivery-queue.ts b/src/infra/outbound/delivery-queue.ts index 97c37f911e4..e0d7abcb9ee 100644 --- a/src/infra/outbound/delivery-queue.ts +++ b/src/infra/outbound/delivery-queue.ts @@ -33,6 +33,7 @@ type QueuedDeliveryPayload = { replyToId?: string | null; bestEffort?: boolean; gifPlayback?: boolean; + forceDocument?: boolean; silent?: boolean; mirror?: OutboundMirror; }; @@ -117,6 +118,7 @@ export async function enqueueDelivery( replyToId: params.replyToId, bestEffort: params.bestEffort, gifPlayback: params.gifPlayback, + forceDocument: params.forceDocument, silent: params.silent, mirror: params.mirror, retryCount: 0, @@ -379,6 +381,7 @@ export async function recoverPendingDeliveries(opts: { replyToId: entry.replyToId, bestEffort: entry.bestEffort, gifPlayback: entry.gifPlayback, + forceDocument: entry.forceDocument, silent: entry.silent, mirror: entry.mirror, skipQueue: true, // Prevent re-enqueueing during recovery diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index c703cd34d24..0b6ad1ba16e 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -478,6 +478,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { expect(resolved.to).toBe("63448508"); }); - const resolveHeartbeatTarget = ( - entry: Parameters[0]["entry"], - directPolicy?: "allow" | "block", - ) => + const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") => resolveHeartbeatDeliveryTarget({ cfg: {}, entry, @@ -341,7 +339,7 @@ describe("resolveSessionDeliveryTarget", () => { const expectHeartbeatTarget = (params: { name: string; - entry: Parameters[0]["entry"]; + entry: SessionEntry; directPolicy?: "allow" | "block"; expectedChannel: string; expectedTo?: string; diff --git a/src/media/load-options.ts b/src/media/load-options.ts index 69400e98ffb..da4545ae10e 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -1,11 +1,13 @@ export type OutboundMediaLoadParams = { maxBytes?: number; mediaLocalRoots?: readonly string[]; + optimizeImages?: boolean; }; export type OutboundMediaLoadOptions = { maxBytes?: number; localRoots?: readonly string[]; + optimizeImages?: boolean; }; export function resolveOutboundMediaLocalRoots( @@ -21,5 +23,6 @@ export function buildOutboundMediaLoadOptions( return { ...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}), ...(localRoots ? { localRoots } : {}), + ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), }; }