mirror of https://github.com/openclaw/openclaw.git
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:
parent
133cce23ce
commit
d039add663
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue