fix(slack): improve interactive reply parity (#53389)

* fix(slack): improve interactive reply parity

* fix(slack): isolate reply interactions from plugins

* docs(changelog): note slack interactive parity fixes

* fix(slack): preserve preview text for local agent replies

* fix(agent): preserve directive text in local previews
This commit is contained in:
Vincent Koc
2026-03-24 10:23:10 -07:00
committed by GitHub
parent 398d58fb8a
commit f2475a7f70
17 changed files with 763 additions and 28 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
### Fixes

View File

@@ -10,6 +10,14 @@ const SLACK_PLAIN_TEXT_MAX = 75;
export type SlackBlock = Block | KnownBlock;
function buildSlackReplyButtonActionId(buttonIndex: number, choiceIndex: number): string {
return `${SLACK_REPLY_BUTTON_ACTION_ID}:${String(buttonIndex)}:${String(choiceIndex + 1)}`;
}
function buildSlackReplySelectActionId(selectIndex: number): string {
return `${SLACK_REPLY_SELECT_ACTION_ID}:${String(selectIndex)}`;
}
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
const initialState = {
blocks: [] as SlackBlock[],
@@ -40,7 +48,7 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements: block.buttons.map((button, choiceIndex) => ({
type: "button",
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
action_id: buildSlackReplyButtonActionId(state.buttonIndex, choiceIndex),
text: {
type: "plain_text",
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
@@ -60,7 +68,7 @@ export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): Sla
elements: [
{
type: "static_select",
action_id: SLACK_REPLY_SELECT_ACTION_ID,
action_id: buildSlackReplySelectActionId(state.selectIndex),
placeholder: {
type: "plain_text",
text: truncateSlackText(

View File

@@ -55,7 +55,7 @@ function requireSlackSendMedia() {
}
function requireSlackSendPayload() {
const sendPayload = slackOutbound.sendPayload;
const sendPayload = slackPlugin.outbound?.sendPayload ?? slackOutbound.sendPayload;
if (!sendPayload) {
throw new Error("slack outbound.sendPayload unavailable");
}
@@ -343,6 +343,77 @@ describe("slackPlugin outbound", () => {
);
expect(result).toEqual({ channel: "slack", messageId: "m-final" });
});
it("renders shared interactive payloads into Slack Block Kit via plugin outbound", async () => {
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-interactive" });
const sendPayload = requireSlackSendPayload();
const result = await sendPayload({
cfg,
to: "user:U123",
text: "",
payload: {
text: "Slack interactive smoke.",
interactive: {
blocks: [
{
type: "text",
text: "Slack interactive smoke.",
},
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve" },
{ label: "Reject", value: "reject" },
],
},
{
type: "select",
placeholder: "Choose a target",
options: [
{ label: "Canary", value: "canary" },
{ label: "Production", value: "production" },
],
},
],
},
},
accountId: "default",
deps: { sendSlack },
});
expect(sendSlack).toHaveBeenCalledWith(
"user:U123",
"Slack interactive smoke.",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "section",
}),
expect.objectContaining({
type: "actions",
elements: [
expect.objectContaining({ type: "button", value: "approve" }),
expect.objectContaining({ type: "button", value: "reject" }),
],
}),
expect.objectContaining({
type: "actions",
elements: [
expect.objectContaining({
type: "static_select",
options: [
expect.objectContaining({ value: "canary" }),
expect.objectContaining({ value: "production" }),
],
}),
],
}),
],
}),
);
expect(result).toEqual({ channel: "slack", messageId: "m-interactive" });
});
});
describe("slackPlugin directory", () => {
@@ -397,6 +468,9 @@ describe("slackPlugin agentPrompt", () => {
},
});
expect(hints).toContain(
"- Prefer Slack buttons/selects for 2-5 discrete choices or parameter picks instead of asking the user to type one.",
);
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.",
);

View File

@@ -48,6 +48,7 @@ import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import { slackOutbound } from "./outbound-adapter.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackUserAllowlist } from "./resolve-users.js";
import {
@@ -604,6 +605,30 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
deliveryMode: "direct",
chunker: null,
textChunkLimit: SLACK_TEXT_LIMIT,
sendPayload: async (ctx) => {
const { send, tokenOverride } = resolveSlackSendContext({
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
});
return await slackOutbound.sendPayload!({
...ctx,
deps: {
...(ctx.deps ?? {}),
slack: async (
to: Parameters<SlackSendFn>[0],
text: Parameters<SlackSendFn>[1],
opts: Parameters<SlackSendFn>[2],
) =>
await send(to, text, {
...opts,
...(tokenOverride ? { token: tokenOverride } : {}),
}),
},
});
},
},
attachedResults: {
channel: "slack",

View File

@@ -3,10 +3,11 @@ import type { OpenClawConfig } from "../../../src/config/config.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
describe("isSlackInteractiveRepliesEnabled", () => {
it("fails closed when accountId is unknown and multiple accounts exist", () => {
it("uses the configured default account when accountId is unknown and multiple accounts exist", () => {
const cfg = {
channels: {
slack: {
defaultAccount: "one",
accounts: {
one: {
capabilities: { interactiveReplies: true },
@@ -17,7 +18,7 @@ describe("isSlackInteractiveRepliesEnabled", () => {
},
} as OpenClawConfig;
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(false);
expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true);
});
it("uses the only configured account when accountId is unknown", () => {

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { listSlackAccountIds, resolveSlackAccount } from "./accounts.js";
import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js";
function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean {
if (!capabilities) {
@@ -20,17 +20,9 @@ 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] });
const account = resolveSlackAccount({
cfg: params.cfg,
accountId: params.accountId ?? resolveDefaultSlackAccountId(params.cfg),
});
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}

View File

@@ -342,12 +342,26 @@ function buildSlackPluginInteractionData(params: {
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) {
if (
actionId === SLACK_REPLY_BUTTON_ACTION_ID ||
actionId === SLACK_REPLY_SELECT_ACTION_ID ||
actionId.startsWith(`${SLACK_REPLY_BUTTON_ACTION_ID}:`) ||
actionId.startsWith(`${SLACK_REPLY_SELECT_ACTION_ID}:`)
) {
return payload || null;
}
return payload ? `${actionId}:${payload}` : actionId;
}
function isSlackReplyActionId(actionId: string): boolean {
return (
actionId === SLACK_REPLY_BUTTON_ACTION_ID ||
actionId === SLACK_REPLY_SELECT_ACTION_ID ||
actionId.startsWith(`${SLACK_REPLY_BUTTON_ACTION_ID}:`) ||
actionId.startsWith(`${SLACK_REPLY_SELECT_ACTION_ID}:`)
);
}
function buildSlackPluginInteractionId(params: {
userId?: string;
channelId?: string;
@@ -729,7 +743,17 @@ async function handleSlackBlockAction(params: {
actionId: parsed.actionId,
summary: parsed.actionSummary,
});
if (pluginInteractionData) {
if (pluginInteractionData && isSlackReplyActionId(parsed.actionId)) {
const handledBindingApproval = await handleSlackPluginBindingApproval({
ctx: params.ctx,
parsed,
pluginInteractionData,
respond,
});
if (handledBindingApproval) {
return;
}
} else if (pluginInteractionData) {
const handled = await dispatchSlackPluginInteraction({
ctx: params.ctx,
parsed,

View File

@@ -369,6 +369,50 @@ describe("registerSlackInteractionEvents", () => {
expect(app.client.chat.update).not.toHaveBeenCalled();
});
it("treats Slack reply buttons as plain interaction events instead of plugin dispatch", async () => {
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
body: {
user: { id: "U123" },
channel: { id: "C1" },
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
message: {
ts: "100.200",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "reply_actions",
elements: [{ type: "button", action_id: "openclaw:reply_button" }],
},
],
},
},
action: {
type: "button",
action_id: "openclaw:reply_button",
block_id: "reply_actions",
value: "codex",
text: { type: "plain_text", text: "codex" },
},
});
expect(ack).toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining('"actionId":"openclaw:reply_button"'),
expect.any(Object),
);
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
});
it("uses unique interaction ids for repeated Slack actions on the same message", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,

View File

@@ -37,6 +37,44 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon
});
}
function hasSlackInteractiveRepliesConfig(cfg: OpenClawConfig, accountId: string): boolean {
const capabilities = resolveSlackAccount({ cfg, accountId }).config.capabilities;
if (Array.isArray(capabilities)) {
return capabilities.some(
(entry) => String(entry).trim().toLowerCase() === "interactivereplies",
);
}
if (!capabilities || typeof capabilities !== "object") {
return false;
}
return "interactiveReplies" in capabilities;
}
function setSlackInteractiveReplies(
cfg: OpenClawConfig,
accountId: string,
interactiveReplies: boolean,
): OpenClawConfig {
const capabilities = resolveSlackAccount({ cfg, accountId }).config.capabilities;
const nextCapabilities = Array.isArray(capabilities)
? interactiveReplies
? [...new Set([...capabilities, "interactiveReplies"])]
: capabilities.filter((entry) => String(entry).trim().toLowerCase() !== "interactivereplies")
: {
...((capabilities && typeof capabilities === "object" ? capabilities : {}) as Record<
string,
unknown
>),
interactiveReplies,
};
return patchChannelConfigForAccount({
cfg,
channel,
accountId,
patch: { capabilities: nextCapabilities },
});
}
function createSlackTokenCredential(params: {
inputKey: "botToken" | "appToken";
providerHint: "slack-bot" | "slack-app";
@@ -218,6 +256,23 @@ export function createSlackSetupWizardBase(handlers: {
resolved: unknown;
}) => setSlackChannelAllowlist(cfg, accountId, resolved as string[]),
}),
finalize: async ({ cfg, accountId, options, prompter }) => {
if (hasSlackInteractiveRepliesConfig(cfg, accountId)) {
return;
}
if (options?.quickstartDefaults) {
return {
cfg: setSlackInteractiveReplies(cfg, accountId, true),
};
}
const enableInteractiveReplies = await prompter.confirm({
message: "Enable Slack interactive replies (buttons/selects) for agent responses?",
initialValue: true,
});
return {
cfg: setSlackInteractiveReplies(cfg, accountId, enableInteractiveReplies),
};
},
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
} satisfies ChannelSetupWizard;
}

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
createTestWizardPrompter,
runSetupWizardFinalize,
type WizardPrompter,
} from "../../../test/helpers/extensions/setup-wizard.js";
import { slackSetupWizard } from "./setup-surface.js";
describe("slackSetupWizard.finalize", () => {
const baseCfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig;
it("prompts to enable interactive replies for newly configured Slack accounts", async () => {
const confirm = vi.fn(async () => true);
const result = await runSetupWizardFinalize({
finalize: slackSetupWizard.finalize,
cfg: baseCfg,
prompter: createTestWizardPrompter({
confirm: confirm as WizardPrompter["confirm"],
}),
});
if (!result?.cfg) {
throw new Error("expected finalize to patch config");
}
expect(confirm).toHaveBeenCalledWith({
message: "Enable Slack interactive replies (buttons/selects) for agent responses?",
initialValue: true,
});
expect(
(result.cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } })
?.capabilities?.interactiveReplies,
).toBe(true);
});
it("records an explicit false choice when the operator declines interactive replies", async () => {
const result = await runSetupWizardFinalize({
finalize: slackSetupWizard.finalize,
cfg: baseCfg,
prompter: createTestWizardPrompter({
confirm: vi.fn(async () => false),
}),
});
if (!result?.cfg) {
throw new Error("expected finalize to patch config");
}
expect(
(result.cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } })
?.capabilities?.interactiveReplies,
).toBe(false);
});
it("auto-enables interactive replies for quickstart defaults without prompting", async () => {
const confirm = vi.fn(async () => false);
const result = await runSetupWizardFinalize({
finalize: slackSetupWizard.finalize,
cfg: baseCfg,
options: { quickstartDefaults: true },
prompter: createTestWizardPrompter({
confirm: confirm as WizardPrompter["confirm"],
}),
});
if (!result?.cfg) {
throw new Error("expected finalize to patch config");
}
expect(confirm).not.toHaveBeenCalled();
expect(
(result.cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } })
?.capabilities?.interactiveReplies,
).toBe(true);
});
});

View File

@@ -77,9 +77,9 @@ describe("buildSlackInteractiveBlocks", () => {
}>;
};
expect(buttonBlock.elements?.[0]?.action_id).toBe("openclaw:reply_button");
expect(buttonBlock.elements?.[0]?.action_id).toBe("openclaw:reply_button:1:1");
expect(buttonBlock.elements?.[0]?.value).toBe("pluginbind:approval-123:o");
expect(selectBlock.elements?.[0]?.action_id).toBe("openclaw:reply_select");
expect(selectBlock.elements?.[0]?.action_id).toBe("openclaw:reply_select:1");
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
});
});

View File

@@ -192,6 +192,7 @@ export function createSlackPluginBase(params: {
messageToolHints: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId })
? [
"- Prefer Slack buttons/selects for 2-5 discrete choices or parameter picks instead of asking the user to type one.",
"- 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.",
]

View File

@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { slackOutbound } from "../../../test/channel-outbounds.js";
import type { CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { deliverAgentCommandResult, normalizeAgentCommandReplyPayloads } from "./delivery.js";
import type { AgentCommandOpts } from "./types.js";
type NormalizeParams = Parameters<typeof normalizeAgentCommandReplyPayloads>[0];
type RunResult = NormalizeParams["result"];
const emptyRegistry = createTestRegistry([]);
const slackRegistry = createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: createOutboundTestPlugin({
id: "slack",
outbound: slackOutbound,
messaging: {
enableInteractiveReplies: ({ cfg }) =>
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
?.capabilities?.interactiveReplies === true,
},
}),
},
]);
function createResult(overrides: Partial<RunResult> = {}): RunResult {
return {
meta: {
durationMs: 1,
...overrides.meta,
},
...(overrides.payloads ? { payloads: overrides.payloads } : {}),
} as RunResult;
}
describe("normalizeAgentCommandReplyPayloads", () => {
beforeEach(() => {
setActivePluginRegistry(slackRegistry);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("compiles Slack directives for direct agent deliveries when interactive replies are enabled", () => {
const normalized = normalizeAgentCommandReplyPayloads({
cfg: {
channels: {
slack: {
capabilities: { interactiveReplies: true },
},
},
} as OpenClawConfig,
opts: { message: "test" } as AgentCommandOpts,
outboundSession: undefined,
deliveryChannel: "slack",
payloads: [{ text: "Choose [[slack_buttons: Retry:retry]]" }],
result: createResult(),
});
expect(normalized).toMatchObject([
{
text: "Choose",
interactive: {
blocks: [
{
type: "text",
text: "Choose",
},
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
],
},
},
]);
});
it("renders response prefix templates with the selected runtime model", () => {
const normalized = normalizeAgentCommandReplyPayloads({
cfg: {
messages: {
responsePrefix: "[{modelFull}]",
},
} as OpenClawConfig,
opts: { message: "test" } as AgentCommandOpts,
outboundSession: undefined,
deliveryChannel: "slack",
payloads: [{ text: "Ready." }],
result: createResult({
meta: {
durationMs: 1,
agentMeta: {
sessionId: "session-1",
provider: "openai-codex",
model: "gpt-5.4",
},
},
}),
});
expect(normalized).toMatchObject([
{
text: "[openai-codex/gpt-5.4] Ready.",
},
]);
});
it("keeps Slack options text intact for local preview when delivery is disabled", async () => {
const runtime = {
log: vi.fn(),
};
const delivered = await deliverAgentCommandResult({
cfg: {
channels: {
slack: {
capabilities: { interactiveReplies: true },
},
},
} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "test",
channel: "slack",
} as AgentCommandOpts,
outboundSession: undefined,
sessionEntry: undefined,
payloads: [{ text: "Options: on, off." }],
result: createResult(),
});
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith("Options: on, off.");
expect(delivered.payloads).toMatchObject([{ text: "Options: on, off." }]);
});
it("keeps LINE directive-only replies intact for local preview when delivery is disabled", async () => {
const runtime = {
log: vi.fn(),
};
const delivered = await deliverAgentCommandResult({
cfg: {} as OpenClawConfig,
deps: {} as CliDeps,
runtime: runtime as never,
opts: {
message: "test",
channel: "line",
} as AgentCommandOpts,
outboundSession: undefined,
sessionEntry: undefined,
payloads: [
{
text: "[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
},
],
result: createResult(),
});
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(runtime.log).toHaveBeenCalledWith(
"[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
);
expect(delivered.payloads).toMatchObject([
{
text: "[[buttons: Release menu | Choose an action | Retry:retry, Ignore:ignore]]",
},
]);
});
});

View File

@@ -1,4 +1,8 @@
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -62,6 +66,68 @@ function logNestedOutput(
}
}
export function normalizeAgentCommandReplyPayloads(params: {
cfg: OpenClawConfig;
opts: AgentCommandOpts;
outboundSession: OutboundSessionContext | undefined;
payloads: RunResult["payloads"];
result: RunResult;
deliveryChannel?: string;
accountId?: string;
applyChannelTransforms?: boolean;
}): ReplyPayload[] {
const payloads = params.payloads ?? [];
if (payloads.length === 0) {
return [];
}
const channel =
params.deliveryChannel && !isInternalMessageChannel(params.deliveryChannel)
? (normalizeChannelId(params.deliveryChannel) ?? params.deliveryChannel)
: undefined;
if (!channel) {
return payloads as ReplyPayload[];
}
const sessionKey = params.outboundSession?.key ?? params.opts.sessionKey;
const agentId =
params.outboundSession?.agentId ??
resolveSessionAgentId({
sessionKey,
config: params.cfg,
});
const replyPrefix = createReplyPrefixContext({
cfg: params.cfg,
agentId,
channel,
accountId: params.accountId,
});
const modelUsed = params.result.meta.agentMeta?.model;
const providerUsed = params.result.meta.agentMeta?.provider;
if (providerUsed && modelUsed) {
replyPrefix.onModelSelected({
provider: providerUsed,
model: modelUsed,
thinkLevel: undefined,
});
}
const responsePrefixContext = replyPrefix.responsePrefixContextProvider();
const applyChannelTransforms = params.applyChannelTransforms ?? true;
const normalizedPayloads: ReplyPayload[] = [];
for (const payload of payloads) {
const normalized = normalizeReplyPayload(payload as ReplyPayload, {
responsePrefix: replyPrefix.responsePrefix,
enableSlackInteractiveReplies: replyPrefix.enableSlackInteractiveReplies,
applyChannelTransforms,
responsePrefixContext,
});
if (normalized) {
normalizedPayloads.push(normalized);
}
}
return normalizedPayloads;
}
export async function deliverAgentCommandResult(params: {
cfg: OpenClawConfig;
deps: CliDeps;
@@ -173,7 +239,17 @@ export async function deliverAgentCommandResult(params: {
}
}
const normalizedPayloads = normalizeOutboundPayloadsForJson(payloads ?? []);
const normalizedReplyPayloads = normalizeAgentCommandReplyPayloads({
cfg,
opts,
outboundSession,
payloads,
result,
deliveryChannel,
accountId: resolvedAccountId,
applyChannelTransforms: deliver,
});
const normalizedPayloads = normalizeOutboundPayloadsForJson(normalizedReplyPayloads);
if (opts.json) {
runtime.log(
JSON.stringify(
@@ -195,7 +271,7 @@ export async function deliverAgentCommandResult(params: {
return { payloads: [], meta: result.meta };
}
const deliveryPayloads = normalizeOutboundPayloads(payloads);
const deliveryPayloads = normalizeOutboundPayloads(normalizedReplyPayloads);
const logPayload = (payload: NormalizedOutboundPayload) => {
if (opts.json) {
return;

View File

@@ -13,13 +13,14 @@ import {
resolveResponsePrefixTemplate,
type ResponsePrefixContext,
} from "./response-prefix-template.js";
import { hasSlackDirectives, parseSlackDirectives } from "./slack-directives.js";
import { compileSlackInteractiveReplies } from "./slack-directives.js";
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
export type NormalizeReplyOptions = {
responsePrefix?: string;
enableSlackInteractiveReplies?: boolean;
applyChannelTransforms?: boolean;
/** Context for template variable interpolation in responsePrefix */
responsePrefixContext?: ResponsePrefixContext;
onHeartbeatStrip?: () => void;
@@ -32,6 +33,7 @@ export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const applyChannelTransforms = opts.applyChannelTransforms ?? true;
const hasContent = (text: string | undefined) =>
hasReplyPayloadContent(
{
@@ -95,7 +97,7 @@ export function normalizeReplyPayload(
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
let enrichedPayload: ReplyPayload = { ...payload, text };
if (text && hasLineDirectives(text)) {
if (applyChannelTransforms && text && hasLineDirectives(text)) {
enrichedPayload = parseLineDirectives(enrichedPayload);
text = enrichedPayload.text;
}
@@ -115,8 +117,8 @@ export function normalizeReplyPayload(
}
enrichedPayload = { ...enrichedPayload, text };
if (opts.enableSlackInteractiveReplies && text && hasSlackDirectives(text)) {
enrichedPayload = parseSlackDirectives(enrichedPayload);
if (applyChannelTransforms && opts.enableSlackInteractiveReplies && text) {
enrichedPayload = compileSlackInteractiveReplies(enrichedPayload);
}
return enrichedPayload;

View File

@@ -193,6 +193,81 @@ describe("normalizeReplyPayload", () => {
],
});
});
it("compiles simple trailing Options lines into Slack buttons when interactive replies are enabled", () => {
const result = normalizeReplyPayload(
{
text: "Current verbose level: off.\nOptions: on, full, off.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe("Current verbose level: off.");
expect(result!.interactive).toEqual({
blocks: [
{
type: "text",
text: "Current verbose level: off.",
},
{
type: "buttons",
buttons: [
{ label: "on", value: "on" },
{ label: "full", value: "full" },
{ label: "off", value: "off" },
],
},
],
});
});
it("uses a Slack select when simple Options lines exceed the button row size", () => {
const result = normalizeReplyPayload(
{
text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe("Choose a reasoning level.");
expect(result!.interactive).toEqual({
blocks: [
{
type: "text",
text: "Choose a reasoning level.",
},
{
type: "select",
placeholder: "Choose an option",
options: [
{ label: "off", value: "off" },
{ label: "minimal", value: "minimal" },
{ label: "low", value: "low" },
{ label: "medium", value: "medium" },
{ label: "high", value: "high" },
{ label: "adaptive", value: "adaptive" },
],
},
],
});
});
it("leaves complex Options lines as plain text", () => {
const result = normalizeReplyPayload(
{
text: "ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
},
{ enableSlackInteractiveReplies: true },
);
expect(result).not.toBeNull();
expect(result!.text).toBe(
"ACP runtime choices.\nOptions: host=sandbox|gateway|node, security=deny|allowlist|full.",
);
expect(result!.interactive).toBeUndefined();
});
});
describe("typing controller", () => {

View File

@@ -3,6 +3,9 @@ import type { ReplyPayload } from "../types.js";
const SLACK_BUTTON_MAX_ITEMS = 5;
const SLACK_SELECT_MAX_ITEMS = 100;
const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi;
const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i;
const SLACK_AUTO_SELECT_MAX_ITEMS = 12;
const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i;
type SlackChoice = {
label: string;
@@ -147,3 +150,97 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
},
};
}
function hasSlackBlocks(payload: ReplyPayload): boolean {
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
if (typeof blocks === "string") {
return blocks.trim().length > 0;
}
return Array.isArray(blocks) && blocks.length > 0;
}
function parseSimpleSlackOptions(raw: string): SlackChoice[] | null {
const entries = raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) {
return null;
}
if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) {
return null;
}
const deduped = new Set(entries.map((entry) => entry.toLowerCase()));
if (deduped.size !== entries.length) {
return null;
}
return entries.map((entry) => ({
label: entry,
value: entry,
}));
}
export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) {
return payload;
}
const lines = text.split("\n");
const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim());
if (lastNonEmptyIndex == null) {
return payload;
}
const optionsLine = lines[lastNonEmptyIndex] ?? "";
const match = optionsLine.match(SLACK_OPTIONS_LINE_RE);
if (!match) {
return payload;
}
const choices = parseSimpleSlackOptions(match[1] ?? "");
if (!choices) {
return payload;
}
const bodyText = lines
.filter((_, index) => index !== lastNonEmptyIndex)
.join("\n")
.trim();
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const bodyBlock = buildTextBlock(bodyText);
if (bodyBlock) {
generatedBlocks.push(bodyBlock);
}
generatedBlocks.push(
choices.length <= SLACK_BUTTON_MAX_ITEMS
? {
type: "buttons",
buttons: choices,
}
: {
type: "select",
placeholder: "Choose an option",
options: choices,
},
);
return {
...payload,
text: bodyText || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}
export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
if (hasSlackDirectives(text)) {
return parseSlackDirectives(payload);
}
return parseSlackOptionsLine(payload);
}