fix(slack): preserve original bytes for forced media (#122667)

Punchcard-Session: crisp-valley-brook-8r
This commit is contained in:
Vincent Koc
2026-08-12 23:32:32 +08:00
committed by GitHub
parent 8060ef8937
commit d5f995c388
15 changed files with 176 additions and 4 deletions
+3 -2
View File
@@ -98,8 +98,9 @@ true}`. `--pin` is shorthand for pinned delivery when the channel supports
it.
- `--reply-to <id>`, `--thread-id <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.
+1 -1
View File
@@ -23,7 +23,7 @@ portable formats, byte limits, and lazy transcoding, see
- `--media <path-or-url>` — 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 <id>`, `--thread-id <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).
@@ -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",
+4
View File
@@ -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 } : {}),
},
+2
View File
@@ -334,6 +334,7 @@ export async function sendSlackMessage(
opts: Omit<SlackActionClientOpts, "cfg"> & {
cfg: OpenClawConfig;
mediaUrl?: string;
forceDocument?: boolean;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
@@ -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,
+2
View File
@@ -250,6 +250,7 @@ export async function uploadSlackFile(params: {
uploadTitle?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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 =
@@ -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({
@@ -31,6 +31,12 @@ type SlackActionInvoke = (
toolContext?: ChannelMessageActionContext["toolContext"],
) => Promise<AgentToolResult<unknown>>;
function readSlackForceDocument(params: Record<string, unknown>): boolean {
return (
readBooleanParam(params, "forceDocument") ?? readBooleanParam(params, "asDocument") ?? false
);
}
function resolveSlackPresentationText(
content: string | undefined,
presentation: ReturnType<typeof normalizeMessagePresentation>,
@@ -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,
},
+13
View File
@@ -32,6 +32,17 @@ function createSlackReactionEmojiSchema(): Record<string, TSchema> {
};
}
function createSlackForcedMediaSchema(): Record<string, TSchema> {
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<string, TSchema> {
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<string, TSchema> {
function createSlackSendActionSchema(): Record<string, TSchema> {
return {
...createSlackForcedMediaSchema(),
topLevel: Type.Optional(
Type.Boolean({
description:
@@ -60,6 +72,7 @@ function createSlackSendActionSchema(): Record<string, TSchema> {
function createSlackTopLevelActionSchema(): Record<string, TSchema> {
return {
...createSlackForcedMediaSchema(),
topLevel: Type.Optional(
Type.Boolean({
description:
@@ -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", () => {
@@ -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" });
+2
View File
@@ -182,6 +182,7 @@ async function sendSlackOutboundMessage(params: {
to: string;
text: string;
mediaUrl?: string;
forceDocument?: boolean;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
@@ -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 } : {}),
+2
View File
@@ -113,6 +113,7 @@ type SlackSendOpts = {
token?: string;
accountId?: string;
mediaUrl?: string;
forceDocument?: boolean;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
@@ -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 } : {}),
});
+30
View File
@@ -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",
+1 -1
View File
@@ -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(