diff --git a/docs/cli/message.md b/docs/cli/message.md index 77dc8025ee46..c7799991afc6 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -98,8 +98,9 @@ true}`. `--pin` is shorthand for pinned delivery when the channel supports it. - `--reply-to `, `--thread-id ` (Telegram forum topic; Slack thread timestamp, same field as `--reply-to`). -- `--force-document` (Telegram, WhatsApp): send images/GIFs/videos as - documents to avoid channel compression. +- `--force-document`: preserve original image bytes on Slack, or send + images/GIFs/videos as documents on Telegram and WhatsApp, to avoid channel + compression. - `--silent` (Telegram, Discord): send without a notification. - `--gif-playback` (WhatsApp only): treat video media as GIF playback. diff --git a/docs/nodes/images.md b/docs/nodes/images.md index fe77964adf6c..d32a4fcff9f7 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -23,7 +23,7 @@ portable formats, byte limits, and lazy transcoding, see - `--media ` — attach media (image/audio/video/document); accepts local paths or URLs. Optional; caption can be empty for media-only sends. - `--gif-playback` — treat video media as GIF playback (WhatsApp only). -- `--force-document` — send media as a document to avoid channel compression (Telegram, WhatsApp); applies to images, GIFs, and videos. +- `--force-document` — preserve original image bytes on Slack, or send images, GIFs, and videos as documents on Telegram and WhatsApp, to avoid channel compression. - `--reply-to `, `--thread-id `, `--pin`, `--silent` — delivery/threading options shared with text-only sends. - `--dry-run` — print the resolved payload and skip sending. - `--json` — print the result as JSON: `{ action, channel, dryRun, handledBy, messageId?, payload }` (`payload` carries the channel-specific send result, including any media reference). diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 8226dd1a3764..ddc1f560f05c 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -973,6 +973,37 @@ describe("handleSlackAction", () => { }); }); + it.each([ + { + name: "sendMessage", + params: { + action: "sendMessage", + to: "channel:C123", + content: "original image", + mediaUrl: "/tmp/original.png", + forceDocument: true, + }, + expectedTarget: "channel:C123", + }, + { + name: "workspace-qualified uploadFile", + params: { + action: "uploadFile", + to: "team:T123:channel:C123", + filePath: "/tmp/original.png", + initialComment: "original image", + forceDocument: true, + }, + expectedTarget: "team:T123:channel:C123", + }, + ] as const)("forwards forced-media intent for $name", async ({ params, expectedTarget }) => { + await handleSlackAction(params, slackConfig()); + + expectSlackSendCall(0, expectedTarget, "original image", { + forceDocument: true, + }); + }); + it.each([ { action: "sendMessage", diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index e6cb787549df..d8bd5f7372ae 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -673,6 +673,7 @@ export async function handleSlackAction( const replyBroadcast = readBooleanParam(params, "replyBroadcast"); const textIsSlackMrkdwn = readBooleanParam(params, "textIsSlackMrkdwn"); const textIsSlackPlainText = readBooleanParam(params, "textIsSlackPlainText"); + const forceDocument = readBooleanParam(params, "forceDocument") === true; const preparedMessages = context?.preparedMessages; const authoredTextPlacement = readStringParam(params, "authoredTextPlacement") as | "none" @@ -712,6 +713,7 @@ export async function handleSlackAction( mediaLocalRoots: context?.mediaLocalRoots, mediaReadFile: context?.mediaReadFile, threadTs: threadTs ?? undefined, + ...(forceDocument ? { forceDocument: true } : {}), }; const sendOpts = { ...baseSendOpts, @@ -807,6 +809,7 @@ export async function handleSlackAction( }); const filename = readStringParam(params, "filename"); const title = readStringParam(params, "title"); + const forceDocument = readBooleanParam(params, "forceDocument") === true; const replyBroadcast = readBooleanParam(params, "replyBroadcast"); if (replyBroadcast) { throw new Error( @@ -831,6 +834,7 @@ export async function handleSlackAction( mediaLocalRoots: context?.mediaLocalRoots, mediaReadFile: context?.mediaReadFile, threadTs: threadTs ?? undefined, + ...(forceDocument ? { forceDocument: true } : {}), ...(filename ? { uploadFileName: filename } : {}), ...(title ? { uploadTitle: title } : {}), }, diff --git a/extensions/slack/src/actions.ts b/extensions/slack/src/actions.ts index 4964e0e3fdd6..04c7fbb5dfdb 100644 --- a/extensions/slack/src/actions.ts +++ b/extensions/slack/src/actions.ts @@ -334,6 +334,7 @@ export async function sendSlackMessage( opts: Omit & { cfg: OpenClawConfig; mediaUrl?: string; + forceDocument?: boolean; mediaAccess?: { localRoots?: readonly string[]; readFile?: (filePath: string) => Promise; @@ -356,6 +357,7 @@ export async function sendSlackMessage( cfg: opts.cfg, token: opts.token, mediaUrl: opts.mediaUrl, + ...(opts.forceDocument ? { forceDocument: true } : {}), mediaAccess: opts.mediaAccess, mediaLocalRoots: opts.mediaLocalRoots, mediaReadFile: opts.mediaReadFile, diff --git a/extensions/slack/src/client-delivery.ts b/extensions/slack/src/client-delivery.ts index 79d9713dc3f9..f2555d541cd8 100644 --- a/extensions/slack/src/client-delivery.ts +++ b/extensions/slack/src/client-delivery.ts @@ -250,6 +250,7 @@ export async function uploadSlackFile(params: { uploadTitle?: string; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; + optimizeImages?: boolean; caption?: string; threadTs?: string; maxBytes?: number; @@ -261,6 +262,7 @@ export async function uploadSlackFile(params: { mediaAccess: params.mediaAccess, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, + ...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}), }); // Slack classifies previews by filename even when the upload body has a MIME type. const uploadFileName = diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 74e0f650c14d..9f2182d14e84 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -751,6 +751,49 @@ describe("handleSlackMessageAction", () => { expectNoForwardedToolContext(invoke); }); + it.each(["forceDocument", "asDocument"] as const)( + "normalizes %s for Slack send and upload-file", + async (propertyName) => { + const sendInvoke = createInvokeSpy(); + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: slackConfig(), + params: { + to: "channel:C1", + media: "/tmp/original.png", + [propertyName]: true, + }, + } as never, + invoke: sendInvoke as never, + }); + expect(firstAction(sendInvoke)).toMatchObject({ + action: "sendMessage", + forceDocument: true, + }); + + const uploadInvoke = createInvokeSpy(); + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "upload-file", + cfg: slackConfig(), + params: { + to: "channel:C1", + filePath: "/tmp/original.png", + [propertyName]: true, + }, + } as never, + invoke: uploadInvoke as never, + }); + expect(firstAction(uploadInvoke)).toMatchObject({ + action: "uploadFile", + forceDocument: true, + }); + }, + ); + it("rejects replyBroadcast for upload-file", async () => { await expect( handleSlackMessageAction({ diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index b2c65c5666a2..6e04d82d132c 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -31,6 +31,12 @@ type SlackActionInvoke = ( toolContext?: ChannelMessageActionContext["toolContext"], ) => Promise>; +function readSlackForceDocument(params: Record): boolean { + return ( + readBooleanParam(params, "forceDocument") ?? readBooleanParam(params, "asDocument") ?? false + ); +} + function resolveSlackPresentationText( content: string | undefined, presentation: ReturnType, @@ -131,6 +137,7 @@ export async function handleSlackMessageAction(params: { to, content: content ?? "", mediaUrl: mediaUrl ?? undefined, + ...(readSlackForceDocument(actionParams) ? { forceDocument: true } : {}), accountId, threadTs: threadId ?? replyTo ?? undefined, ...(topLevel ? { topLevel: true } : {}), @@ -355,6 +362,7 @@ export async function handleSlackMessageAction(params: { filename: readStringParam(actionParams, "filename"), title: readStringParam(actionParams, "title"), threadTs: threadId ?? undefined, + ...(readSlackForceDocument(actionParams) ? { forceDocument: true } : {}), ...(topLevel ? { topLevel: true } : {}), accountId, }, diff --git a/extensions/slack/src/message-tool-api.ts b/extensions/slack/src/message-tool-api.ts index 7a4092201a9c..bb296fc30ad2 100644 --- a/extensions/slack/src/message-tool-api.ts +++ b/extensions/slack/src/message-tool-api.ts @@ -32,6 +32,17 @@ function createSlackReactionEmojiSchema(): Record { }; } +function createSlackForcedMediaSchema(): Record { + const description = + "Preserve original image bytes without image optimization. Slack still uploads a regular file; this does not convert it into a Slack document."; + return { + forceDocument: Type.Optional(Type.Boolean({ description })), + asDocument: Type.Optional( + Type.Boolean({ description: `Alias for forceDocument. ${description}` }), + ), + }; +} + function createSlackMessageIdActionSchema(): Record { const description = 'Slack message timestamp/message id (for example "1777423717.666499"). Used by react, reactions, edit, delete, pin, and unpin actions. React defaults to the current inbound message when available. Not used by download-file, which requires fileId from event.files[].id.'; @@ -43,6 +54,7 @@ function createSlackMessageIdActionSchema(): Record { function createSlackSendActionSchema(): Record { return { + ...createSlackForcedMediaSchema(), topLevel: Type.Optional( Type.Boolean({ description: @@ -60,6 +72,7 @@ function createSlackSendActionSchema(): Record { function createSlackTopLevelActionSchema(): Record { return { + ...createSlackForcedMediaSchema(), topLevel: Type.Optional( Type.Boolean({ description: diff --git a/extensions/slack/src/message-tools.test.ts b/extensions/slack/src/message-tools.test.ts index 1e64d7f4afdc..eccffca34940 100644 --- a/extensions/slack/src/message-tools.test.ts +++ b/extensions/slack/src/message-tools.test.ts @@ -220,6 +220,18 @@ describe("Slack message tools", () => { ]); expect(discovery.capabilities).toEqual(["presentation"]); expect(Array.isArray(discovery.schema)).toBe(true); + const schemas = Array.isArray(discovery.schema) ? discovery.schema : []; + for (const propertyName of ["forceDocument", "asDocument"]) { + const entries = schemas.filter((entry) => propertyName in entry.properties); + expect(entries.map((entry) => entry.actions)).toEqual([["send"], ["upload-file"]]); + for (const entry of entries) { + const description = (entry.properties[propertyName] as { description?: string }) + .description; + expect(description).toMatch(/preserve original image bytes/i); + expect(description).toMatch(/without image optimization/i); + expect(description).toMatch(/not.*Slack document/i); + } + } }); it("honors account-scoped action gates", () => { diff --git a/extensions/slack/src/outbound-adapter.test.ts b/extensions/slack/src/outbound-adapter.test.ts index 8e04732943c6..6c1776c2d04c 100644 --- a/extensions/slack/src/outbound-adapter.test.ts +++ b/extensions/slack/src/outbound-adapter.test.ts @@ -92,6 +92,28 @@ describe("slackOutbound", () => { expect(result).toEqual({ channel: "slack", messageId: "m-final" }); }); + it("forwards forced-media intent through the core outbound adapter", async () => { + sendMessageSlackMock.mockResolvedValueOnce({ messageId: "m-media" }); + + await slackOutbound.sendMedia!({ + cfg, + to: "C123", + text: "original image", + mediaUrl: "https://example.com/original.png", + forceDocument: true, + accountId: "default", + }); + + expect(sendMessageSlackMock).toHaveBeenCalledWith( + "C123", + "original image", + expect.objectContaining({ + mediaUrl: "https://example.com/original.png", + forceDocument: true, + }), + ); + }); + it("renders channelData Slack blocks on payload sends", async () => { sendMessageSlackMock.mockResolvedValueOnce({ messageId: "m-blocks" }); diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index fee9d2b9ee04..bc4530c69a77 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -182,6 +182,7 @@ async function sendSlackOutboundMessage(params: { to: string; text: string; mediaUrl?: string; + forceDocument?: boolean; mediaAccess?: { localRoots?: readonly string[]; readFile?: (filePath: string) => Promise; @@ -227,6 +228,7 @@ async function sendSlackOutboundMessage(params: { mediaAccess: params.mediaAccess, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, + ...(params.forceDocument ? { forceDocument: true } : {}), } : {}), ...(params.blocks ? { blocks: params.blocks } : {}), diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index b8bf565f28e3..a1191a31faaa 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -113,6 +113,7 @@ type SlackSendOpts = { token?: string; accountId?: string; mediaUrl?: string; + forceDocument?: boolean; mediaAccess?: { localRoots?: readonly string[]; readFile?: (filePath: string) => Promise; @@ -1418,6 +1419,7 @@ async function sendMessageSlackQueuedInner(params: { caption: firstChunk, threadTs: opts.threadTs, maxBytes: mediaMaxBytes, + ...(opts.forceDocument ? { optimizeImages: false } : {}), onPlatformSendDispatch: dispatchOnce, ...(delivery.upload ? { auditContext: delivery.upload.auditContext } : {}), }); diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index 81790c15cbab..d3aa4436fbe8 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -276,6 +276,36 @@ describe("sendMessageSlack file upload with user IDs", () => { vi.restoreAllMocks(); }); + it("disables image optimization for forced-media uploads", async () => { + await sendUpload(client, { + mediaUrl: "/tmp/original.png", + forceDocument: true, + }); + + expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith( + "/tmp/original.png", + expect.objectContaining({ optimizeImages: false }), + ); + }); + + it.each([ + ["absent", undefined], + ["false", false], + ] as const)( + "keeps default image optimization when forced-media intent is %s", + async (_name, forceDocument) => { + await sendUpload(client, { + mediaUrl: "/tmp/optimized.png", + ...(forceDocument !== undefined ? { forceDocument } : {}), + }); + + const loadOptions = loadOutboundMediaFromUrlMock.mock.calls[0]?.[1] as + | { optimizeImages?: boolean } + | undefined; + expect(loadOptions?.optimizeImages).toBeUndefined(); + }, + ); + it.each([ { name: "resolves bare user ID to DM channel before completing upload", diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts index cd7683f014ed..3835631fa72b 100644 --- a/src/cli/program/message/register.send.ts +++ b/src/cli/program/message/register.send.ts @@ -31,7 +31,7 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false) .option( "--force-document", - "Send media as document to avoid channel compression (Telegram, WhatsApp). Applies to images, GIFs, and videos.", + "Preserve original image bytes on Slack, or send images, GIFs, and videos as documents on Telegram and WhatsApp, to avoid channel compression.", false, ) .option(