mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
83
extensions/slack/src/setup-surface.test.ts
Normal file
83
extensions/slack/src/setup-surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
|
||||
177
src/agents/command/delivery.test.ts
Normal file
177
src/agents/command/delivery.test.ts
Normal 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]]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user