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
This commit is contained in:
Vincent Koc 2026-03-14 10:03:06 -07:00 committed by GitHub
parent 133cce23ce
commit d039add663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 93 additions and 14 deletions

View File

@ -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

View File

@ -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<void> => {
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(

View File

@ -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,
}),
);
});
});

View File

@ -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 {