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.
|
- 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.
|
- 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
|
## 2026.3.13
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { resolveStorePath, updateLastRoute } from "../../../../../src/config/ses
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
|
||||||
import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js";
|
import { resolveAgentOutboundIdentity } from "../../../../../src/infra/outbound/identity.js";
|
||||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../../src/security/dm-policy-shared.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 { createSlackDraftStream } from "../../draft-stream.js";
|
||||||
import { normalizeSlackOutboundText } from "../../format.js";
|
import { normalizeSlackOutboundText } from "../../format.js";
|
||||||
import { recordSlackThreadParticipation } from "../../sent-thread-cache.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 { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
||||||
import { resolveSlackThreadTargets } from "../../threading.js";
|
import { resolveSlackThreadTargets } from "../../threading.js";
|
||||||
import { normalizeSlackAllowOwnerEntry } from "../allow-list.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";
|
import type { PreparedSlackMessage } from "./types.js";
|
||||||
|
|
||||||
function hasMedia(payload: ReplyPayload): boolean {
|
function hasMedia(payload: ReplyPayload): boolean {
|
||||||
|
|
@ -245,7 +250,12 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||||
};
|
};
|
||||||
|
|
||||||
const deliverWithStreaming = async (payload: ReplyPayload): Promise<void> => {
|
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);
|
await deliverNormally(payload, streamSession?.threadTs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -302,28 +312,34 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0);
|
||||||
|
const slackBlocks = readSlackReplyBlocks(payload);
|
||||||
const draftMessageId = draftStream?.messageId();
|
const draftMessageId = draftStream?.messageId();
|
||||||
const draftChannelId = draftStream?.channelId();
|
const draftChannelId = draftStream?.channelId();
|
||||||
const finalText = payload.text;
|
const finalText = payload.text ?? "";
|
||||||
|
const trimmedFinalText = finalText.trim();
|
||||||
const canFinalizeViaPreviewEdit =
|
const canFinalizeViaPreviewEdit =
|
||||||
previewStreamingEnabled &&
|
previewStreamingEnabled &&
|
||||||
streamMode !== "status_final" &&
|
streamMode !== "status_final" &&
|
||||||
mediaCount === 0 &&
|
mediaCount === 0 &&
|
||||||
!payload.isError &&
|
!payload.isError &&
|
||||||
typeof finalText === "string" &&
|
(trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) &&
|
||||||
finalText.trim().length > 0 &&
|
|
||||||
typeof draftMessageId === "string" &&
|
typeof draftMessageId === "string" &&
|
||||||
typeof draftChannelId === "string";
|
typeof draftChannelId === "string";
|
||||||
|
|
||||||
if (canFinalizeViaPreviewEdit) {
|
if (canFinalizeViaPreviewEdit) {
|
||||||
draftStream?.stop();
|
draftStream?.stop();
|
||||||
try {
|
try {
|
||||||
await ctx.app.client.chat.update({
|
await editSlackMessage(
|
||||||
token: ctx.botToken,
|
draftChannelId,
|
||||||
channel: draftChannelId,
|
draftMessageId,
|
||||||
ts: draftMessageId,
|
normalizeSlackOutboundText(trimmedFinalText),
|
||||||
text: normalizeSlackOutboundText(finalText.trim()),
|
{
|
||||||
});
|
token: ctx.botToken,
|
||||||
|
accountId: account.accountId,
|
||||||
|
client: ctx.app.client,
|
||||||
|
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,45 @@ describe("deliverReplies identity passthrough", () => {
|
||||||
expect(sendMock).toHaveBeenCalledOnce();
|
expect(sendMock).toHaveBeenCalledOnce();
|
||||||
expect(sendMock.mock.calls[0][2]).not.toHaveProperty("identity");
|
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 { ReplyPayload } from "../../../../src/auto-reply/types.js";
|
||||||
import type { MarkdownTableMode } from "../../../../src/config/types.base.js";
|
import type { MarkdownTableMode } from "../../../../src/config/types.base.js";
|
||||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||||
|
import { parseSlackBlocksInput } from "../blocks-input.js";
|
||||||
import { markdownToSlackMrkdwnChunks } from "../format.js";
|
import { markdownToSlackMrkdwnChunks } from "../format.js";
|
||||||
import { sendMessageSlack, type SlackSendIdentity } from "../send.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: {
|
export async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
target: string;
|
target: string;
|
||||||
|
|
@ -26,19 +39,24 @@ export async function deliverReplies(params: {
|
||||||
const threadTs = inlineReplyToId ?? params.replyThreadTs;
|
const threadTs = inlineReplyToId ?? params.replyThreadTs;
|
||||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
if (!text && mediaList.length === 0) {
|
const slackBlocks = readSlackReplyBlocks(payload);
|
||||||
|
if (!text && mediaList.length === 0 && !slackBlocks?.length) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
if (!trimmed && !slackBlocks?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed && isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await sendMessageSlack(params.target, trimmed, {
|
await sendMessageSlack(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
threadTs,
|
threadTs,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
|
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
|
||||||
...(params.identity ? { identity: params.identity } : {}),
|
...(params.identity ? { identity: params.identity } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue