diff --git a/CHANGELOG.md b/CHANGELOG.md index 2992b5a29..34b250f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff +- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc. ### Fixes diff --git a/src/channels/plugins/outbound/slack.sendpayload.test.ts b/src/channels/plugins/outbound/slack.sendpayload.test.ts index 374c9881a..8c6b08062 100644 --- a/src/channels/plugins/outbound/slack.sendpayload.test.ts +++ b/src/channels/plugins/outbound/slack.sendpayload.test.ts @@ -1,4 +1,4 @@ -import { describe, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { ReplyPayload } from "../../../auto-reply/types.js"; import { installSendPayloadContractSuite, @@ -38,4 +38,66 @@ describe("slackOutbound sendPayload", () => { chunking: { mode: "passthrough", longTextLength: 5000 }, createHarness, }); + + it("forwards Slack blocks from channelData", async () => { + const { run, sendMock, to } = createHarness({ + payload: { + text: "Fallback summary", + channelData: { + slack: { + blocks: [{ type: "divider" }], + }, + }, + }, + }); + + const result = await run(); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock).toHaveBeenCalledWith( + to, + "Fallback summary", + expect.objectContaining({ + blocks: [{ type: "divider" }], + }), + ); + expect(result).toMatchObject({ channel: "slack", messageId: "sl-1" }); + }); + + it("accepts blocks encoded as JSON strings in Slack channelData", async () => { + const { run, sendMock, to } = createHarness({ + payload: { + channelData: { + slack: { + blocks: '[{"type":"section","text":{"type":"mrkdwn","text":"hello"}}]', + }, + }, + }, + }); + + await run(); + + expect(sendMock).toHaveBeenCalledWith( + to, + "", + expect.objectContaining({ + blocks: [{ type: "section", text: { type: "mrkdwn", text: "hello" } }], + }), + ); + }); + + it("rejects invalid Slack blocks from channelData", async () => { + const { run, sendMock } = createHarness({ + payload: { + channelData: { + slack: { + blocks: {}, + }, + }, + }, + }); + + await expect(run()).rejects.toThrow(/blocks must be an array/i); + expect(sendMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 1c14cc374..ae29f988a 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,5 +1,6 @@ import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; +import { parseSlackBlocksInput } from "../../../slack/blocks-input.js"; import { sendMessageSlack, type SlackSendIdentity } from "../../../slack/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { sendTextMediaPayload } from "./direct-text-media.js"; @@ -53,6 +54,7 @@ async function sendSlackOutboundMessage(params: { text: string; mediaUrl?: string; mediaLocalRoots?: readonly string[]; + blocks?: Parameters[2]["blocks"]; accountId?: string | null; deps?: { sendSlack?: typeof sendMessageSlack } | null; replyToId?: string | null; @@ -87,17 +89,43 @@ async function sendSlackOutboundMessage(params: { ...(params.mediaUrl ? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots } : {}), + ...(params.blocks ? { blocks: params.blocks } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), }); return { channel: "slack" as const, ...result }; } +function resolveSlackBlocks(channelData: Record | undefined) { + const slackData = channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return undefined; + } + return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks); +} + export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendPayload: async (ctx) => - await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }), + sendPayload: async (ctx) => { + const blocks = resolveSlackBlocks(ctx.payload.channelData); + if (!blocks) { + return await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound }); + } + return await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: ctx.payload.text ?? "", + mediaUrl: ctx.payload.mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }); + }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { return await sendSlackOutboundMessage({ cfg,