From 2dfa2663ecff52d953b5d9bf228813fdfe6c890a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:58:09 +0100 Subject: [PATCH] fix(slack): split media and block action sends --- CHANGELOG.md | 1 + extensions/slack/src/action-runtime.test.ts | 55 ++++++++++++++----- extensions/slack/src/action-runtime.ts | 26 ++++++--- .../slack/src/message-action-dispatch.test.ts | 44 +++++++++++++++ .../slack/src/message-action-dispatch.ts | 3 - 5 files changed, 106 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9def1532f..fdf7ed821af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc. - Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129. - Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc. +- Slack/message actions: send media before the follow-up Block Kit message when Slack `send` includes a file plus presentation or interactive controls, so file attachments are no longer rejected. Fixes #51458. Thanks @HirokiKobayashi-R. - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars. - Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97. diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 191218b63c4..cc0dcac4e70 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -427,19 +427,48 @@ describe("handleSlackAction", () => { ); }); - it("rejects blocks combined with mediaUrl", async () => { - await expect( - handleSlackAction( - { - action: "sendMessage", - to: "channel:C123", - content: "hello", - mediaUrl: "https://example.com/file.png", - blocks: JSON.stringify([{ type: "divider" }]), - }, - slackConfig(), - ), - ).rejects.toThrow(/does not support blocks with mediaUrl/i); + it("sends media before a separate blocks message", async () => { + sendSlackMessage.mockResolvedValueOnce({ channelId: "C123" }); + sendSlackMessage.mockResolvedValueOnce({ channelId: "C123" }); + + const result = await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "hello", + mediaUrl: "https://example.com/file.png", + blocks: JSON.stringify([{ type: "divider" }]), + }, + slackConfig(), + ); + + expect(sendSlackMessage).toHaveBeenCalledTimes(2); + expect(sendSlackMessage).toHaveBeenNthCalledWith( + 1, + "channel:C123", + "", + expect.objectContaining({ + cfg: expect.any(Object), + mediaUrl: "https://example.com/file.png", + threadTs: undefined, + }), + ); + expect(sendSlackMessage.mock.calls[0]?.[2]).not.toHaveProperty("blocks"); + expect(sendSlackMessage).toHaveBeenNthCalledWith( + 2, + "channel:C123", + "hello", + expect.objectContaining({ + cfg: expect.any(Object), + blocks: [{ type: "divider" }], + threadTs: undefined, + }), + ); + expect(sendSlackMessage.mock.calls[1]?.[2]).not.toHaveProperty("mediaUrl"); + expect(result.details).toEqual({ + ok: true, + result: { channelId: "C123" }, + }); }); it.each([ diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 23dd5d23389..28e798fc5d0 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -244,22 +244,34 @@ export async function handleSlackAction( if (!content && !mediaUrl && !blocks) { throw new Error("Slack sendMessage requires content, blocks, or mediaUrl."); } - if (mediaUrl && blocks) { - throw new Error("Slack sendMessage does not support blocks with mediaUrl."); - } const threadTs = resolveThreadTsFromContext( readStringParam(params, "threadTs"), to, context, ); - const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", { + const sendOpts = { ...writeOpts, - mediaUrl: mediaUrl ?? undefined, mediaLocalRoots: context?.mediaLocalRoots, mediaReadFile: context?.mediaReadFile, threadTs: threadTs ?? undefined, - blocks, - }); + }; + const result = + mediaUrl && blocks + ? await (async () => { + await slackActionRuntime.sendSlackMessage(to, "", { + ...sendOpts, + mediaUrl, + }); + return await slackActionRuntime.sendSlackMessage(to, content ?? "", { + ...sendOpts, + blocks, + }); + })() + : await slackActionRuntime.sendSlackMessage(to, content ?? "", { + ...sendOpts, + mediaUrl: mediaUrl ?? undefined, + blocks, + }); if (threadTs && result.channelId && account.accountId) { slackActionRuntime.recordSlackThreadParticipation( diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 7b7442f8f04..b888de9a170 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -99,6 +99,50 @@ describe("handleSlackMessageAction", () => { ]); }); + it("passes media and rendered interactive blocks through for split Slack delivery", async () => { + const invoke = createInvokeSpy(); + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action: "send", + cfg: {}, + params: { + to: "channel:C1", + message: "Approval required", + media: "https://example.com/report.md", + interactive: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Approve", value: "approve" }], + }, + ], + }, + }, + } as never, + invoke: invoke as never, + }); + + expect(invoke).toHaveBeenCalledOnce(); + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "channel:C1", + content: "Approval required", + mediaUrl: "https://example.com/report.md", + blocks: [ + expect.objectContaining({ + type: "actions", + elements: [expect.objectContaining({ value: "approve" })], + }), + ], + }), + expect.any(Object), + undefined, + ); + }); + it("maps upload-file to the internal uploadFile action", async () => { const invoke = createInvokeSpy(); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index f85539d88c1..ff96e3655a8 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -58,9 +58,6 @@ export async function handleSlackMessageAction(params: { if (!content && !mediaUrl && !blocks) { throw new Error("Slack send requires message, blocks, or media."); } - if (mediaUrl && blocks) { - throw new Error("Slack send does not support blocks with media."); - } const threadId = readStringParam(actionParams, "threadId"); const replyTo = readStringParam(actionParams, "replyTo"); return await invoke(