From d039add6633568aaf02f457f9eaf72464a2aebe3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 10:03:06 -0700 Subject: [PATCH] Slack: preserve interactive reply blocks in DMs (#45890) * Slack: forward reply blocks in DM delivery * Slack: preserve reply blocks in preview finalization * Slack: cover block-only DM replies * Changelog: note Slack interactive reply fix --- CHANGELOG.md | 4 ++ .../src/monitor/message-handler/dispatch.ts | 40 ++++++++++++------ extensions/slack/src/monitor/replies.test.ts | 41 +++++++++++++++++++ extensions/slack/src/monitor/replies.ts | 22 +++++++++- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b11e1f03da9..70329bce0ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Docs: https://docs.openclaw.ai - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. +### Fixes + +- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. + ## 2026.3.13 ### Changes diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 17681de7890..43ee958bdda 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -11,7 +11,7 @@ import { resolveStorePath, updateLastRoute } from "../../../../../src/config/ses import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.js"; -import { reactSlackMessage, removeSlackReaction } from "../../actions.js"; +import { editSlackMessage, reactSlackMessage, removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { normalizeSlackOutboundText } from "../../format.js"; import { recordSlackThreadParticipation } from "../../sent-thread-cache.js"; @@ -24,7 +24,12 @@ import type { SlackStreamSession } from "../../streaming.js"; import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js"; import { resolveSlackThreadTargets } from "../../threading.js"; import { normalizeSlackAllowOwnerEntry } from "../allow-list.js"; -import { createSlackReplyDeliveryPlan, deliverReplies, resolveSlackThreadTs } from "../replies.js"; +import { + createSlackReplyDeliveryPlan, + deliverReplies, + readSlackReplyBlocks, + resolveSlackThreadTs, +} from "../replies.js"; import type { PreparedSlackMessage } from "./types.js"; function hasMedia(payload: ReplyPayload): boolean { @@ -245,7 +250,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }; const deliverWithStreaming = async (payload: ReplyPayload): Promise => { - if (streamFailed || hasMedia(payload) || !payload.text?.trim()) { + if ( + streamFailed || + hasMedia(payload) || + readSlackReplyBlocks(payload)?.length || + !payload.text?.trim() + ) { await deliverNormally(payload, streamSession?.threadTs); return; } @@ -302,28 +312,34 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const slackBlocks = readSlackReplyBlocks(payload); const draftMessageId = draftStream?.messageId(); const draftChannelId = draftStream?.channelId(); - const finalText = payload.text; + const finalText = payload.text ?? ""; + const trimmedFinalText = finalText.trim(); const canFinalizeViaPreviewEdit = previewStreamingEnabled && streamMode !== "status_final" && mediaCount === 0 && !payload.isError && - typeof finalText === "string" && - finalText.trim().length > 0 && + (trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) && typeof draftMessageId === "string" && typeof draftChannelId === "string"; if (canFinalizeViaPreviewEdit) { draftStream?.stop(); try { - await ctx.app.client.chat.update({ - token: ctx.botToken, - channel: draftChannelId, - ts: draftMessageId, - text: normalizeSlackOutboundText(finalText.trim()), - }); + await editSlackMessage( + draftChannelId, + draftMessageId, + normalizeSlackOutboundText(trimmedFinalText), + { + token: ctx.botToken, + accountId: account.accountId, + client: ctx.app.client, + ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), + }, + ); return; } catch (err) { logVerbose( diff --git a/extensions/slack/src/monitor/replies.test.ts b/extensions/slack/src/monitor/replies.test.ts index 3d0c3e4fc5a..50bf5e4026f 100644 --- a/extensions/slack/src/monitor/replies.test.ts +++ b/extensions/slack/src/monitor/replies.test.ts @@ -53,4 +53,45 @@ describe("deliverReplies identity passthrough", () => { expect(sendMock).toHaveBeenCalledOnce(); expect(sendMock.mock.calls[0][2]).not.toHaveProperty("identity"); }); + + it("delivers block-only replies through to sendMessageSlack", async () => { + sendMock.mockResolvedValue(undefined); + const blocks = [ + { + type: "actions", + elements: [ + { + type: "button", + action_id: "openclaw:reply_button", + text: { type: "plain_text", text: "Option A" }, + value: "reply_1_option_a", + }, + ], + }, + ]; + + await deliverReplies( + baseParams({ + replies: [ + { + text: "", + channelData: { + slack: { + blocks, + }, + }, + }, + ], + }), + ); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock).toHaveBeenCalledWith( + "C123", + "", + expect.objectContaining({ + blocks, + }), + ); + }); }); diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index deb3ccab571..885e71b7818 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -5,9 +5,22 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../../src/auto-repl import type { ReplyPayload } from "../../../../src/auto-reply/types.js"; import type { MarkdownTableMode } from "../../../../src/config/types.base.js"; import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { parseSlackBlocksInput } from "../blocks-input.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; +export function readSlackReplyBlocks(payload: ReplyPayload) { + const slackData = payload.channelData?.slack; + if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) { + return undefined; + } + try { + return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks); + } catch { + return undefined; + } +} + export async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -26,19 +39,24 @@ export async function deliverReplies(params: { const threadTs = inlineReplyToId ?? params.replyThreadTs; const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = payload.text ?? ""; - if (!text && mediaList.length === 0) { + const slackBlocks = readSlackReplyBlocks(payload); + if (!text && mediaList.length === 0 && !slackBlocks?.length) { continue; } if (mediaList.length === 0) { const trimmed = text.trim(); - if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + if (!trimmed && !slackBlocks?.length) { + continue; + } + if (trimmed && isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { continue; } await sendMessageSlack(params.target, trimmed, { token: params.token, threadTs, accountId: params.accountId, + ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); } else {