mirror of https://github.com/openclaw/openclaw.git
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:
parent
40c81e9cd3
commit
0ee11d3321
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue