From da3ce0a1b6224777750cbb194fdaec169e07aa46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 10 May 2026 16:28:08 +0100 Subject: [PATCH] fix(slack): normalize direct interactive sends Co-authored-by: Kazuhiko Kazama --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/slack/src/channel.test.ts | 34 +++++++++++++ extensions/slack/src/channel.ts | 4 ++ src/channels/plugins/outbound.types.ts | 8 ++- .../channel-outbound-send.test.ts | 43 ++++++++++++++++ src/cli/send-runtime/channel-outbound-send.ts | 14 ++++++ src/infra/outbound/deliver.test.ts | 50 +++++++++++++++++++ src/infra/outbound/deliver.ts | 7 ++- 9 files changed, 161 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a4c197c6d..cd0d980ee7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Slack: add scoped message-tool formatting hints so agents use Markdown for plain sends and direct mrkdwn for Block Kit fields. Fixes #34609. (#50979) Thanks @carrotRakko. - Slack: describe `download-file` file ids separately from message timestamps and return a targeted recovery error when agents pass `messageId` instead of `fileId`. (#74155) Thanks @jarvis-ai-gregmoser. - Slack: retain processed room messages for `requireMention=false` channels so always-on Slack rooms keep recent conversation context between turns. (#38658) Thanks @syedamaann. +- Slack: compile interactive reply directives for direct outbound sends without bypassing the `interactiveReplies` capability gate, preserving Block Kit for Slack CLI and cron deliveries. (#78220) Thanks @kazamak. - Gateway/agents: keep structured reasons when active-run queueing fails and deprecate the legacy boolean queue helper, so steering and subagent wake diagnostics distinguish completed, non-streaming, and compacting runs. Fixes #80156. Thanks @markus-lassfolk. - Agents/UI: compact exec and tool progress rows by hiding redundant shell tool names, replacing known workspace paths with short context markers, and preserving Discord trace scrubbing for compact command lines. - ACPX: run and await the embedded ACP backend startup probe by default so the gateway `ready` signal no longer fires before the acpx runtime has either become usable or reported a probe failure; set `OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE=0` to restore lazy startup. Fixes #79596. Thanks @bzelones. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 5b3d33ac8d3..a886f7e74ad 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -0fff629c16bf8d215832c7526a1cf78877a4a97e696cfd1d932023905ae4cce9 plugin-sdk-api-baseline.json -5f0862e41455f46c0f68ca8ccd322a76a7b5f4ff50c3dd743904705c0c943cba plugin-sdk-api-baseline.jsonl +5219fe6237bdf573740840ef3c29631a8465abb73dd60128fb94589384ae2a96 plugin-sdk-api-baseline.json +f63c9723859bb900cf636e5e3fc1349a48563e279ca09e01a7ffafad9efe72f8 plugin-sdk-api-baseline.jsonl diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index fb4c43ed59a..1c4b2405a84 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -821,6 +821,40 @@ describe("slackPlugin outbound", () => { expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); }); + it("normalizes slack button directives for direct outbound delivery", () => { + const normalized = slackPlugin.outbound?.normalizePayload?.({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { interactiveReplies: true }, + }, + }, + }, + accountId: "default", + payload: { + text: "Slack interactive minimal test\n[[slack_buttons: Test:test-value]]", + }, + }); + + expect(normalized).toEqual({ + text: "Slack interactive minimal test", + interactive: { + blocks: [ + { + type: "text", + text: "Slack interactive minimal test", + }, + { + type: "buttons", + buttons: [{ label: "Test", value: "test-value" }], + }, + ], + }, + }); + }); + it("sends block payload media first, then the final block message", async () => { const sendSlack = vi .fn() diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 73f543b6d48..cd27f30847d 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -378,6 +378,10 @@ const slackChannelOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: SLACK_TEXT_LIMIT, + normalizePayload: ({ payload, cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? compileSlackInteractiveReplies(payload) + : payload, deliveryCapabilities: { durableFinal: { text: true, diff --git a/src/channels/plugins/outbound.types.ts b/src/channels/plugins/outbound.types.ts index 5b682b9ef66..4a182929dcf 100644 --- a/src/channels/plugins/outbound.types.ts +++ b/src/channels/plugins/outbound.types.ts @@ -90,6 +90,12 @@ export type ChannelOutboundChunkContext = { formatting?: OutboundDeliveryFormattingOptions; }; +export type ChannelOutboundNormalizePayloadParams = { + payload: ReplyPayload; + cfg: OpenClawConfig; + accountId?: string | null; +}; + export type ChannelOutboundAdapter = { deliveryMode: "direct" | "gateway" | "hybrid"; chunker?: ((text: string, limit: number, ctx?: ChannelOutboundChunkContext) => string[]) | null; @@ -101,7 +107,7 @@ export type ChannelOutboundAdapter = { pollMaxOptions?: number; supportsPollDurationSeconds?: boolean; supportsAnonymousPolls?: boolean; - normalizePayload?: (params: { payload: ReplyPayload }) => ReplyPayload | null; + normalizePayload?: (params: ChannelOutboundNormalizePayloadParams) => ReplyPayload | null; sendTextOnlyErrorPayloads?: boolean; shouldSkipPlainTextSanitization?: (params: { payload: ReplyPayload }) => boolean; resolveEffectiveTextChunkLimit?: (params: { diff --git a/src/cli/send-runtime/channel-outbound-send.test.ts b/src/cli/send-runtime/channel-outbound-send.test.ts index 712fabb3046..8e13534dd1c 100644 --- a/src/cli/send-runtime/channel-outbound-send.test.ts +++ b/src/cli/send-runtime/channel-outbound-send.test.ts @@ -86,6 +86,49 @@ describe("createChannelOutboundRuntimeSend", () => { ); }); + it("routes block sends through payload delivery", async () => { + const sendPayload = vi.fn(async () => ({ channel: "slack", messageId: "slack-blocks" })); + const sendText = vi.fn(); + mocks.loadChannelOutboundAdapter.mockResolvedValue({ + sendPayload, + sendText, + }); + + const { createChannelOutboundRuntimeSend } = await import("./channel-outbound-send.js"); + const runtimeSend = createChannelOutboundRuntimeSend({ + channelId: "slack" as never, + unavailableMessage: "unavailable", + }); + const blocks = [ + { + type: "actions", + elements: [{ type: "button", text: { type: "plain_text", text: "OK" }, value: "ok" }], + }, + ]; + + await runtimeSend.sendMessage("C123", "fallback", { + cfg: {}, + accountId: "default", + blocks, + }); + + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "default", + cfg: {}, + payload: { + channelData: { + slack: { blocks }, + }, + text: "fallback", + }, + text: "fallback", + to: "C123", + }), + ); + expect(sendText).not.toHaveBeenCalled(); + }); + it("accepts plugin outbound thread and reply aliases", async () => { const sendText = vi.fn(async () => ({ channel: "matrix", messageId: "$reply" })); mocks.loadChannelOutboundAdapter.mockResolvedValue({ diff --git a/src/cli/send-runtime/channel-outbound-send.ts b/src/cli/send-runtime/channel-outbound-send.ts index 47d7c0c44fe..2fdcb097509 100644 --- a/src/cli/send-runtime/channel-outbound-send.ts +++ b/src/cli/send-runtime/channel-outbound-send.ts @@ -7,6 +7,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; type RuntimeSendOpts = { cfg?: OpenClawConfig; + blocks?: unknown; mediaUrl?: string; mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; @@ -58,6 +59,19 @@ export function createChannelOutboundRuntimeSend(params: { gatewayClientScopes: opts.gatewayClientScopes, }); const hasMedia = Boolean(opts.mediaUrl); + if (opts.blocks && outbound?.sendPayload) { + return await outbound.sendPayload({ + ...buildContext(), + payload: { + text, + channelData: { + [params.channelId]: { + blocks: opts.blocks, + }, + }, + }, + }); + } if (hasMedia && outbound?.sendMedia) { return await outbound.sendMedia(buildContext()); } diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index c063da06c80..001822967f5 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1515,6 +1515,56 @@ describe("deliverOutboundPayloads", () => { expect(deliveredPayload?.channelData).toStrictEqual({ copiedText: "visible" }); }); + it("passes delivery config and account context to adapter payload normalization", async () => { + const normalizePayload = vi.fn(({ payload }) => ({ + ...payload, + channelData: { normalized: true }, + })); + const sendPayload = vi.fn().mockResolvedValue({ + channel: "matrix" as const, + messageId: "context", + roomId: "!room", + }); + const cfg = { channels: { matrix: { enabled: true } } } as unknown as OpenClawConfig; + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: createOutboundTestPlugin({ + id: "matrix", + outbound: { + deliveryMode: "direct", + normalizePayload, + sendText: vi.fn(), + sendMedia: vi.fn(), + sendPayload, + }, + }), + }, + ]), + ); + + await deliverOutboundPayloads({ + cfg, + channel: "matrix", + to: "!room", + accountId: "workspace-a", + payloads: [{ text: "visible" }], + }); + + expect(normalizePayload).toHaveBeenCalledWith({ + accountId: "workspace-a", + cfg, + payload: expect.objectContaining({ text: "visible" }), + }); + expect(sendPayload).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ channelData: { normalized: true } }), + }), + ); + }); + it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => { const sendPayload = vi.fn().mockResolvedValue({ channel: "matrix" as const, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index b8d930ec397..1c26b49cf31 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -372,7 +372,12 @@ function createPluginHandler( ? (payload) => outbound.sanitizeText!({ text: payload.text ?? "", payload }) : undefined, normalizePayload: outbound?.normalizePayload - ? (payload) => outbound.normalizePayload!({ payload }) + ? (payload) => + outbound.normalizePayload!({ + payload, + cfg: params.cfg, + accountId: params.accountId, + }) : undefined, sendTextOnlyErrorPayloads: outbound?.sendTextOnlyErrorPayloads === true, renderPresentation: outbound?.renderPresentation