diff --git a/CHANGELOG.md b/CHANGELOG.md index f486b910056..54a67b8a5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff - Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc. +- Slack/interactive replies: add opt-in Slack button and select reply directives behind `channels.slack.capabilities.interactiveReplies`, disabled by default unless explicitly enabled. (#44607) Thanks @vincentkoc. ### Fixes diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index ad6860d6f8d..b846d6e3cd7 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -137,6 +137,46 @@ describe("slackPlugin outbound", () => { }); }); +describe("slackPlugin agentPrompt", () => { + it("tells agents interactive replies are disabled by default", () => { + const hints = slackPlugin.agentPrompt?.messageToolHints?.({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + }, + }, + }); + + expect(hints).toEqual([ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ]); + }); + + it("shows Slack interactive reply directives when enabled", () => { + const hints = slackPlugin.agentPrompt?.messageToolHints?.({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + capabilities: { interactiveReplies: true }, + }, + }, + }, + }); + + expect(hints).toContain( + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + ); + expect(hints).toContain( + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ); + }); +}); + describe("slackPlugin config", () => { it("treats HTTP mode accounts with bot token + signing secret as configured", async () => { const cfg: OpenClawConfig = { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 570ef20ffa1..bd2b640c510 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -29,6 +29,7 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackReplyToMode, + isSlackInteractiveRepliesEnabled, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, buildSlackThreadingToolContext, @@ -146,6 +147,17 @@ export const slackPlugin: ChannelPlugin = { media: true, nativeCommands: true, }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 9aafb66bd34..793cbcc326f 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -12,11 +12,13 @@ import { resolveResponsePrefixTemplate, type ResponsePrefixContext, } from "./response-prefix-template.js"; +import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js"; export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; export type NormalizeReplyOptions = { responsePrefix?: string; + enableSlackInteractiveReplies?: boolean; /** Context for template variable interpolation in responsePrefix */ responsePrefixContext?: ResponsePrefixContext; onHeartbeatStrip?: () => void; @@ -105,5 +107,10 @@ export function normalizeReplyPayload( text = `${effectivePrefix} ${text}`; } - return { ...enrichedPayload, text }; + enrichedPayload = { ...enrichedPayload, text }; + if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) { + enrichedPayload = parseSlackDirectives(enrichedPayload); + } + + return enrichedPayload; } diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 7272a3081a2..d212245ef59 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -43,6 +43,7 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number { export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; responsePrefix?: string; + enableSlackInteractiveReplies?: boolean; /** Static context for response prefix template interpolation. */ responsePrefixContext?: ResponsePrefixContext; /** Dynamic context provider for response prefix template interpolation. @@ -84,7 +85,11 @@ export type ReplyDispatcher = { type NormalizeReplyPayloadInternalOptions = Pick< ReplyDispatcherOptions, - "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" + | "responsePrefix" + | "enableSlackInteractiveReplies" + | "responsePrefixContext" + | "responsePrefixContextProvider" + | "onHeartbeatStrip" > & { onSkip?: (reason: NormalizeReplySkipReason) => void; }; @@ -98,6 +103,7 @@ function normalizeReplyPayloadInternal( return normalizeReplyPayload(payload, { responsePrefix: opts.responsePrefix, + enableSlackInteractiveReplies: opts.enableSlackInteractiveReplies, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, onSkip: opts.onSkip, @@ -129,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 d0fd692c2e1..d7efa640b1c 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -16,6 +16,7 @@ import { } from "./queue.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; +import { parseSlackDirectives, hasSlackDirectives } from "./slack-directives.js"; describe("normalizeInboundTextNewlines", () => { it("normalizes real newlines and preserves literal backslash-n sequences", () => { @@ -196,6 +197,8 @@ describe("inbound context contract (providers + extensions)", () => { const getLineData = (result: ReturnType) => (result.channelData?.line as Record | undefined) ?? {}; +const getSlackData = (result: ReturnType) => + (result.channelData?.slack as Record | undefined) ?? {}; describe("hasLineDirectives", () => { it("matches expected detection across directive patterns", () => { @@ -219,6 +222,24 @@ describe("hasLineDirectives", () => { }); }); +describe("hasSlackDirectives", () => { + it("matches expected detection across Slack directive patterns", () => { + const cases: Array<{ text: string; expected: boolean }> = [ + { text: "Pick one [[slack_buttons: Approve:approve, Reject:reject]]", expected: true }, + { + text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]", + expected: true, + }, + { text: "Just regular text", expected: false }, + { text: "[[buttons: Menu | Choose | A:a]]", expected: false }, + ]; + + for (const testCase of cases) { + expect(hasSlackDirectives(testCase.text)).toBe(testCase.expected); + } + }); +}); + describe("parseLineDirectives", () => { describe("quick_replies", () => { it("parses quick replies variants", () => { @@ -579,6 +600,279 @@ describe("parseLineDirectives", () => { }); }); +describe("parseSlackDirectives", () => { + it("builds section and button blocks from slack_buttons directives", () => { + const result = parseSlackDirectives({ + text: "Choose an action [[slack_buttons: Approve:approve, Reject:reject]]", + }); + + expect(result.text).toBe("Choose an action"); + expect(getSlackData(result).blocks).toEqual([ + { + type: "section", + text: { + type: "mrkdwn", + text: "Choose an action", + }, + }, + { + type: "actions", + block_id: "openclaw_reply_buttons_1", + elements: [ + { + type: "button", + action_id: "openclaw:reply_button", + text: { + type: "plain_text", + text: "Approve", + emoji: true, + }, + value: "reply_1_approve", + }, + { + type: "button", + action_id: "openclaw:reply_button", + text: { + type: "plain_text", + text: "Reject", + emoji: true, + }, + value: "reply_2_reject", + }, + ], + }, + ]); + }); + + it("builds static select blocks from slack_select directives", () => { + const result = parseSlackDirectives({ + text: "[[slack_select: Choose a project | Alpha:alpha, Beta:beta]]", + }); + + expect(result.text).toBeUndefined(); + 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: "Choose a project", + emoji: true, + }, + options: [ + { + text: { + type: "plain_text", + text: "Alpha", + emoji: true, + }, + value: "reply_1_alpha", + }, + { + text: { + type: "plain_text", + text: "Beta", + emoji: true, + }, + value: "reply_2_beta", + }, + ], + }, + ], + }, + ]); + }); + + it("appends Slack interactive blocks to existing slack blocks", () => { + const result = parseSlackDirectives({ + text: "Act now [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: [{ type: "divider" }], + }, + }, + }); + + 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: "reply_1_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: "reply_1_alpha", + }, + ], + }, + ], + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "then", + }, + }, + { + 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: "reply_1_retry", + }, + ], + }, + ]); + }); + + it("truncates Slack interactive reply strings to safe Block Kit limits", () => { + const long = "x".repeat(120); + const result = parseSlackDirectives({ + text: `${"y".repeat(3100)} [[slack_select: ${long} | ${long}:${long}]] [[slack_buttons: ${long}:${long}]]`, + }); + + const blocks = getSlackData(result).blocks as Array>; + expect(blocks).toHaveLength(3); + expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000); + expect( + ( + ( + (blocks[1]?.elements as Array>)?.[0]?.placeholder as { + text?: string; + } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ( + ( + (blocks[1]?.elements as Array>)?.[0]?.options as Array< + Record + > + )?.[0]?.text as { text?: string } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + (( + (blocks[1]?.elements as Array>)?.[0]?.options as Array< + Record + > + )?.[0]?.value as string | undefined) ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ( + (blocks[2]?.elements as Array>)?.[0]?.text as { + text?: string; + } + )?.text ?? "" + ).length, + ).toBeLessThanOrEqual(75); + expect( + ( + ((blocks[2]?.elements as Array>)?.[0]?.value as + | string + | undefined) ?? "" + ).length, + ).toBeLessThanOrEqual(75); + }); + + it("falls back to the original payload when generated blocks would exceed Slack limits", () => { + const result = parseSlackDirectives({ + text: "Choose [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: Array.from({ length: 49 }, () => ({ type: "divider" })), + }, + }, + }); + + expect(result).toEqual({ + text: "Choose [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: Array.from({ length: 49 }, () => ({ type: "divider" })), + }, + }, + }); + }); + + it("ignores malformed existing Slack blocks during directive compilation", () => { + expect(() => + parseSlackDirectives({ + text: "Choose [[slack_buttons: Retry:retry]]", + channelData: { + slack: { + blocks: "{not json}", + }, + }, + }), + ).not.toThrow(); + }); +}); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -1485,6 +1779,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/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index c1e76e50403..88f092bf1e5 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -150,6 +150,67 @@ describe("normalizeReplyPayload", () => { expect(result!.text).toBe(""); expect(result!.mediaUrl).toBe("https://example.com/img.png"); }); + + it("does not compile Slack directives unless interactive replies are enabled", () => { + const result = normalizeReplyPayload({ + text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]", + }); + + expect(result).not.toBeNull(); + expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(result!.channelData).toBeUndefined(); + }); + + it("applies responsePrefix before compiling Slack directives into blocks", () => { + const result = normalizeReplyPayload( + { + text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]", + }, + { responsePrefix: "[bot]", enableSlackInteractiveReplies: true }, + ); + + expect(result).not.toBeNull(); + expect(result!.text).toBe("[bot] hello"); + expect(result!.channelData).toEqual({ + slack: { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "[bot] hello", + }, + }, + { + 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: "reply_1_retry", + }, + { + type: "button", + action_id: "openclaw:reply_button", + text: { + type: "plain_text", + text: "Ignore", + emoji: true, + }, + value: "reply_2_ignore", + }, + ], + }, + ], + }, + }); + }); }); describe("typing controller", () => { diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 9b5d432149a..5a0405da22b 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -201,6 +201,55 @@ describe("routeReply", () => { ); }); + it("routes directive-only Slack replies when interactive replies are enabled", async () => { + mocks.sendMessageSlack.mockClear(); + const cfg = { + channels: { + slack: { + capabilities: { interactiveReplies: true }, + }, + }, + } as unknown as OpenClawConfig; + await routeReply({ + payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" }, + channel: "slack", + to: "channel:C123", + cfg, + }); + expect(mocks.sendMessageSlack).toHaveBeenCalledWith( + "channel:C123", + "", + expect.objectContaining({ + blocks: [ + expect.objectContaining({ + type: "actions", + block_id: "openclaw_reply_select_1", + }), + ], + }), + ); + }); + + it("does not bypass the empty-reply guard for invalid Slack blocks", async () => { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload: { + text: " ", + channelData: { + slack: { + blocks: " ", + }, + }, + }, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + }); + + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + }); + it("does not derive responsePrefix from agent identity when routing", async () => { mocks.sendMessageSlack.mockClear(); const cfg = { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index a489bedcbbf..8b3319698b2 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,6 +12,8 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; +import { parseSlackBlocksInput } from "../../slack/blocks-input.js"; +import { isSlackInteractiveRepliesEnabled } from "../../slack/interactive-replies.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -94,6 +96,8 @@ export async function routeReply(params: RouteReplyParams): Promise; +type SlackChannelData = { + blocks?: unknown; +}; + +type SlackChoice = { + label: string; + value: string; +}; + +function truncateSlackText(value: string, max: number): string { + const trimmed = value.trim(); + if (trimmed.length <= max) { + return trimmed; + } + if (max <= 1) { + return trimmed.slice(0, max); + } + return `${trimmed.slice(0, max - 1)}…`; +} + +function parseChoice(raw: string): SlackChoice | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const delimiter = trimmed.indexOf(":"); + if (delimiter === -1) { + return { + label: trimmed, + value: trimmed, + }; + } + const label = trimmed.slice(0, delimiter).trim(); + const value = trimmed.slice(delimiter + 1).trim(); + if (!label || !value) { + return null; + } + return { label, value }; +} + +function parseChoices(raw: string, maxItems: number): SlackChoice[] { + return raw + .split(",") + .map((entry) => parseChoice(entry)) + .filter((entry): entry is SlackChoice => Boolean(entry)) + .slice(0, maxItems); +} + +function buildSlackReplyChoiceToken(value: string, index: number): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return truncateSlackText(`reply_${index}_${slug || "choice"}`, SLACK_OPTION_VALUE_MAX); +} + +function buildSectionBlock(text: string): SlackBlock | null { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + return { + type: "section", + text: { + type: "mrkdwn", + text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX), + }, + }; +} + +function buildButtonsBlock(raw: string, index: number): SlackBlock | null { + const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS); + if (choices.length === 0) { + return null; + } + return { + type: "actions", + block_id: `openclaw_reply_buttons_${index}`, + elements: choices.map((choice, choiceIndex) => ({ + type: "button", + action_id: SLACK_REPLY_BUTTON_ACTION_ID, + text: { + type: "plain_text", + text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX), + emoji: true, + }, + value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1), + })), + }; +} + +function buildSelectBlock(raw: string, index: number): SlackBlock | null { + const parts = raw + .split("|") + .map((entry) => entry.trim()) + .filter(Boolean); + if (parts.length === 0) { + return null; + } + const [first, second] = parts; + const placeholder = parts.length >= 2 ? first : "Choose an option"; + const choices = parseChoices(parts.length >= 2 ? second : first, SLACK_SELECT_MAX_ITEMS); + if (choices.length === 0) { + return null; + } + return { + type: "actions", + block_id: `openclaw_reply_select_${index}`, + elements: [ + { + type: "static_select", + action_id: SLACK_REPLY_SELECT_ACTION_ID, + placeholder: { + type: "plain_text", + text: truncateSlackText(placeholder, SLACK_PLAIN_TEXT_MAX), + emoji: true, + }, + options: choices.map((choice, choiceIndex) => ({ + text: { + type: "plain_text", + text: truncateSlackText(choice.label, SLACK_PLAIN_TEXT_MAX), + emoji: true, + }, + value: buildSlackReplyChoiceToken(choice.value, choiceIndex + 1), + })), + }, + ], + }; +} + +function readExistingSlackBlocks(payload: ReplyPayload): SlackBlock[] { + const slackData = payload.channelData?.slack as SlackChannelData | undefined; + try { + const blocks = parseSlackBlocksInput(slackData?.blocks) as SlackBlock[] | undefined; + return blocks ?? []; + } catch { + return []; + } +} + +export function hasSlackDirectives(text: string): boolean { + SLACK_DIRECTIVE_RE.lastIndex = 0; + return SLACK_DIRECTIVE_RE.test(text); +} + +export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload { + const text = payload.text; + if (!text) { + return payload; + } + + 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; + + 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); + } + cursor = index + matchText.length; + } + + const trailingText = text.slice(cursor); + visibleTextParts.push(trailingText); + const trailingSection = buildSectionBlock(trailingText); + if (trailingSection) { + generatedBlocks.push(trailingSection); + } + const cleanedText = visibleTextParts.join(""); + + if (!matchedDirective || !generatedInteractiveBlock) { + return payload; + } + + const existingBlocks = readExistingSlackBlocks(payload); + if (existingBlocks.length + generatedBlocks.length > SLACK_MAX_BLOCKS) { + return payload; + } + const nextBlocks = [...existingBlocks, ...generatedBlocks]; + + return { + ...payload, + text: cleanedText.trim() || undefined, + channelData: { + ...payload.channelData, + slack: { + ...(payload.channelData?.slack as Record | undefined), + blocks: nextBlocks, + }, + }, + }; +} diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index 2ae6f3d221a..59f0a29381d 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -5,19 +5,24 @@ import { } from "../auto-reply/reply/response-prefix-template.js"; import type { GetReplyOptions } from "../auto-reply/types.js"; import type { OpenClawConfig } from "../config/config.js"; +import { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js"; type ModelSelectionContext = Parameters>[0]; export type ReplyPrefixContextBundle = { prefixContext: ResponsePrefixContext; responsePrefix?: string; + enableSlackInteractiveReplies?: boolean; responsePrefixContextProvider: () => ResponsePrefixContext; onModelSelected: (ctx: ModelSelectionContext) => void; }; export type ReplyPrefixOptions = Pick< ReplyPrefixContextBundle, - "responsePrefix" | "responsePrefixContextProvider" | "onModelSelected" + | "responsePrefix" + | "enableSlackInteractiveReplies" + | "responsePrefixContextProvider" + | "onModelSelected" >; export function createReplyPrefixContext(params: { @@ -45,6 +50,10 @@ export function createReplyPrefixContext(params: { channel: params.channel, accountId: params.accountId, }).responsePrefix, + enableSlackInteractiveReplies: + params.channel === "slack" + ? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId }) + : undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected, }; @@ -56,7 +65,16 @@ export function createReplyPrefixOptions(params: { channel?: string; accountId?: string; }): ReplyPrefixOptions { - const { responsePrefix, responsePrefixContextProvider, onModelSelected } = - createReplyPrefixContext(params); - return { responsePrefix, responsePrefixContextProvider, onModelSelected }; + const { + responsePrefix, + enableSlackInteractiveReplies, + responsePrefixContextProvider, + onModelSelected, + } = createReplyPrefixContext(params); + return { + responsePrefix, + enableSlackInteractiveReplies, + responsePrefixContextProvider, + onModelSelected, + }; } diff --git a/src/config/channel-capabilities.test.ts b/src/config/channel-capabilities.test.ts index 423cc3e2f74..75083317e82 100644 --- a/src/config/channel-capabilities.test.ts +++ b/src/config/channel-capabilities.test.ts @@ -125,6 +125,23 @@ describe("resolveChannelCapabilities", () => { }), ).toBeUndefined(); }); + + it("handles Slack object-format capabilities gracefully", () => { + const cfg = { + channels: { + slack: { + capabilities: { interactiveReplies: true }, + }, + }, + } as unknown as Partial; + + expect( + resolveChannelCapabilities({ + cfg, + channel: "slack", + }), + ).toBeUndefined(); + }); }); const createStubPlugin = (id: string): ChannelPlugin => ({ diff --git a/src/config/channel-capabilities.ts b/src/config/channel-capabilities.ts index 0e66f755e3b..b7edc354596 100644 --- a/src/config/channel-capabilities.ts +++ b/src/config/channel-capabilities.ts @@ -2,9 +2,10 @@ import { normalizeChannelId } from "../channels/plugins/index.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { normalizeAccountId } from "../routing/session-key.js"; import type { OpenClawConfig } from "./config.js"; +import type { SlackCapabilitiesConfig } from "./types.slack.js"; import type { TelegramCapabilitiesConfig } from "./types.telegram.js"; -type CapabilitiesConfig = TelegramCapabilitiesConfig; +type CapabilitiesConfig = TelegramCapabilitiesConfig | SlackCapabilitiesConfig; const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string"); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7038c1effd9..215a17d77d8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1431,6 +1431,8 @@ export const FIELD_HELP: Record = { "Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority.", "channels.slack.userTokenReadOnly": "When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes.", + "channels.slack.capabilities.interactiveReplies": + "Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false.", "channels.mattermost.configWrites": "Allow Mattermost to write config in response to channel events/commands (default: true).", "channels.discord.configWrites": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 774597463a8..9b1fdb73445 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -813,6 +813,7 @@ export const FIELD_LABELS: Record = { "channels.slack.appToken": "Slack App Token", "channels.slack.userToken": "Slack User Token", "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.capabilities.interactiveReplies": "Slack Interactive Replies", "channels.slack.streaming": "Slack Streaming Mode", "channels.slack.nativeStreaming": "Slack Native Streaming", "channels.slack.streamMode": "Slack Stream Mode (Legacy)", diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 96abe2641d6..a90f1ed5020 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -47,6 +47,11 @@ export type SlackChannelConfig = { export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SlackStreamingMode = "off" | "partial" | "block" | "progress"; export type SlackLegacyStreamMode = "replace" | "status_final" | "append"; +export type SlackCapabilitiesConfig = + | string[] + | { + interactiveReplies?: boolean; + }; export type SlackActionConfig = { reactions?: boolean; @@ -89,7 +94,7 @@ export type SlackAccountConfig = { /** Slack Events API webhook path (default: /slack/events). */ webhookPath?: string; /** Optional provider capability tags used for agent/runtime guidance. */ - capabilities?: string[]; + capabilities?: SlackCapabilitiesConfig; /** Markdown formatting overrides (tables). */ markdown?: MarkdownConfig; /** Override native command registration for Slack (bool or "auto"). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 47f76614dd8..ced89bd8512 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -59,6 +59,14 @@ const TelegramCapabilitiesSchema = z.union([ }) .strict(), ]); +const SlackCapabilitiesSchema = z.union([ + z.array(z.string()), + z + .object({ + interactiveReplies: z.boolean().optional(), + }) + .strict(), +]); export const TelegramTopicSchema = z .object({ @@ -831,7 +839,7 @@ export const SlackAccountSchema = z mode: z.enum(["socket", "http"]).optional(), signingSecret: SecretInputSchema.optional().register(sensitive), webhookPath: z.string().optional(), - capabilities: z.array(z.string()).optional(), + capabilities: SlackCapabilitiesSchema.optional(), markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 18cf529ca45..c3aabde6fe2 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -8,6 +8,7 @@ export { resolveSlackAccount, resolveSlackReplyToMode, } from "../slack/accounts.js"; +export { isSlackInteractiveRepliesEnabled } from "../slack/interactive-replies.js"; export { inspectSlackAccount } from "../slack/account-inspect.js"; export { projectCredentialSnapshotFields, 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 new file mode 100644 index 00000000000..399c186cfdc --- /dev/null +++ b/src/slack/interactive-replies.ts @@ -0,0 +1,36 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { listSlackAccountIds, resolveSlackAccount } from "./accounts.js"; + +function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean { + if (!capabilities) { + return false; + } + if (Array.isArray(capabilities)) { + return capabilities.some( + (entry) => String(entry).trim().toLowerCase() === "interactivereplies", + ); + } + if (typeof capabilities === "object") { + return (capabilities as { interactiveReplies?: unknown }).interactiveReplies === true; + } + return false; +} + +export function isSlackInteractiveRepliesEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + if (params.accountId) { + const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); + return resolveInteractiveRepliesFromCapabilities(account.config.capabilities); + } + const accountIds = listSlackAccountIds(params.cfg); + if (accountIds.length === 0) { + return resolveInteractiveRepliesFromCapabilities(params.cfg.channels?.slack?.capabilities); + } + if (accountIds.length > 1) { + return false; + } + const account = resolveSlackAccount({ cfg: params.cfg, accountId: accountIds[0] }); + return resolveInteractiveRepliesFromCapabilities(account.config.capabilities); +}