diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 0476c901ae7..d212245ef59 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -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, diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 3a6787de58f..7bb4332cb8a 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -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({ diff --git a/src/auto-reply/reply/slack-directives.ts b/src/auto-reply/reply/slack-directives.ts index a899bbd39ff..d56b3af87c4 100644 --- a/src/auto-reply/reply/slack-directives.ts +++ b/src/auto-reply/reply/slack-directives.ts @@ -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; 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, diff --git a/src/slack/interactive-replies.test.ts b/src/slack/interactive-replies.test.ts new file mode 100644 index 00000000000..5222a4fc873 --- /dev/null +++ b/src/slack/interactive-replies.test.ts @@ -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); + }); +}); diff --git a/src/slack/interactive-replies.ts b/src/slack/interactive-replies.ts index 94ddaf921ab..399c186cfdc 100644 --- a/src/slack/interactive-replies.ts +++ b/src/slack/interactive-replies.ts @@ -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); }