mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(slack): preserve original bytes for forced media (#122667)
Punchcard-Session: crisp-valley-brook-8r
This commit is contained in:
+3
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user