feat: add --force-document to message.send for Telegram (bypass sendPhoto + image optimizer) (#45111)

* feat: add --force-document to message.send for Telegram

Adds --force-document CLI flag to bypass sendPhoto and use sendDocument
instead, avoiding Telegram image compression for PNG/image files.

- TelegramSendOpts: add forceDocument field
- send.ts: skip sendPhoto when forceDocument=true (mediaSender pattern)
- ChannelOutboundContext: add forceDocument field
- telegramOutbound.sendMedia: pass forceDocument to sendMessageTelegram
- ChannelHandlerParams / DeliverOutboundPayloadsCoreParams: add forceDocument
- createChannelOutboundContextBase: propagate forceDocument
- outbound-send-service.ts: add forceDocument to executeSendAction params
- message-action-runner.ts: read forceDocument from params
- message.ts: add forceDocument to MessageSendParams
- register.send.ts: add --force-document CLI option

* fix: pass forceDocument through telegram action dispatch path

The actual send path goes through dispatchChannelMessageAction ->
telegramMessageActions.handleAction -> handleTelegramAction, not
deliverOutboundPayloads. forceDocument was not being read in
readTelegramSendParams or passed to sendMessageTelegram.

* fix: apply forceDocument to GIF branch to avoid sendAnimation

* fix: add disable_content_type_detection=true to sendDocument for --force-document

* fix: add forceDocument to buildSendSchema for agent discoverability

* fix: scope telegram force-document detection

* test: fix heartbeat target helper typing

* fix: skip image optimization when forceDocument is set

* fix: persist forceDocument in WAL queue for crash-recovery replay

* test: tighten heartbeat target test entry typing

---------

Co-authored-by: thepagent <thepagent@users.noreply.github.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
thepagent 2026-03-14 07:43:49 -04:00 committed by GitHub
parent 40c81e9cd3
commit 0ee11d3321
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 129 additions and 8 deletions

View File

@ -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:<id>` 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

View File

@ -38,6 +38,7 @@ function readTelegramSendParams(params: Record<string, unknown>) {
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<string, unknown>) {
buttons,
asVoice,
silent,
forceDocument,
quoteText: quoteText ?? undefined,
};
}

View File

@ -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 };
},

View File

@ -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;

View File

@ -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<string, unknown> | undefined) =>
@ -826,7 +829,7 @@ export async function sendMessageTelegram(
) as Promise<TelegramMessageLike>,
};
}
if (kind === "image") {
if (kind === "image" && !opts.forceDocument) {
return {
label: "photo",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
@ -893,7 +896,11 @@ export async function sendMessageTelegram(
api.sendDocument(
chatId,
file,
effectiveParams as Parameters<typeof api.sendDocument>[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<typeof api.sendDocument>[2],
) as Promise<TelegramMessageLike>,
};
})();

View File

@ -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(

View File

@ -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,

View File

@ -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;

View File

@ -24,6 +24,11 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--reply-to <id>", "Reply-to message id")
.option("--thread-id <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)",

View File

@ -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,
});

View File

@ -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

View File

@ -478,6 +478,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
}
params.message = message;
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
const forceDocument = readBooleanParam(params, "forceDocument") ?? false;
const bestEffort = readBooleanParam(params, "bestEffort");
const silent = readBooleanParam(params, "silent");
@ -547,6 +548,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
mediaUrl: mediaUrl || undefined,
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
gifPlayback,
forceDocument,
bestEffort: bestEffort ?? undefined,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId ?? undefined,

View File

@ -39,6 +39,7 @@ type MessageSendParams = {
mediaUrl?: string;
mediaUrls?: string[];
gifPlayback?: boolean;
forceDocument?: boolean;
accountId?: string;
replyToId?: string;
threadId?: string | number;
@ -245,6 +246,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
forceDocument: params.forceDocument,
deps: params.deps,
bestEffort: params.bestEffort,
abortSignal: params.abortSignal,

View File

@ -84,6 +84,7 @@ export async function executeSendAction(params: {
mediaUrl?: string;
mediaUrls?: string[];
gifPlayback?: boolean;
forceDocument?: boolean;
bestEffort?: boolean;
replyToId?: string;
threadId?: string | number;
@ -132,6 +133,7 @@ export async function executeSendAction(params: {
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
forceDocument: params.forceDocument,
dryRun: params.ctx.dryRun,
bestEffort: params.bestEffort ?? undefined,
deps: params.ctx.deps,

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import {
@ -326,10 +327,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.to).toBe("63448508");
});
const resolveHeartbeatTarget = (
entry: Parameters<typeof resolveHeartbeatDeliveryTarget>[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<typeof resolveHeartbeatDeliveryTarget>[0]["entry"];
entry: SessionEntry;
directPolicy?: "allow" | "block";
expectedChannel: string;
expectedTo?: string;

View File

@ -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 } : {}),
};
}