Slack: add opt-in interactive reply directives (#44607)

* Reply: add Slack interactive directive parser

* Reply: wire Slack directives into normalization

* Reply: cover Slack directive parsing

* Reply: test Slack directive normalization

* Slack: hint interactive reply directives

* Config: add Slack interactive reply capability type

* Config: validate Slack interactive reply capability

* Reply: gate Slack directives behind capability

* Slack: gate interactive reply hints by capability

* Tests: cover Slack interactive reply capability gating

* Changelog: note opt-in Slack interactive replies

* Slack: fix interactive reply review findings

* Slack: harden interactive reply routing and limits

* Slack: harden interactive reply trust and validation
This commit is contained in:
Vincent Koc
2026-03-13 17:08:04 -04:00
committed by GitHub
parent 1f4b8c4eea
commit a976cc2e95
20 changed files with 893 additions and 10 deletions

View File

@@ -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

View File

@@ -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.<account>.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 = {

View File

@@ -29,6 +29,7 @@ import {
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
isSlackInteractiveRepliesEnabled,
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
@@ -146,6 +147,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
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.<account>.capabilities`).",
],
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<typeof parseLineDirectives>) =>
(result.channelData?.line as Record<string, unknown> | undefined) ?? {};
const getSlackData = (result: ReturnType<typeof parseSlackDirectives>) =>
(result.channelData?.slack as Record<string, unknown> | 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<Record<string, unknown>>;
expect(blocks).toHaveLength(3);
expect(((blocks[0]?.text as { text?: string })?.text ?? "").length).toBeLessThanOrEqual(3000);
expect(
(
(
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.placeholder as {
text?: string;
}
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
(
(
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
Record<string, unknown>
>
)?.[0]?.text as { text?: string }
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
((
(blocks[1]?.elements as Array<Record<string, unknown>>)?.[0]?.options as Array<
Record<string, unknown>
>
)?.[0]?.value as string | undefined) ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
(
(blocks[2]?.elements as Array<Record<string, unknown>>)?.[0]?.text as {
text?: string;
}
)?.text ?? ""
).length,
).toBeLessThanOrEqual(75);
expect(
(
((blocks[2]?.elements as Array<Record<string, unknown>>)?.[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<T>() {
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({

View File

@@ -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", () => {

View File

@@ -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 = {

View File

@@ -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<RouteReplyRe
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
enableSlackInteractiveReplies:
channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false,
});
if (!normalized) {
return { ok: true };
@@ -106,9 +110,25 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
? [normalized.mediaUrl]
: [];
const replyToId = normalized.replyToId;
let hasSlackBlocks = false;
if (
channel === "slack" &&
normalized.channelData?.slack &&
typeof normalized.channelData.slack === "object" &&
!Array.isArray(normalized.channelData.slack)
) {
try {
hasSlackBlocks = Boolean(
parseSlackBlocksInput((normalized.channelData.slack as { blocks?: unknown }).blocks)
?.length,
);
} catch {
hasSlackBlocks = false;
}
}
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
return { ok: true };
}

View File

@@ -0,0 +1,228 @@
import { parseSlackBlocksInput } from "../../slack/blocks-input.js";
import type { ReplyPayload } from "../types.js";
const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
const SLACK_MAX_BLOCKS = 50;
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_SECTION_TEXT_MAX = 3000;
const SLACK_PLAIN_TEXT_MAX = 75;
const SLACK_OPTION_VALUE_MAX = 75;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
type SlackBlock = Record<string, unknown>;
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<string, unknown> | undefined),
blocks: nextBlocks,
},
},
};
}

View File

@@ -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<NonNullable<GetReplyOptions["onModelSelected"]>>[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,
};
}

View File

@@ -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<OpenClawConfig>;
expect(
resolveChannelCapabilities({
cfg,
channel: "slack",
}),
).toBeUndefined();
});
});
const createStubPlugin = (id: string): ChannelPlugin => ({

View File

@@ -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");

View File

@@ -1431,6 +1431,8 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@@ -813,6 +813,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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)",

View File

@@ -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"). */

View File

@@ -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,

View File

@@ -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,

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

@@ -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);
}