diff --git a/src/agents/tools/slack-actions.e2e.test.ts b/src/agents/tools/slack-actions.e2e.test.ts index 94c51815040..227ac17660d 100644 --- a/src/agents/tools/slack-actions.e2e.test.ts +++ b/src/agents/tools/slack-actions.e2e.test.ts @@ -137,9 +137,76 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", { mediaUrl: undefined, threadTs: "1234567890.123456", + blocks: undefined, }); }); + it("accepts blocks JSON and allows empty content", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + blocks: JSON.stringify([ + { type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }, + ]), + }, + cfg, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", { + mediaUrl: undefined, + threadTs: undefined, + blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Deploy* status" } }], + }); + }); + + it("accepts blocks arrays directly", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + blocks: [{ type: "divider" }], + }, + cfg, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "", { + mediaUrl: undefined, + threadTs: undefined, + blocks: [{ type: "divider" }], + }); + }); + + it("rejects invalid blocks JSON", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + await expect( + handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + blocks: "{bad-json", + }, + cfg, + ), + ).rejects.toThrow(/blocks must be valid JSON/i); + }); + + it("requires at least one of content, blocks, or mediaUrl", async () => { + const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; + await expect( + handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "", + }, + cfg, + ), + ).rejects.toThrow(/requires content, blocks, or mediaUrl/i); + }); + it("auto-injects threadTs from context when replyToMode=all", async () => { const cfg = { channels: { slack: { botToken: "tok" } } } as OpenClawConfig; sendSlackMessage.mockClear(); @@ -159,6 +226,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Auto-threaded", { mediaUrl: undefined, threadTs: "1111111111.111111", + blocks: undefined, }); }); @@ -182,6 +250,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "First", { mediaUrl: undefined, threadTs: "1111111111.111111", + blocks: undefined, }); expect(hasRepliedRef.value).toBe(true); @@ -194,6 +263,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Second", { mediaUrl: undefined, threadTs: undefined, + blocks: undefined, }); }); @@ -221,6 +291,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Explicit", { mediaUrl: undefined, threadTs: "2222222222.222222", + blocks: undefined, }); expect(hasRepliedRef.value).toBe(true); @@ -232,6 +303,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "Second", { mediaUrl: undefined, threadTs: undefined, + blocks: undefined, }); }); @@ -247,6 +319,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { mediaUrl: undefined, threadTs: undefined, + blocks: undefined, }); }); @@ -269,6 +342,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { mediaUrl: undefined, threadTs: undefined, + blocks: undefined, }); }); @@ -291,6 +365,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C999", "Different channel", { mediaUrl: undefined, threadTs: undefined, + blocks: undefined, }); }); @@ -314,6 +389,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Explicit thread", { mediaUrl: undefined, threadTs: "2222222222.222222", + blocks: undefined, }); }); @@ -336,6 +412,7 @@ describe("handleSlackAction", () => { expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { mediaUrl: undefined, threadTs: "1111111111.111111", + blocks: undefined, }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 97198e3fe7e..49fcf7d17ab 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { Block, KnownBlock } from "@slack/web-api"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveSlackAccount } from "../../slack/accounts.js"; import { @@ -84,6 +85,27 @@ function resolveThreadTsFromContext( return undefined; } +function readSlackBlocksParam(params: Record) { + const raw = params.blocks; + if (raw == null) { + return undefined; + } + const parsed = + typeof raw === "string" + ? (() => { + try { + return JSON.parse(raw); + } catch { + throw new Error("blocks must be valid JSON"); + } + })() + : raw; + if (!Array.isArray(parsed)) { + throw new Error("blocks must be an array"); + } + return parsed as (Block | KnownBlock)[]; +} + export async function handleSlackAction( params: Record, cfg: OpenClawConfig, @@ -174,17 +196,22 @@ export async function handleSlackAction( switch (action) { case "sendMessage": { const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content", { required: true }); + const content = readStringParam(params, "content", { allowEmpty: true }); const mediaUrl = readStringParam(params, "mediaUrl"); + const blocks = readSlackBlocksParam(params); + if (!content && !mediaUrl && !blocks) { + throw new Error("Slack sendMessage requires content, blocks, or mediaUrl."); + } const threadTs = resolveThreadTsFromContext( readStringParam(params, "threadTs"), to, context, ); - const result = await sendSlackMessage(to, content, { + const result = await sendSlackMessage(to, content ?? "", { ...writeOpts, mediaUrl: mediaUrl ?? undefined, threadTs: threadTs ?? undefined, + blocks, }); // Keep "first" mode consistent even when the agent explicitly provided diff --git a/src/slack/actions.ts b/src/slack/actions.ts index f6ef345bd9a..53ffa257e11 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -1,4 +1,4 @@ -import type { WebClient } from "@slack/web-api"; +import type { Block, KnownBlock, WebClient } from "@slack/web-api"; import { loadConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { resolveSlackAccount } from "./accounts.js"; @@ -147,7 +147,11 @@ export async function listSlackReactions( export async function sendSlackMessage( to: string, content: string, - opts: SlackActionClientOpts & { mediaUrl?: string; threadTs?: string } = {}, + opts: SlackActionClientOpts & { + mediaUrl?: string; + threadTs?: string; + blocks?: (Block | KnownBlock)[]; + } = {}, ) { return await sendMessageSlack(to, content, { accountId: opts.accountId, @@ -155,6 +159,7 @@ export async function sendSlackMessage( mediaUrl: opts.mediaUrl, client: opts.client, threadTs: opts.threadTs, + blocks: opts.blocks, }); } diff --git a/src/slack/send.ts b/src/slack/send.ts index 34eac1732fa..733baa0d3ad 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -1,4 +1,9 @@ -import { type FilesUploadV2Arguments, type WebClient } from "@slack/web-api"; +import { + type Block, + type FilesUploadV2Arguments, + type KnownBlock, + type WebClient, +} from "@slack/web-api"; import type { SlackTokenSource } from "./accounts.js"; import { chunkMarkdownTextWithMode, @@ -41,6 +46,7 @@ type SlackSendOpts = { client?: WebClient; threadTs?: string; identity?: SlackSendIdentity; + blocks?: (Block | KnownBlock)[]; }; function hasCustomIdentity(identity?: SlackSendIdentity): boolean { @@ -79,11 +85,13 @@ async function postSlackMessageBestEffort(params: { text: string; threadTs?: string; identity?: SlackSendIdentity; + blocks?: (Block | KnownBlock)[]; }) { const basePayload = { channel: params.channelId, text: params.text, thread_ts: params.threadTs, + ...(params.blocks?.length ? { blocks: params.blocks } : {}), }; try { // Slack Web API types model icon_url and icon_emoji as mutually exclusive. @@ -214,8 +222,9 @@ export async function sendMessageSlack( opts: SlackSendOpts = {}, ): Promise { const trimmedMessage = message?.trim() ?? ""; - if (!trimmedMessage && !opts.mediaUrl) { - throw new Error("Slack send requires text or media"); + const blocks = opts.blocks?.length ? opts.blocks : undefined; + if (!trimmedMessage && !opts.mediaUrl && !blocks) { + throw new Error("Slack send requires text, blocks, or media"); } const cfg = loadConfig(); const account = resolveSlackAccount({ @@ -231,6 +240,23 @@ export async function sendMessageSlack( const client = opts.client ?? createSlackWebClient(token); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(client, recipient); + if (blocks) { + if (opts.mediaUrl) { + throw new Error("Slack send does not support blocks with mediaUrl"); + } + const response = await postSlackMessageBestEffort({ + client, + channelId, + text: trimmedMessage || " ", + threadTs: opts.threadTs, + identity: opts.identity, + blocks, + }); + return { + messageId: response.ts ?? "unknown", + channelId, + }; + } const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); const tableMode = resolveMarkdownTableMode({