fix(slack): split media and block action sends

This commit is contained in:
Peter Steinberger
2026-05-02 03:58:09 +01:00
parent 689a1cd21d
commit 2dfa2663ec
5 changed files with 106 additions and 23 deletions

View File

@@ -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 `<!subteam^...>` 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.

View File

@@ -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([

View File

@@ -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(

View File

@@ -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();

View File

@@ -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(