Slack: fix interactive reply review findings

This commit is contained in:
Vincent Koc
2026-03-12 23:58:30 -04:00
parent df353a98f5
commit 357fa94893
5 changed files with 181 additions and 31 deletions

View File

@@ -135,6 +135,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = normalizeReplyPayloadInternal(payload, {
responsePrefix: options.responsePrefix,
enableSlackInteractiveReplies: options.enableSlackInteractiveReplies,
responsePrefixContext: options.responsePrefixContext,
responsePrefixContextProvider: options.responsePrefixContextProvider,
onHeartbeatStrip: options.onHeartbeatStrip,

View File

@@ -700,6 +700,70 @@ describe("parseSlackDirectives", () => {
expect(result.text).toBe("Act now");
expect(getSlackData(result).blocks).toEqual([
{ type: "divider" },
{
type: "section",
text: {
type: "mrkdwn",
text: "Act now",
},
},
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
elements: [
{
type: "button",
action_id: "openclaw:reply_button",
text: {
type: "plain_text",
text: "Retry",
emoji: true,
},
value: "retry",
},
],
},
]);
});
it("preserves authored order for mixed Slack directives", () => {
const result = parseSlackDirectives({
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
});
expect(getSlackData(result).blocks).toEqual([
{
type: "actions",
block_id: "openclaw_reply_select_1",
elements: [
{
type: "static_select",
action_id: "openclaw:reply_select",
placeholder: {
type: "plain_text",
text: "Pick one",
emoji: true,
},
options: [
{
text: {
type: "plain_text",
text: "Alpha",
emoji: true,
},
value: "alpha",
},
],
},
],
},
{
type: "section",
text: {
type: "mrkdwn",
text: "then",
},
},
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
@@ -1626,6 +1690,43 @@ describe("createReplyDispatcher", () => {
expect(onHeartbeatStrip).toHaveBeenCalledTimes(2);
});
it("compiles Slack directives in dispatcher flows when enabled", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const dispatcher = createReplyDispatcher({
deliver,
enableSlackInteractiveReplies: true,
});
expect(
dispatcher.sendFinalReply({
text: "Choose [[slack_buttons: Retry:retry]]",
}),
).toBe(true);
await dispatcher.waitForIdle();
expect(deliver).toHaveBeenCalledTimes(1);
expect(deliver.mock.calls[0]?.[0]).toMatchObject({
text: "Choose",
channelData: {
slack: {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "Choose",
},
},
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
},
],
},
},
});
});
it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const dispatcher = createReplyDispatcher({

View File

@@ -1,12 +1,11 @@
import { parseSlackBlocksInput } from "../../slack/blocks-input.js";
import type { ReplyPayload } from "../types.js";
const SLACK_BUTTONS_DIRECTIVE_RE = /\[\[slack_buttons:\s*([^\]]+)\]\]/gi;
const SLACK_SELECT_DIRECTIVE_RE = /\[\[slack_select:\s*([^\]]+)\]\]/gi;
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
type SlackBlock = Record<string, unknown>;
type SlackChannelData = {
@@ -127,9 +126,8 @@ function readExistingSlackBlocks(payload: ReplyPayload): SlackBlock[] {
}
export function hasSlackDirectives(text: string): boolean {
SLACK_BUTTONS_DIRECTIVE_RE.lastIndex = 0;
SLACK_SELECT_DIRECTIVE_RE.lastIndex = 0;
return SLACK_BUTTONS_DIRECTIVE_RE.test(text) || SLACK_SELECT_DIRECTIVE_RE.test(text);
SLACK_DIRECTIVE_RE.lastIndex = 0;
return SLACK_DIRECTIVE_RE.test(text);
}
export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
@@ -139,40 +137,51 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
}
const generatedBlocks: SlackBlock[] = [];
const visibleTextParts: string[] = [];
let buttonIndex = 0;
let selectIndex = 0;
let cursor = 0;
let matchedDirective = false;
let generatedInteractiveBlock = false;
SLACK_DIRECTIVE_RE.lastIndex = 0;
let cleanedText = text.replace(SLACK_BUTTONS_DIRECTIVE_RE, (_match, body: string) => {
buttonIndex += 1;
const block = buildButtonsBlock(body, buttonIndex);
for (const match of text.matchAll(SLACK_DIRECTIVE_RE)) {
matchedDirective = true;
const matchText = match[0];
const directiveType = match[1];
const body = match[2];
const index = match.index ?? 0;
const precedingText = text.slice(cursor, index);
visibleTextParts.push(precedingText);
const section = buildSectionBlock(precedingText);
if (section) {
generatedBlocks.push(section);
}
const block =
directiveType.toLowerCase() === "slack_buttons"
? buildButtonsBlock(body, ++buttonIndex)
: buildSelectBlock(body, ++selectIndex);
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
}
return "";
});
cursor = index + matchText.length;
}
cleanedText = cleanedText.replace(SLACK_SELECT_DIRECTIVE_RE, (_match, body: string) => {
selectIndex += 1;
const block = buildSelectBlock(body, selectIndex);
if (block) {
generatedBlocks.push(block);
}
return "";
});
const trailingText = text.slice(cursor);
visibleTextParts.push(trailingText);
const trailingSection = buildSectionBlock(trailingText);
if (trailingSection) {
generatedBlocks.push(trailingSection);
}
const cleanedText = visibleTextParts.join("");
if (generatedBlocks.length === 0) {
if (!matchedDirective || !generatedInteractiveBlock) {
return payload;
}
const existingBlocks = readExistingSlackBlocks(payload);
const nextBlocks = [...existingBlocks];
if (existingBlocks.length === 0) {
const section = buildSectionBlock(cleanedText);
if (section) {
nextBlocks.push(section);
}
}
nextBlocks.push(...generatedBlocks);
const nextBlocks = [...existingBlocks, ...generatedBlocks];
return {
...payload,

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
describe("isSlackInteractiveRepliesEnabled", () => {
it("fails closed when accountId is unknown and multiple accounts exist", () => {
const cfg = {
channels: {
slack: {
accounts: {
one: {
capabilities: { interactiveReplies: true },
},
two: {},
},
},
},
} as OpenClawConfig;
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(false);
});
it("uses the only configured account when accountId is unknown", () => {
const cfg = {
channels: {
slack: {
accounts: {
only: {
capabilities: { interactiveReplies: true },
},
},
},
},
} as OpenClawConfig;
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true);
});
});

View File

@@ -28,8 +28,9 @@ export function isSlackInteractiveRepliesEnabled(params: {
if (accountIds.length === 0) {
return resolveInteractiveRepliesFromCapabilities(params.cfg.channels?.slack?.capabilities);
}
return accountIds.some((accountId) => {
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
});
if (accountIds.length > 1) {
return false;
}
const account = resolveSlackAccount({ cfg: params.cfg, accountId: accountIds[0] });
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}