Slack: support Block Kit payloads in agent replies (#44592)
* Slack: route reply blocks through outbound adapter * Slack: cover Block Kit outbound payloads * Changelog: add Slack Block Kit agent reply entry
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof sendMessageSlack>[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<string, unknown> | 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,
|
||||
|
||||
Reference in New Issue
Block a user