Compare commits

...

50 Commits

Author SHA1 Message Date
Peter Steinberger
bec437faf8 fix: harden shared interactive payload landing (#47715) (thanks @vincentkoc) 2026-03-16 04:55:01 +00:00
Vincent Koc
81c8e66f61
Merge branch 'main' into vincentkoc-code/slack-plugin-interactive-dedupe 2026-03-15 20:48:40 -07:00
Vincent Koc
3bfd093cdb Slack: fail oversized merged block payloads 2026-03-15 20:39:43 -07:00
Vincent Koc
47e0bf522f Slack: fix review regressions 2026-03-15 20:38:18 -07:00
Vincent Koc
749f3e7baa Channels: centralize shared interactive rendering 2026-03-15 20:25:09 -07:00
Vincent Koc
4572ddfe2c Channels: add message action capabilities 2026-03-15 20:24:45 -07:00
Vincent Koc
66df5f2bcb Matrix: guard optional outbound handlers 2026-03-15 19:34:02 -07:00
Vincent Koc
9b7a943eda Slack: test shared interactive renderer 2026-03-15 19:34:01 -07:00
Vincent Koc
fbe80bdd46 Discord: test shared interactive renderer 2026-03-15 19:34:01 -07:00
Vincent Koc
53e4359249 Plugins: update Slack interactive tests 2026-03-15 19:34:01 -07:00
Vincent Koc
daecbeeaba Plugins: register Slack interactive handlers correctly 2026-03-15 19:34:01 -07:00
Vincent Koc
bd74ce74aa Slack: update shared interactive interaction tests 2026-03-15 19:34:01 -07:00
Vincent Koc
e9a8d840a6 Slack: fix shared interactive registration context 2026-03-15 19:34:01 -07:00
Vincent Koc
78f0e35529 Cron: treat shared interactive payloads as deliverable 2026-03-15 19:34:01 -07:00
Vincent Koc
a1e78fd52f Outbound: test shared interactive telegram delivery 2026-03-15 19:34:01 -07:00
Vincent Koc
86c26bd171 Outbound: deliver shared interactive payloads 2026-03-15 19:34:01 -07:00
Vincent Koc
fd60b0fc85 Reply: update shared interactive normalize tests 2026-03-15 19:34:01 -07:00
Vincent Koc
2ff309db4d Reply: update shared interactive flow tests 2026-03-15 19:34:01 -07:00
Vincent Koc
6f6edbe770 Reply: route shared interactive payloads outbound 2026-03-15 19:34:01 -07:00
Vincent Koc
99a251e7ca Reply: keep shared interactive payloads during normalization 2026-03-15 19:34:01 -07:00
Vincent Koc
3b6652dcd8 Slack: map shared interactive sends in SDK actions 2026-03-15 19:34:01 -07:00
Vincent Koc
ee89ffd264 Discord: map shared interactive sends in actions 2026-03-15 19:34:01 -07:00
Vincent Koc
8880bc32fa CLI: add shared interactive send flag 2026-03-15 19:34:01 -07:00
Vincent Koc
7648fe6f8a Outbound: accept shared interactive sends 2026-03-15 19:34:01 -07:00
Vincent Koc
488f1b0ed3 Outbound: parse shared interactive params 2026-03-15 19:34:01 -07:00
Vincent Koc
4db39a74f1 Message Tool: add shared interactive schema 2026-03-15 19:34:01 -07:00
Vincent Koc
fbc4217443 Slack: advertise shared interactive support 2026-03-15 19:34:01 -07:00
Vincent Koc
1a0313bb1f Discord: advertise shared interactive support 2026-03-15 19:34:01 -07:00
Vincent Koc
68a809298d Telegram: advertise shared interactive support 2026-03-15 19:34:01 -07:00
Vincent Koc
8fd4511df5 Channels: test shared interactive support checks 2026-03-15 19:34:01 -07:00
Vincent Koc
5d4dbf1c7d Channels: expose shared interactive support checks 2026-03-15 19:34:01 -07:00
Vincent Koc
a079c190f9 Channels: add interactive message capability 2026-03-15 19:34:01 -07:00
Vincent Koc
23b68d0349 Slack: add shared interactive renderer 2026-03-15 19:34:01 -07:00
Vincent Koc
f56aa4dee7 Discord: render shared interactive payloads outbound 2026-03-15 19:34:01 -07:00
Vincent Koc
3218efcfd8 Discord: add shared interactive renderer 2026-03-15 19:34:01 -07:00
Vincent Koc
c9a07282e4 Telegram: render shared interactive payloads outbound 2026-03-15 19:34:01 -07:00
Vincent Koc
1c562bf211 Telegram: add shared interactive renderer 2026-03-15 19:34:01 -07:00
Vincent Koc
86befdd2b3 Slack: render shared interactive payloads outbound 2026-03-15 19:34:01 -07:00
Vincent Koc
2c50e199b7 Reply: compile Slack directives into shared interactions 2026-03-15 19:34:01 -07:00
Vincent Koc
103f92c3ed Plugins: centralize binding approval interactions 2026-03-15 19:34:01 -07:00
Vincent Koc
b1ac4e1d8e Outbound: preserve shared interactive payloads 2026-03-15 19:34:01 -07:00
Vincent Koc
bb34721175 Reply: keep interactive payloads renderable 2026-03-15 19:34:01 -07:00
Vincent Koc
96e6ba3046 Reply: expose shared interactive payloads 2026-03-15 19:34:01 -07:00
Vincent Koc
60648a51b3 Interactive: add shared payload model 2026-03-15 19:34:01 -07:00
Vincent Koc
07eae3da90 Tests: cover Slack block-action shared dispatch 2026-03-15 19:34:01 -07:00
Vincent Koc
7158406298 Slack: route block actions through shared dispatcher 2026-03-15 19:34:01 -07:00
Vincent Koc
c2f8549bce Tests: cover Slack shared interactive dispatcher 2026-03-15 19:34:00 -07:00
Vincent Koc
93e25774da Plugins: add Slack shared interactive dispatcher 2026-03-15 19:34:00 -07:00
Vincent Koc
8795a5fee6 Plugins: add Slack interactive handler types 2026-03-15 19:34:00 -07:00
Vincent Koc
70e4931739 Plugin SDK: export Slack interactive handler context 2026-03-15 19:34:00 -07:00
56 changed files with 2450 additions and 716 deletions

View File

@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.

View File

@ -8,7 +8,9 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js";
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
import { buildDiscordInteractiveComponents } from "../components.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
@ -40,7 +42,9 @@ export async function handleDiscordMessageAction(
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents = params.components;
const rawComponents =
params.components ??
buildDiscordInteractiveComponents(normalizeInteractiveReply(params.interactive));
const hasComponents =
Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object");

View File

@ -106,6 +106,10 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
}
return Array.from(actions);
},
getCapabilities: ({ cfg }) =>
listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)).length > 0
? (["interactive", "components"] as const)
: [],
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "sendMessage") {

View File

@ -25,6 +25,8 @@ import {
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js";
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
@ -212,6 +214,69 @@ export type DiscordComponentBuildResult = {
modals: DiscordModalEntry[];
};
function resolveDiscordInteractiveButtonStyle(
style?: InteractiveButtonStyle,
): DiscordComponentButtonStyle | undefined {
return style ?? "secondary";
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply(
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
if (block.type === "text") {
const text = block.text.trim();
if (text) {
state.push({ type: "text", text });
}
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
for (
let index = 0;
index < block.buttons.length;
index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE
) {
state.push({
type: "actions",
buttons: block.buttons
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
.map((button) => ({
label: button.label,
style: resolveDiscordInteractiveButtonStyle(button.style),
callbackData: button.value,
})),
});
}
return state;
}
if (block.type === "select" && block.options.length > 0) {
state.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
},
});
}
return state;
},
);
return blocks.length > 0 ? { blocks } : undefined;
}
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],
["action-row", "actions"],

View File

@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
sendDiscordComponentMessageMock: vi.fn(),
sendMessageDiscordMock: vi.fn(),
sendPollDiscordMock: vi.fn(),
sendWebhookMessageDiscordMock: vi.fn(),
getThreadBindingManagerMock: vi.fn(),
}));
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
sendDiscordComponentMessage: (...args: unknown[]) =>
hoisted.sendDiscordComponentMessageMock(...args),
sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args),
sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args),
sendWebhookMessageDiscord: (...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args),
};
});
vi.mock("./monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: (...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
};
});
const { discordOutbound } = await import("./outbound-adapter.js");
describe("discordOutbound shared interactive ordering", () => {
beforeEach(() => {
hoisted.sendDiscordComponentMessageMock.mockReset().mockResolvedValue({
messageId: "msg-1",
channelId: "123456",
});
hoisted.sendMessageDiscordMock.mockReset();
hoisted.sendPollDiscordMock.mockReset();
hoisted.sendWebhookMessageDiscordMock.mockReset();
hoisted.getThreadBindingManagerMock.mockReset().mockReturnValue(null);
});
it("keeps shared text blocks in authored order without hoisting fallback text", async () => {
const result = await discordOutbound.sendPayload!({
cfg: {},
to: "channel:123456",
text: "",
payload: {
interactive: {
blocks: [
{ type: "text", text: "First" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
{ type: "text", text: "Last" },
],
},
},
});
expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith(
"channel:123456",
{
blocks: [
{ type: "text", text: "First" },
{
type: "actions",
buttons: [{ label: "Approve", style: "secondary", callbackData: "approve" }],
},
{ type: "text", text: "Last" },
],
},
expect.objectContaining({
cfg: {},
}),
);
expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled();
expect(result).toEqual({
channel: "discord",
messageId: "msg-1",
channelId: "123456",
});
});
});

View File

@ -1,11 +1,22 @@
import { sendTextMediaPayload } from "../../../src/channels/plugins/outbound/direct-text-media.js";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendTextMediaPayload,
} from "../../../src/channels/plugins/outbound/direct-text-media.js";
import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import type { DiscordComponentMessageSpec } from "./components.js";
import { buildDiscordInteractiveComponents } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
import {
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendWebhookMessageDiscord,
} from "./send.js";
function resolveDiscordOutboundTarget(params: {
to: string;
@ -78,8 +89,76 @@ export const discordOutbound: ChannelOutboundAdapter = {
textChunkLimit: 2000,
pollMaxOptions: 10,
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
sendPayload: async (ctx) =>
await sendTextMediaPayload({ channel: "discord", ctx, adapter: discordOutbound }),
sendPayload: async (ctx) => {
const payload = {
...ctx.payload,
text: ctx.payload.text ?? "",
};
const discordData = payload.channelData?.discord as
| { components?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
const componentSpec = rawComponentSpec
? rawComponentSpec.text
? rawComponentSpec
: {
...rawComponentSpec,
text: payload.text?.trim() ? payload.text : undefined,
}
: undefined;
if (!componentSpec) {
return await sendTextMediaPayload({
channel: "discord",
ctx: {
...ctx,
payload,
},
adapter: discordOutbound,
});
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) {
const result = await sendDiscordComponentMessage(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
return { channel: "discord", ...result };
}
const lastResult = await sendPayloadMediaSequence({
text: payload.text ?? "",
mediaUrls,
send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, {
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
}
return await send(target, text, {
verbose: false,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
cfg: ctx.cfg,
});
},
});
return lastResult
? { channel: "discord", ...lastResult }
: { channel: "discord", messageId: "" };
},
sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => {
if (!silent) {
const webhookResult = await maybeSendDiscordWebhookText({

View File

@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { buildDiscordInteractiveComponents } from "./components.js";
describe("buildDiscordInteractiveComponents", () => {
it("maps shared buttons and selects into Discord component blocks", () => {
expect(
buildDiscordInteractiveComponents({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
],
},
{
type: "select",
placeholder: "Pick one",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
buttons: [
{ label: "Approve", style: "success", callbackData: "approve" },
{ label: "Reject", style: "danger", callbackData: "reject" },
],
},
{
type: "actions",
select: {
type: "string",
placeholder: "Pick one",
options: [{ label: "Alpha", value: "alpha" }],
},
},
],
});
});
it("preserves authored shared text blocks around controls", () => {
expect(
buildDiscordInteractiveComponents({
blocks: [
{ type: "text", text: "First" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success" }],
},
{ type: "text", text: "Last" },
],
}),
).toEqual({
blocks: [
{ type: "text", text: "First" },
{
type: "actions",
buttons: [{ label: "Approve", style: "success", callbackData: "approve" }],
},
{ type: "text", text: "Last" },
],
});
});
it("splits long shared button rows to stay within Discord action limits", () => {
expect(
buildDiscordInteractiveComponents({
blocks: [
{
type: "buttons",
buttons: [
{ label: "One", value: "1" },
{ label: "Two", value: "2" },
{ label: "Three", value: "3" },
{ label: "Four", value: "4" },
{ label: "Five", value: "5" },
{ label: "Six", value: "6" },
],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
buttons: [
{ label: "One", style: "secondary", callbackData: "1" },
{ label: "Two", style: "secondary", callbackData: "2" },
{ label: "Three", style: "secondary", callbackData: "3" },
{ label: "Four", style: "secondary", callbackData: "4" },
{ label: "Five", style: "secondary", callbackData: "5" },
],
},
{
type: "actions",
buttons: [{ label: "Six", style: "secondary", callbackData: "6" }],
},
],
});
});
});

View File

@ -219,11 +219,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
}
return Array.from(actions);
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.feishu?.enabled !== false &&
getCapabilities: ({ cfg }) => {
return cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
);
? (["cards"] as const)
: [];
},
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });

View File

@ -288,10 +288,27 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendText!(params),
sendMedia: async (params) =>
(await loadMatrixChannelRuntime()).matrixOutbound.sendMedia!(params),
sendPoll: async (params) => (await loadMatrixChannelRuntime()).matrixOutbound.sendPoll!(params),
sendText: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendText) {
throw new Error("Matrix outbound text delivery is unavailable");
}
return await outbound.sendText(params);
},
sendMedia: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendMedia) {
throw new Error("Matrix outbound media delivery is unavailable");
}
return await outbound.sendMedia(params);
},
sendPoll: async (params) => {
const outbound = (await loadMatrixChannelRuntime()).matrixOutbound;
if (!outbound.sendPoll) {
throw new Error("Matrix outbound poll delivery is unavailable");
}
return await outbound.sendPoll(params);
},
},
status: {
defaultRuntime: {

View File

@ -71,11 +71,11 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
supportsAction: ({ action }) => {
return action === "send" || action === "react";
},
supportsButtons: ({ cfg }) => {
getCapabilities: ({ cfg }) => {
const accounts = listMattermostAccountIds(cfg)
.map((id) => resolveMattermostAccount({ cfg, accountId: id }))
.filter((a) => a.enabled && a.botToken?.trim() && a.baseUrl?.trim());
return accounts.length > 0;
return accounts.length > 0 ? (["buttons"] as const) : [];
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "react") {

View File

@ -366,11 +366,11 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}
return ["poll"] satisfies ChannelMessageActionName[];
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.msteams?.enabled !== false &&
getCapabilities: ({ cfg }) => {
return cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams))
);
? (["cards"] as const)
: [];
},
handleAction: async (ctx) => {
// Handle send action with card parameter

View File

@ -0,0 +1,85 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveReply } from "../../../src/interactive/payload.js";
import { truncateSlackText } from "./truncate.js";
export const SLACK_REPLY_BUTTON_ACTION_ID = "openclaw:reply_button";
export const SLACK_REPLY_SELECT_ACTION_ID = "openclaw:reply_select";
const SLACK_SECTION_TEXT_MAX = 3000;
const SLACK_PLAIN_TEXT_MAX = 75;
export type SlackBlock = Block | KnownBlock;
export function buildSlackInteractiveBlocks(interactive?: InteractiveReply): SlackBlock[] {
const initialState = {
blocks: [] as SlackBlock[],
buttonIndex: 0,
selectIndex: 0,
};
return reduceInteractiveReply(interactive, initialState, (state, block) => {
if (block.type === "text") {
const trimmed = block.text.trim();
if (!trimmed) {
return state;
}
state.blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
},
});
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_buttons_${++state.buttonIndex}`,
elements: block.buttons.map((button, choiceIndex) => ({
type: "button",
action_id: SLACK_REPLY_BUTTON_ACTION_ID,
text: {
type: "plain_text",
text: truncateSlackText(button.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: button.value,
})),
});
return state;
}
if (block.options.length === 0) {
return state;
}
state.blocks.push({
type: "actions",
block_id: `openclaw_reply_select_${++state.selectIndex}`,
elements: [
{
type: "static_select",
action_id: SLACK_REPLY_SELECT_ACTION_ID,
placeholder: {
type: "plain_text",
text: truncateSlackText(
block.placeholder?.trim() || "Choose an option",
SLACK_PLAIN_TEXT_MAX,
),
emoji: true,
},
options: block.options.map((option, choiceIndex) => ({
text: {
type: "plain_text",
text: truncateSlackText(option.label, SLACK_PLAIN_TEXT_MAX),
emoji: true,
},
value: option.value,
})),
},
],
});
return state;
}).blocks;
}

View File

@ -292,6 +292,16 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
},
actions: {
listActions: ({ cfg }) => listSlackMessageActions(cfg),
getCapabilities: ({ cfg }) => {
const capabilities = new Set<"interactive" | "blocks">();
if (listSlackMessageActions(cfg).includes("send")) {
capabilities.add("blocks");
}
if (isSlackInteractiveRepliesEnabled({ cfg })) {
capabilities.add("interactive");
}
return Array.from(capabilities);
},
extractToolSend: ({ args }) => extractSlackToolSend(args),
handleAction: async (ctx) =>
await handleSlackMessageAction({

View File

@ -1,12 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSlackInteractionEvents } from "./interactions.js";
const enqueueSystemEventMock = vi.fn();
const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
matched: false,
handled: false,
duplicate: false,
}));
const resolvePluginConversationBindingApprovalMock = vi.fn();
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
enqueueSystemEvent: (...args: unknown[]) =>
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
}));
vi.mock("../../../../../src/plugins/interactive.js", () => ({
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
}));
vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../../src/plugins/conversation-binding.js")
>("../../../../../src/plugins/conversation-binding.js");
return {
...actual,
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
(resolvePluginConversationBindingApprovalMock as (...innerArgs: unknown[]) => unknown)(
...args,
),
buildPluginBindingResolvedText: (...args: unknown[]) =>
(buildPluginBindingResolvedTextMock as (...innerArgs: unknown[]) => unknown)(...args),
};
});
type RegisteredHandler = (args: {
ack: () => Promise<void>;
body: {
@ -78,10 +106,12 @@ function createContext(overrides?: {
}>;
}) {
let handler: RegisteredHandler | null = null;
let actionMatcher: RegExp | null = null;
let viewHandler: RegisteredViewHandler | null = null;
let viewClosedHandler: RegisteredViewClosedHandler | null = null;
const app = {
action: vi.fn((_matcher: RegExp, next: RegisteredHandler) => {
action: vi.fn((matcher: RegExp, next: RegisteredHandler) => {
actionMatcher = matcher;
handler = next;
}),
view: vi.fn((_matcher: RegExp, next: RegisteredViewHandler) => {
@ -122,6 +152,7 @@ function createContext(overrides?: {
);
const ctx = {
app,
accountId: "default",
runtime: { log: runtimeLog },
dmEnabled: overrides?.dmEnabled ?? true,
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
@ -144,6 +175,7 @@ function createContext(overrides?: {
isChannelAllowed,
resolveUserName,
resolveChannelName,
getActionMatcher: () => actionMatcher,
getHandler: () => handler,
getViewHandler: () => viewHandler,
getViewClosedHandler: () => viewClosedHandler,
@ -151,8 +183,21 @@ function createContext(overrides?: {
}
describe("registerSlackInteractionEvents", () => {
it("enqueues structured events and updates button rows", async () => {
beforeEach(() => {
enqueueSystemEventMock.mockClear();
dispatchPluginInteractiveHandlerMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
buildPluginBindingResolvedTextMock.mockClear();
buildPluginBindingResolvedTextMock.mockReturnValue("Binding updated.");
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: false,
handled: false,
duplicate: false,
});
});
it("enqueues structured events and updates button rows", async () => {
const { ctx, app, getHandler, resolveSessionKey } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
@ -228,6 +273,236 @@ describe("registerSlackInteractionEvents", () => {
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
});
it("registers a matcher that accepts plugin action ids beyond the OpenClaw prefix", () => {
const { ctx, getActionMatcher } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const matcher = getActionMatcher();
expect(matcher).toBeTruthy();
expect(matcher?.test("openclaw:verify")).toBe(true);
expect(matcher?.test("codex")).toBe(true);
});
it("routes matching Slack actions through the shared plugin interactive dispatcher", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValueOnce({
matched: true,
handled: true,
duplicate: false,
});
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U123" },
team: { id: "T9" },
trigger_id: "123.trigger",
response_url: "https://hooks.slack.test/response",
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: "codex_actions",
elements: [{ type: "button", action_id: "codex" }],
},
],
},
},
action: {
type: "button",
action_id: "codex",
block_id: "codex_actions",
value: "approve:thread-1",
text: { type: "plain_text", text: "Approve" },
},
});
expect(ack).toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
data: "codex:approve:thread-1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
ctx: expect.objectContaining({
accountId: ctx.accountId,
conversationId: "C1",
interactionId: "U123:C1:100.200:123.trigger:codex:approve:thread-1",
threadId: "100.100",
interaction: expect.objectContaining({
actionId: "codex",
value: "approve:thread-1",
}),
}),
}),
);
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(app.client.chat.update).not.toHaveBeenCalled();
});
it("uses unique interaction ids for repeated Slack actions on the same message", async () => {
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
matched: true,
handled: false,
duplicate: false,
});
const { ctx, 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" },
trigger_id: "trigger-1",
message: {
ts: "100.200",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "codex_actions",
elements: [{ type: "button", action_id: "codex" }],
},
],
},
},
action: {
type: "button",
action_id: "codex",
block_id: "codex_actions",
value: "approve:thread-1",
text: { type: "plain_text", text: "Approve" },
},
});
await handler!({
ack,
body: {
user: { id: "U123" },
channel: { id: "C1" },
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
trigger_id: "trigger-2",
message: {
ts: "100.200",
text: "fallback",
blocks: [
{
type: "actions",
block_id: "codex_actions",
elements: [{ type: "button", action_id: "codex" }],
},
],
},
},
action: {
type: "button",
action_id: "codex",
block_id: "codex_actions",
value: "approve:thread-1",
text: { type: "plain_text", text: "Approve" },
},
});
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(2);
const calls = dispatchPluginInteractiveHandlerMock.mock.calls as unknown[][];
const firstCall = calls[0]?.[0] as
| {
interactionId?: string;
}
| undefined;
const secondCall = calls[1]?.[0] as
| {
interactionId?: string;
}
| undefined;
expect(firstCall?.interactionId).toContain(":trigger-1:");
expect(secondCall?.interactionId).toContain(":trigger-2:");
expect(firstCall?.interactionId).not.toBe(secondCall?.interactionId);
});
it("resolves plugin binding approvals from shared interactive Slack actions", async () => {
resolvePluginConversationBindingApprovalMock.mockResolvedValueOnce({
status: "approved",
decision: "allow-once",
request: {
pluginId: "codex",
pluginName: "Codex",
summary: "for this thread",
},
});
const { ctx, app, getHandler } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
expect(handler).toBeTruthy();
const ack = vi.fn().mockResolvedValue(undefined);
const respond = vi.fn().mockResolvedValue(undefined);
await handler!({
ack,
respond,
body: {
user: { id: "U123" },
channel: { id: "C1" },
container: { channel_id: "C1", message_ts: "100.200", thread_ts: "100.100" },
message: {
ts: "100.200",
text: "Approve this bind?",
blocks: [
{
type: "actions",
block_id: "bind_actions",
elements: [{ type: "button", action_id: "openclaw:reply_button" }],
},
],
},
},
action: {
type: "button",
action_id: "openclaw:reply_button",
block_id: "bind_actions",
value: "pluginbind:approval-123:o",
text: { type: "plain_text", text: "Allow once" },
},
});
expect(ack).toHaveBeenCalled();
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledWith({
approvalId: "approval-123",
decision: "allow-once",
senderId: "U123",
});
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
expect(app.client.chat.update).toHaveBeenCalledWith(
expect.objectContaining({
channel: "C1",
ts: "100.200",
text: "Approve this bind?",
blocks: [],
}),
);
expect(respond).toHaveBeenCalledWith({
text: "Binding updated.",
response_type: "ephemeral",
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
});
it("drops block actions when mismatch guard triggers", async () => {
enqueueSystemEventMock.mockClear();
const { ctx, app, getHandler } = createContext({

View File

@ -1,6 +1,13 @@
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "../../../../../src/plugins/conversation-binding.js";
import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js";
import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js";
import { truncateSlackText } from "../../truncate.js";
import { authorizeSlackSystemEventSender } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
@ -417,6 +424,46 @@ function formatInteractionConfirmationText(params: {
return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`;
}
function buildSlackPluginInteractionData(params: {
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
}): string | null {
const actionId = params.actionId.trim();
if (!actionId) {
return null;
}
const payload =
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) {
return payload || null;
}
return payload ? `${actionId}:${payload}` : actionId;
}
function buildSlackPluginInteractionId(params: {
userId?: string;
channelId?: string;
messageTs?: string;
triggerId?: string;
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
}): string {
const primaryValue =
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
return [
params.userId?.trim() || "",
params.channelId?.trim() || "",
params.messageTs?.trim() || "",
params.triggerId?.trim() || "",
params.actionId.trim(),
primaryValue,
].join(":");
}
function summarizeViewState(values: unknown): ModalInputSummary[] {
if (!values || typeof values !== "object") {
return [];
@ -447,68 +494,108 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
return;
}
// Handle Block Kit button clicks from OpenClaw-generated messages
// Only matches action_ids that start with our prefix to avoid interfering
// with other Slack integrations or future features
ctx.app.action(
new RegExp(`^${OPENCLAW_ACTION_PREFIX}`),
async (args: SlackActionMiddlewareArgs) => {
const { ack, body, action, respond } = args;
const typedBody = body as unknown as {
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
message?: { ts?: string; text?: string; blocks?: unknown[] };
};
// Handle Block Kit actions for this Slack app, including legacy/custom
// action_ids that plugin handlers map into shared interactive namespaces.
ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => {
const { ack, body, action, respond } = args;
const typedBody = body as unknown as {
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
message?: { ts?: string; text?: string; blocks?: unknown[] };
};
// Acknowledge the action immediately to prevent the warning icon
await ack();
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
return;
}
// Acknowledge the action immediately to prevent the warning icon
await ack();
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
return;
}
// Extract action details using proper Bolt types
const typedAction = readInteractionAction(action);
if (!typedAction) {
ctx.runtime.log?.(
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
typedBody.user?.id ?? "unknown"
}`,
);
return;
// Extract action details using proper Bolt types
const typedAction = readInteractionAction(action);
if (!typedAction) {
ctx.runtime.log?.(
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
typedBody.user?.id ?? "unknown"
}`,
);
return;
}
const typedActionWithText = typedAction as {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
const actionId =
typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown";
const blockId = typedActionWithText.block_id;
const userId = typedBody.user?.id ?? "unknown";
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
const threadTs = typedBody.container?.thread_ts;
const auth = await authorizeSlackSystemEventSender({
ctx,
senderId: userId,
channelId,
});
if (!auth.allowed) {
ctx.runtime.log?.(
`slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
);
if (respond) {
try {
await respond({
text: "You are not authorized to use this control.",
response_type: "ephemeral",
});
} catch {
// Best-effort feedback only.
}
}
const typedActionWithText = typedAction as {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
const actionId =
typeof typedActionWithText.action_id === "string"
? typedActionWithText.action_id
: "unknown";
const blockId = typedActionWithText.block_id;
const userId = typedBody.user?.id ?? "unknown";
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
const threadTs = typedBody.container?.thread_ts;
const auth = await authorizeSlackSystemEventSender({
ctx,
senderId: userId,
return;
}
const actionSummary = summarizeAction(typedAction);
const pluginInteractionData = buildSlackPluginInteractionData({
actionId,
summary: actionSummary,
});
if (pluginInteractionData) {
const pluginInteractionId = buildSlackPluginInteractionId({
userId,
channelId,
messageTs,
triggerId: typedBody.trigger_id,
actionId,
summary: actionSummary,
});
if (!auth.allowed) {
ctx.runtime.log?.(
`slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
);
const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData);
if (pluginBindingApproval) {
const resolved = await resolvePluginConversationBindingApproval({
approvalId: pluginBindingApproval.approvalId,
decision: pluginBindingApproval.decision,
senderId: userId,
});
if (channelId && messageTs) {
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: [],
});
} catch {
// Best-effort cleanup only; continue with follow-up feedback.
}
}
if (respond) {
try {
await respond({
text: "You are not authorized to use this control.",
text: buildPluginBindingResolvedText(resolved),
response_type: "ephemeral",
});
} catch {
@ -517,115 +604,178 @@ export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContex
}
return;
}
const actionSummary = summarizeAction(typedAction);
const eventPayload: InteractionSummary = {
interactionType: "block_action",
actionId,
blockId,
...actionSummary,
userId,
teamId: typedBody.team?.id,
triggerId: typedBody.trigger_id,
responseUrl: typedBody.response_url,
channelId,
messageTs,
threadTs,
};
// Log the interaction for debugging
ctx.runtime.log?.(
`slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`,
);
// Send a system event to notify the agent about the button click
// Pass undefined (not "unknown") to allow proper main session fallback
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId: channelId,
channelType: auth.channelType,
senderId: userId,
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
data: pluginInteractionData,
interactionId: pluginInteractionId,
ctx: {
accountId: ctx.accountId,
interactionId: pluginInteractionId,
conversationId: channelId ?? "",
parentConversationId: undefined,
threadId: threadTs,
senderId: userId,
senderUsername: undefined,
auth: {
isAuthorizedSender: auth.allowed,
},
interaction: {
kind: actionSummary.actionType === "button" ? "button" : "select",
actionId,
blockId,
messageTs,
threadTs,
value: actionSummary.value,
selectedValues: actionSummary.selectedValues,
selectedLabels: actionSummary.selectedLabels,
triggerId: typedBody.trigger_id,
responseUrl: typedBody.response_url,
},
},
respond: {
acknowledge: async () => {},
reply: async ({ text, responseType }) => {
if (!respond) {
return;
}
await respond({
text,
response_type: responseType ?? "ephemeral",
});
},
followUp: async ({ text, responseType }) => {
if (!respond) {
return;
}
await respond({
text,
response_type: responseType ?? "ephemeral",
});
},
editMessage: async ({ text, blocks }) => {
if (!channelId || !messageTs) {
return;
}
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: text ?? typedBody.message?.text ?? "",
...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}),
});
},
},
});
// Build context key - only include defined values to avoid "unknown" noise
const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
const contextKey = contextParts.join(":");
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
sessionKey,
contextKey,
});
const originalBlocks = typedBody.message?.blocks;
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
if (pluginResult.matched && pluginResult.handled) {
return;
}
}
const eventPayload: InteractionSummary = {
interactionType: "block_action",
actionId,
blockId,
...actionSummary,
userId,
teamId: typedBody.team?.id,
triggerId: typedBody.trigger_id,
responseUrl: typedBody.response_url,
channelId,
messageTs,
threadTs,
};
if (!blockId) {
return;
// Log the interaction for debugging
ctx.runtime.log?.(
`slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`,
);
// Send a system event to notify the agent about the button click
// Pass undefined (not "unknown") to allow proper main session fallback
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId: channelId,
channelType: auth.channelType,
senderId: userId,
});
// Build context key - only include defined values to avoid "unknown" noise
const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
const contextKey = contextParts.join(":");
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
sessionKey,
contextKey,
});
const originalBlocks = typedBody.message?.blocks;
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
return;
}
if (!blockId) {
return;
}
const selectedLabel = formatInteractionSelectionLabel({
actionId,
summary: actionSummary,
buttonText: typedActionWithText.text?.text,
});
let updatedBlocks = originalBlocks.map((block) => {
const typedBlock = block as InteractionMessageBlock;
if (typedBlock.type === "actions" && typedBlock.block_id === blockId) {
return {
type: "context",
elements: [
{
type: "mrkdwn",
text: formatInteractionConfirmationText({ selectedLabel, userId }),
},
],
};
}
return block;
});
const selectedLabel = formatInteractionSelectionLabel({
actionId,
summary: actionSummary,
buttonText: typedActionWithText.text?.text,
});
let updatedBlocks = originalBlocks.map((block) => {
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
const typedBlock = block as InteractionMessageBlock;
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
});
if (!hasRemainingIndividualActionRows) {
updatedBlocks = updatedBlocks.filter((block, index) => {
const typedBlock = block as InteractionMessageBlock;
if (typedBlock.type === "actions" && typedBlock.block_id === blockId) {
return {
type: "context",
elements: [
{
type: "mrkdwn",
text: formatInteractionConfirmationText({ selectedLabel, userId }),
},
],
};
if (isBulkActionsBlock(typedBlock)) {
return false;
}
return block;
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
}
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
const typedBlock = block as InteractionMessageBlock;
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: updatedBlocks as (Block | KnownBlock)[],
});
if (!hasRemainingIndividualActionRows) {
updatedBlocks = updatedBlocks.filter((block, index) => {
const typedBlock = block as InteractionMessageBlock;
if (isBulkActionsBlock(typedBlock)) {
return false;
}
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
} catch {
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
if (!respond) {
return;
}
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: updatedBlocks as (Block | KnownBlock)[],
await respond({
text: `Button "${actionId}" clicked!`,
response_type: "ephemeral",
});
} catch {
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
if (!respond) {
return;
}
try {
await respond({
text: `Button "${actionId}" clicked!`,
response_type: "ephemeral",
});
} catch {
// Action was acknowledged and system event enqueued even when response updates fail.
}
// Action was acknowledged and system event enqueued even when response updates fail.
}
},
);
}
});
if (typeof ctx.app.view !== "function") {
return;

View File

@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
describe("buildSlackInteractiveBlocks", () => {
it("renders shared interactive blocks in authored order", () => {
expect(
buildSlackInteractiveBlocks({
blocks: [
{
type: "select",
placeholder: "Pick one",
options: [{ label: "Alpha", value: "alpha" }],
},
{ type: "text", text: "then" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
],
}),
).toEqual([
expect.objectContaining({
type: "actions",
block_id: "openclaw_reply_select_1",
}),
expect.objectContaining({
type: "section",
text: expect.objectContaining({ text: "then" }),
}),
expect.objectContaining({
type: "actions",
block_id: "openclaw_reply_buttons_1",
}),
]);
});
it("truncates Slack render strings to Block Kit limits", () => {
const long = "x".repeat(120);
const blocks = buildSlackInteractiveBlocks({
blocks: [
{ type: "text", text: "y".repeat(3100) },
{ type: "select", placeholder: long, options: [{ label: long, value: long }] },
{ type: "buttons", buttons: [{ label: long, value: long }] },
],
});
const section = blocks[0] as { text?: { text?: string } };
const selectBlock = blocks[1] as {
elements?: Array<{ placeholder?: { text?: string } }>;
};
const buttonBlock = blocks[2] as {
elements?: Array<{ value?: string }>;
};
expect((section.text?.text ?? "").length).toBeLessThanOrEqual(3000);
expect((selectBlock.elements?.[0]?.placeholder?.text ?? "").length).toBeLessThanOrEqual(75);
expect(buttonBlock.elements?.[0]?.value).toBe(long);
});
it("preserves original callback payloads for round-tripping", () => {
const blocks = buildSlackInteractiveBlocks({
blocks: [
{
type: "buttons",
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
},
{
type: "select",
options: [{ label: "Approve", value: "codex:approve:thread-1" }],
},
],
});
const buttonBlock = blocks[0] as {
elements?: Array<{ action_id?: string; value?: string }>;
};
const selectBlock = blocks[1] as {
elements?: Array<{
action_id?: string;
options?: Array<{ value?: string }>;
}>;
};
expect(buttonBlock.elements?.[0]?.action_id).toBe("openclaw:reply_button");
expect(buttonBlock.elements?.[0]?.value).toBe("pluginbind:approval-123:o");
expect(selectBlock.elements?.[0]?.action_id).toBe("openclaw:reply_select");
expect(selectBlock.elements?.[0]?.options?.[0]?.value).toBe("codex:approve:thread-1");
});
});

View File

@ -1,3 +1,6 @@
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveReply, InteractiveReplyButton } from "../../../src/interactive/payload.js";
export type TelegramButtonStyle = "danger" | "success" | "primary";
export type TelegramInlineButton = {
@ -7,3 +10,53 @@ export type TelegramInlineButton = {
};
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;
const TELEGRAM_INTERACTIVE_ROW_SIZE = 3;
function toTelegramButtonStyle(
style?: InteractiveReplyButton["style"],
): TelegramInlineButton["style"] {
return style === "danger" || style === "success" || style === "primary" ? style : undefined;
}
function chunkInteractiveButtons(
buttons: readonly InteractiveReplyButton[],
rows: TelegramInlineButton[][],
) {
for (let i = 0; i < buttons.length; i += TELEGRAM_INTERACTIVE_ROW_SIZE) {
const row = buttons.slice(i, i + TELEGRAM_INTERACTIVE_ROW_SIZE).map((button) => ({
text: button.label,
callback_data: button.value,
style: toTelegramButtonStyle(button.style),
}));
if (row.length > 0) {
rows.push(row);
}
}
}
export function buildTelegramInteractiveButtons(
interactive?: InteractiveReply,
): TelegramInlineButtons | undefined {
const rows = reduceInteractiveReply(
interactive,
[] as TelegramInlineButton[][],
(state, block) => {
if (block.type === "buttons") {
chunkInteractiveButtons(block.buttons, state);
return state;
}
if (block.type === "select") {
chunkInteractiveButtons(
block.options.map((option) => ({
label: option.label,
value: option.value,
})),
state,
);
}
return state;
},
);
return rows.length > 0 ? rows : undefined;
}

View File

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const handleTelegramActionMock = vi.hoisted(() => vi.fn());
vi.mock("../../../src/agents/tools/telegram-actions.js", () => ({
handleTelegramAction: (...args: unknown[]) => handleTelegramActionMock(...args),
}));
import { telegramMessageActions } from "./channel-actions.js";
describe("telegramMessageActions", () => {
beforeEach(() => {
handleTelegramActionMock.mockReset().mockResolvedValue({
ok: true,
content: [],
details: {},
});
});
it("allows interactive-only sends", async () => {
await telegramMessageActions.handleAction!({
action: "send",
params: {
to: "123456",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success" }],
},
],
},
},
cfg: {} as never,
accountId: "default",
mediaLocalRoots: [],
} as never);
expect(handleTelegramActionMock).toHaveBeenCalledWith(
expect.objectContaining({
action: "sendMessage",
to: "123456",
content: "",
buttons: [[{ text: "Approve", callback_data: "approve", style: "success" }]],
accountId: "default",
}),
expect.anything(),
expect.objectContaining({
mediaLocalRoots: [],
}),
);
});
});

View File

@ -15,6 +15,7 @@ import type {
ChannelMessageActionName,
} from "../../../src/channels/plugins/types.js";
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
import { normalizeInteractiveReply } from "../../../src/interactive/payload.js";
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
@ -23,6 +24,7 @@ import {
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "./accounts.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";
const providerId = "telegram";
@ -30,12 +32,18 @@ const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
const buttons =
params.buttons ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive));
const hasButtons = Array.isArray(buttons) && buttons.length > 0;
const message = readStringParam(params, "message", {
required: !mediaUrl && !hasButtons,
allowEmpty: true,
});
const caption = readStringParam(params, "caption", { allowEmpty: true });
const content = message || caption || "";
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = readBooleanParam(params, "asVoice");
const silent = readBooleanParam(params, "silent");
const forceDocument = readBooleanParam(params, "forceDocument");
@ -120,14 +128,15 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
}
return Array.from(actions);
},
supportsButtons: ({ cfg }) => {
getCapabilities: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledTelegramAccounts(cfg));
if (accounts.length === 0) {
return false;
return [];
}
return accounts.some((account) =>
const buttonsEnabled = accounts.some((account) =>
isTelegramInlineButtonsEnabled({ cfg, accountId: account.accountId }),
);
return buttonsEnabled ? (["interactive", "buttons"] as const) : [];
},
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");

View File

@ -8,7 +8,9 @@ import {
resolveOutboundSendDep,
type OutboundSendDeps,
} from "../../../src/infra/outbound/send-deps.js";
import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import { sendMessageTelegram } from "./send.js";
@ -59,8 +61,14 @@ export async function sendTelegramPayloadMessages(params: {
| undefined;
const quoteText =
typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined;
const text = params.payload.text ?? "";
const text =
resolveInteractiveTextFallback({
text: params.payload.text,
interactive: params.payload.interactive,
}) ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const interactiveButtons = buildTelegramInteractiveButtons(params.payload.interactive);
const buttons = telegramData?.buttons ?? interactiveButtons;
const payloadOpts = {
...params.baseOpts,
quoteText,
@ -69,7 +77,7 @@ export async function sendTelegramPayloadMessages(params: {
if (mediaUrls.length === 0) {
return await params.send(params.to, text, {
...payloadOpts,
buttons: telegramData?.buttons,
buttons,
});
}
@ -81,7 +89,7 @@ export async function sendTelegramPayloadMessages(params: {
await params.send(params.to, text, {
...payloadOpts,
mediaUrl,
...(isFirst ? { buttons: telegramData?.buttons } : {}),
...(isFirst ? { buttons } : {}),
}),
});
return finalResult ?? { messageId: "unknown", chatId: params.to };

View File

@ -24,7 +24,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
},
supportsButtons: () => false,
getCapabilities: () => [],
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {

View File

@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@ -47,9 +48,10 @@ function createChannelPlugin(params: {
blurb: string;
actions?: ChannelMessageActionName[];
listActions?: NonNullable<NonNullable<ChannelPlugin["actions"]>["listActions"]>;
supportsButtons?: boolean;
capabilities?: readonly ChannelMessageCapability[];
messaging?: ChannelPlugin["messaging"];
}): ChannelPlugin {
const actionCapabilities = params.capabilities;
return {
id: params.id as ChannelPlugin["id"],
meta: {
@ -71,7 +73,9 @@ function createChannelPlugin(params: {
(() => {
return (params.actions ?? []) as never;
}),
...(params.supportsButtons ? { supportsButtons: () => true } : {}),
...(actionCapabilities
? { getCapabilities: (_params: { cfg: unknown }) => actionCapabilities }
: {}),
},
};
}
@ -145,7 +149,7 @@ describe("message tool schema scoping", () => {
docsPath: "/channels/telegram",
blurb: "Telegram test plugin.",
actions: ["send", "react", "poll"],
supportsButtons: true,
capabilities: ["interactive", "buttons"],
});
const discordPlugin = createChannelPlugin({
@ -154,6 +158,16 @@ describe("message tool schema scoping", () => {
docsPath: "/channels/discord",
blurb: "Discord test plugin.",
actions: ["send", "poll", "poll-vote"],
capabilities: ["interactive", "components"],
});
const slackPlugin = createChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
blurb: "Slack test plugin.",
actions: ["send", "react"],
capabilities: ["interactive", "blocks"],
});
afterEach(() => {
@ -164,6 +178,7 @@ describe("message tool schema scoping", () => {
{
provider: "telegram",
expectComponents: false,
expectBlocks: false,
expectButtons: true,
expectButtonStyle: true,
expectTelegramPollExtras: true,
@ -172,16 +187,27 @@ describe("message tool schema scoping", () => {
{
provider: "discord",
expectComponents: true,
expectBlocks: false,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "poll", "poll-vote", "react"],
},
{
provider: "slack",
expectComponents: false,
expectBlocks: true,
expectButtons: false,
expectButtonStyle: false,
expectTelegramPollExtras: true,
expectedActions: ["send", "react", "poll", "poll-vote"],
},
])(
"scopes schema fields for $provider",
({
provider,
expectComponents,
expectBlocks,
expectButtons,
expectButtonStyle,
expectTelegramPollExtras,
@ -191,6 +217,7 @@ describe("message tool schema scoping", () => {
createTestRegistry([
{ pluginId: "telegram", source: "test", plugin: telegramPlugin },
{ pluginId: "discord", source: "test", plugin: discordPlugin },
{ pluginId: "slack", source: "test", plugin: slackPlugin },
]),
);
@ -206,6 +233,11 @@ describe("message tool schema scoping", () => {
} else {
expect(properties.components).toBeUndefined();
}
if (expectBlocks) {
expect(properties.blocks).toBeDefined();
} else {
expect(properties.blocks).toBeUndefined();
}
if (expectButtons) {
expect(properties.buttons).toBeDefined();
} else {
@ -263,7 +295,7 @@ describe("message tool schema scoping", () => {
.channels?.telegram;
return telegramCfg?.actions?.poll === false ? ["send", "react"] : ["send", "react", "poll"];
},
supportsButtons: true,
capabilities: ["interactive", "buttons"],
});
setActivePluginRegistry(

View File

@ -2,12 +2,11 @@ import { Type } from "@sinclair/typebox";
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import {
channelSupportsMessageCapability,
channelSupportsMessageCapabilityForChannel,
listChannelMessageActions,
supportsChannelMessageButtons,
supportsChannelMessageButtonsForChannel,
supportsChannelMessageCards,
supportsChannelMessageCardsForChannel,
} from "../../channels/plugins/message-actions.js";
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
import {
CHANNEL_MESSAGE_ACTION_NAMES,
type ChannelMessageActionName,
@ -163,10 +162,41 @@ const discordComponentMessageSchema = Type.Object(
},
);
const interactiveOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
});
const interactiveButtonSchema = Type.Object({
label: Type.String(),
value: Type.String(),
style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger"])),
});
const interactiveBlockSchema = Type.Object({
type: stringEnum(["text", "buttons", "select"]),
text: Type.Optional(Type.String()),
buttons: Type.Optional(Type.Array(interactiveButtonSchema)),
placeholder: Type.Optional(Type.String()),
options: Type.Optional(Type.Array(interactiveOptionSchema)),
});
const interactiveMessageSchema = Type.Object(
{
blocks: Type.Array(interactiveBlockSchema),
},
{
description:
"Shared interactive message payload for buttons and selects. Channels render this into their native components when supported.",
},
);
function buildSendSchema(options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
}) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
@ -208,6 +238,7 @@ function buildSendSchema(options: {
description: "Send image/GIF as document to avoid Telegram compression (Telegram only).",
}),
),
interactive: Type.Optional(interactiveMessageSchema),
buttons: Type.Optional(
Type.Array(
Type.Array(
@ -232,16 +263,33 @@ function buildSendSchema(options: {
),
),
components: Type.Optional(discordComponentMessageSchema),
blocks: Type.Optional(
Type.Array(
Type.Object(
{},
{
additionalProperties: true,
description: "Slack Block Kit payload blocks (Slack only).",
},
),
),
),
};
if (!options.includeButtons) {
delete props.buttons;
}
if (!options.includeInteractive) {
delete props.interactive;
}
if (!options.includeCards) {
delete props.card;
}
if (!options.includeComponents) {
delete props.components;
}
if (!options.includeBlocks) {
delete props.blocks;
}
return props;
}
@ -447,9 +495,11 @@ function buildChannelManagementSchema() {
}
function buildMessageToolSchemaProps(options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
includeTelegramPollExtras: boolean;
}) {
return {
@ -472,9 +522,11 @@ function buildMessageToolSchemaProps(options: {
function buildMessageToolSchemaFromActions(
actions: readonly string[],
options: {
includeInteractive: boolean;
includeButtons: boolean;
includeCards: boolean;
includeComponents: boolean;
includeBlocks: boolean;
includeTelegramPollExtras: boolean;
},
) {
@ -486,9 +538,11 @@ function buildMessageToolSchemaFromActions(
}
const MessageToolSchema = buildMessageToolSchemaFromActions(AllMessageActions, {
includeInteractive: true,
includeButtons: true,
includeCards: true,
includeComponents: true,
includeBlocks: true,
includeTelegramPollExtras: true,
});
@ -539,16 +593,59 @@ function resolveMessageToolSchemaActions(params: {
return actions.length > 0 ? actions : ["send"];
}
function resolveIncludeCapability(
params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
},
capability: ChannelMessageCapability,
): boolean {
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
if (currentChannel) {
return channelSupportsMessageCapabilityForChannel(
{
cfg: params.cfg,
channel: currentChannel,
},
capability,
);
}
return channelSupportsMessageCapability(params.cfg, capability);
}
function resolveIncludeComponents(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
if (currentChannel) {
return currentChannel === "discord";
}
// Components are currently Discord-specific.
return listChannelSupportedActions({ cfg: params.cfg, channel: "discord" }).length > 0;
return resolveIncludeCapability(params, "components");
}
function resolveIncludeInteractive(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "interactive");
}
function resolveIncludeButtons(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "buttons");
}
function resolveIncludeCards(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "cards");
}
function resolveIncludeBlocks(params: {
cfg: OpenClawConfig;
currentChannelProvider?: string;
}): boolean {
return resolveIncludeCapability(params, "blocks");
}
function resolveIncludeTelegramPollExtras(params: {
@ -566,20 +663,19 @@ function buildMessageToolSchema(params: {
currentChannelProvider?: string;
currentChannelId?: string;
}) {
const currentChannel = normalizeMessageChannel(params.currentChannelProvider);
const actions = resolveMessageToolSchemaActions(params);
const includeButtons = currentChannel
? supportsChannelMessageButtonsForChannel({ cfg: params.cfg, channel: currentChannel })
: supportsChannelMessageButtons(params.cfg);
const includeCards = currentChannel
? supportsChannelMessageCardsForChannel({ cfg: params.cfg, channel: currentChannel })
: supportsChannelMessageCards(params.cfg);
const includeInteractive = resolveIncludeInteractive(params);
const includeButtons = resolveIncludeButtons(params);
const includeCards = resolveIncludeCards(params);
const includeComponents = resolveIncludeComponents(params);
const includeBlocks = resolveIncludeBlocks(params);
const includeTelegramPollExtras = resolveIncludeTelegramPollExtras(params);
return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], {
includeInteractive,
includeButtons,
includeCards,
includeComponents,
includeBlocks,
includeTelegramPollExtras,
});
}

View File

@ -32,11 +32,12 @@ export function normalizeReplyPayload(
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0);
const hasInteractive = (payload.interactive?.blocks.length ?? 0) > 0;
const hasChannelData = Boolean(
payload.channelData && Object.keys(payload.channelData).length > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia && !hasChannelData) {
if (!trimmed && !hasMedia && !hasInteractive && !hasChannelData) {
opts.onSkip?.("empty");
return null;
}
@ -44,7 +45,7 @@ export function normalizeReplyPayload(
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
let text = payload.text ?? undefined;
if (text && isSilentReplyText(text, silentToken)) {
if (!hasMedia && !hasChannelData) {
if (!hasMedia && !hasInteractive && !hasChannelData) {
opts.onSkip?.("silent");
return null;
}
@ -55,7 +56,7 @@ export function normalizeReplyPayload(
// silent just like the exact-match path above. (#30916, #30955)
if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) {
text = stripSilentToken(text, silentToken);
if (!text && !hasMedia && !hasChannelData) {
if (!text && !hasMedia && !hasInteractive && !hasChannelData) {
opts.onSkip?.("silent");
return null;
}
@ -71,7 +72,7 @@ export function normalizeReplyPayload(
if (stripped.didStrip) {
opts.onHeartbeatStrip?.();
}
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
if (stripped.shouldSkip && !hasMedia && !hasInteractive && !hasChannelData) {
opts.onSkip?.("heartbeat");
return null;
}
@ -81,7 +82,7 @@ export function normalizeReplyPayload(
if (text) {
text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) });
}
if (!text?.trim() && !hasMedia && !hasChannelData) {
if (!text?.trim() && !hasMedia && !hasInteractive && !hasChannelData) {
opts.onSkip?.("empty");
return null;
}

View File

@ -197,8 +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) ?? {};
const getSlackInteractive = (result: ReturnType<typeof parseSlackDirectives>) =>
result.interactive?.blocks ?? [];
describe("hasLineDirectives", () => {
it("matches expected detection across directive patterns", () => {
@ -601,93 +601,52 @@ describe("parseLineDirectives", () => {
});
describe("parseSlackDirectives", () => {
it("builds section and button blocks from slack_buttons directives", () => {
it("builds shared text 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([
expect(getSlackInteractive(result)).toEqual([
{
type: "section",
text: {
type: "mrkdwn",
text: "Choose an action",
},
type: "text",
text: "Choose an action",
},
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
elements: [
type: "buttons",
buttons: [
{
type: "button",
action_id: "openclaw:reply_button",
text: {
type: "plain_text",
text: "Approve",
emoji: true,
},
value: "reply_1_approve",
label: "Approve",
value: "approve",
},
{
type: "button",
action_id: "openclaw:reply_button",
text: {
type: "plain_text",
text: "Reject",
emoji: true,
},
value: "reply_2_reject",
label: "Reject",
value: "reject",
},
],
},
]);
});
it("builds static select blocks from slack_select directives", () => {
it("builds shared 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([
expect(getSlackInteractive(result)).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",
},
],
},
type: "select",
placeholder: "Choose a project",
options: [
{ label: "Alpha", value: "alpha" },
{ label: "Beta", value: "beta" },
],
},
]);
});
it("appends Slack interactive blocks to existing slack blocks", () => {
it("leaves existing slack blocks in channelData and appends shared interactive blocks", () => {
const result = parseSlackDirectives({
text: "Act now [[slack_buttons: Retry:retry]]",
channelData: {
@ -698,30 +657,19 @@ describe("parseSlackDirectives", () => {
});
expect(result.text).toBe("Act now");
expect(getSlackData(result).blocks).toEqual([
{ type: "divider" },
expect(result.channelData).toEqual({
slack: {
blocks: [{ type: "divider" }],
},
});
expect(getSlackInteractive(result)).toEqual([
{
type: "section",
text: {
type: "mrkdwn",
text: "Act now",
},
type: "text",
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",
},
],
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
]);
});
@ -731,146 +679,70 @@ describe("parseSlackDirectives", () => {
text: "[[slack_select: Pick one | Alpha:alpha]] then [[slack_buttons: Retry:retry]]",
});
expect(getSlackData(result).blocks).toEqual([
expect(getSlackInteractive(result)).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: "select",
placeholder: "Pick one",
options: [{ label: "Alpha", value: "alpha" }],
},
{
type: "section",
text: {
type: "mrkdwn",
text: "then",
},
type: "text",
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",
},
],
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
]);
});
it("truncates Slack interactive reply strings to safe Block Kit limits", () => {
it("preserves long Slack directive values in the shared interactive model", () => {
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);
expect(getSlackInteractive(result)).toEqual([
{
type: "text",
text: "y".repeat(3100),
},
{
type: "select",
placeholder: long,
options: [{ label: long, value: long }],
},
{
type: "buttons",
buttons: [{ label: long, value: long }],
},
]);
});
it("falls back to the original payload when generated blocks would exceed Slack limits", () => {
it("keeps existing interactive blocks when compiling additional Slack directives", () => {
const result = parseSlackDirectives({
text: "Choose [[slack_buttons: Retry:retry]]",
channelData: {
slack: {
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
},
interactive: {
blocks: [{ type: "text", text: "Existing" }],
},
});
expect(getSlackInteractive(result)).toEqual([
{ type: "text", text: "Existing" },
{ type: "text", text: "Choose" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
]);
});
it("ignores malformed directive choices when none remain", () => {
const result = parseSlackDirectives({
text: "Choose [[slack_buttons: : , : ]]",
});
expect(result).toEqual({
text: "Choose [[slack_buttons: Retry:retry]]",
channelData: {
slack: {
blocks: Array.from({ length: 49 }, () => ({ type: "divider" })),
},
},
text: "Choose [[slack_buttons: : , : ]]",
});
});
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>() {
@ -1796,22 +1668,17 @@ describe("createReplyDispatcher", () => {
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",
},
],
},
interactive: {
blocks: [
{
type: "text",
text: "Choose",
},
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry" }],
},
],
},
});
});

View File

@ -79,6 +79,7 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
payload.audioAsVoice ||
payload.interactive ||
payload.channelData,
);
}

View File

@ -158,10 +158,10 @@ describe("normalizeReplyPayload", () => {
expect(result).not.toBeNull();
expect(result!.text).toBe("hello [[slack_buttons: Retry:retry, Ignore:ignore]]");
expect(result!.channelData).toBeUndefined();
expect(result!.interactive).toBeUndefined();
});
it("applies responsePrefix before compiling Slack directives into blocks", () => {
it("applies responsePrefix before compiling Slack directives into shared interactive blocks", () => {
const result = normalizeReplyPayload(
{
text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]",
@ -171,44 +171,26 @@ describe("normalizeReplyPayload", () => {
expect(result).not.toBeNull();
expect(result!.text).toBe("[bot] hello");
expect(result!.channelData).toEqual({
slack: {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "[bot] hello",
expect(result!.interactive).toEqual({
blocks: [
{
type: "text",
text: "[bot] hello",
},
{
type: "buttons",
buttons: [
{
label: "Retry",
value: "retry",
},
},
{
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",
},
],
},
],
},
{
label: "Ignore",
value: "ignore",
},
],
},
],
});
});
});

View File

@ -117,6 +117,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
? [externalPayload.mediaUrl]
: [];
const replyToId = externalPayload.replyToId;
const hasInteractive = (externalPayload.interactive?.blocks.length ?? 0) > 0;
let hasSlackBlocks = false;
if (
channel === "slack" &&
@ -135,7 +136,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
}
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0 && !hasSlackBlocks) {
if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasSlackBlocks) {
return { ok: true };
}

View File

@ -1,22 +1,9 @@
import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js";
import { truncateSlackText } from "../../../extensions/slack/src/truncate.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;
@ -50,51 +37,35 @@ function parseChoices(raw: string, maxItems: number): SlackChoice[] {
.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 {
function buildTextBlock(
text: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
return {
type: "section",
text: {
type: "mrkdwn",
text: truncateSlackText(trimmed, SLACK_SECTION_TEXT_MAX),
},
};
return { type: "text", text: trimmed };
}
function buildButtonsBlock(raw: string, index: number): SlackBlock | null {
function buildButtonsBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | 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),
type: "buttons",
buttons: choices.map((choice) => ({
label: choice.label,
value: choice.value,
})),
};
}
function buildSelectBlock(raw: string, index: number): SlackBlock | null {
function buildSelectBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const parts = raw
.split("|")
.map((entry) => entry.trim())
@ -109,40 +80,12 @@ function buildSelectBlock(raw: string, index: number): SlackBlock | null {
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),
})),
},
],
type: "select",
placeholder,
options: choices,
};
}
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);
@ -154,10 +97,8 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
return payload;
}
const generatedBlocks: SlackBlock[] = [];
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const visibleTextParts: string[] = [];
let buttonIndex = 0;
let selectIndex = 0;
let cursor = 0;
let matchedDirective = false;
let generatedInteractiveBlock = false;
@ -171,14 +112,14 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
const index = match.index ?? 0;
const precedingText = text.slice(cursor, index);
visibleTextParts.push(precedingText);
const section = buildSectionBlock(precedingText);
const section = buildTextBlock(precedingText);
if (section) {
generatedBlocks.push(section);
}
const block =
directiveType.toLowerCase() === "slack_buttons"
? buildButtonsBlock(body, ++buttonIndex)
: buildSelectBlock(body, ++selectIndex);
? buildButtonsBlock(body)
: buildSelectBlock(body);
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
@ -188,7 +129,7 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
const trailingText = text.slice(cursor);
visibleTextParts.push(trailingText);
const trailingSection = buildSectionBlock(trailingText);
const trailingSection = buildTextBlock(trailingText);
if (trailingSection) {
generatedBlocks.push(trailingSection);
}
@ -198,21 +139,11 @@ export function parseSlackDirectives(payload: ReplyPayload): ReplyPayload {
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,
},
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}

View File

@ -1,4 +1,5 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { InteractiveReply } from "../interactive/payload.js";
import type { TypingController } from "./reply/typing.js";
export type BlockReplyContext = {
@ -76,6 +77,7 @@ export type ReplyPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
interactive?: InteractiveReply;
btw?: {
question: string;
};

View File

@ -1292,6 +1292,25 @@ describe("slack actions adapter", () => {
}
});
it("does not attach empty blocks to plain media sends", async () => {
handleSlackAction.mockClear();
await runSlackAction("send", {
to: "channel:C1",
message: "",
media: "https://example.com/image.png",
});
const [params] = handleSlackAction.mock.calls[0] ?? [];
expect(params).toMatchObject({
action: "sendMessage",
to: "channel:C1",
content: "",
mediaUrl: "https://example.com/image.png",
});
expect(params).not.toHaveProperty("blocks");
});
it("rejects edit when both message and blocks are missing", async () => {
const { cfg, actions } = slackHarness();

View File

@ -6,19 +6,19 @@ import {
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import {
supportsChannelMessageButtons,
supportsChannelMessageButtonsForChannel,
supportsChannelMessageCards,
supportsChannelMessageCardsForChannel,
channelSupportsMessageCapability,
channelSupportsMessageCapabilityForChannel,
listChannelMessageCapabilities,
listChannelMessageCapabilitiesForChannel,
} from "./message-actions.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
import type { ChannelPlugin } from "./types.js";
const emptyRegistry = createTestRegistry([]);
function createMessageActionsPlugin(params: {
id: "discord" | "telegram";
supportsButtons: boolean;
supportsCards: boolean;
capabilities: readonly ChannelMessageCapability[];
}): ChannelPlugin {
return {
...createChannelTestPluginBase({
@ -31,22 +31,19 @@ function createMessageActionsPlugin(params: {
}),
actions: {
listActions: () => ["send"],
supportsButtons: () => params.supportsButtons,
supportsCards: () => params.supportsCards,
getCapabilities: () => params.capabilities,
},
};
}
const buttonsPlugin = createMessageActionsPlugin({
id: "discord",
supportsButtons: true,
supportsCards: false,
capabilities: ["interactive", "buttons"],
});
const cardsPlugin = createMessageActionsPlugin({
id: "telegram",
supportsButtons: false,
supportsCards: true,
capabilities: ["cards"],
});
function activateMessageActionTestRegistry() {
@ -63,25 +60,66 @@ describe("message action capability checks", () => {
setActivePluginRegistry(emptyRegistry);
});
it("aggregates buttons/card support across plugins", () => {
it("aggregates capabilities across plugins", () => {
activateMessageActionTestRegistry();
expect(supportsChannelMessageButtons({} as OpenClawConfig)).toBe(true);
expect(supportsChannelMessageCards({} as OpenClawConfig)).toBe(true);
expect(listChannelMessageCapabilities({} as OpenClawConfig).toSorted()).toEqual([
"buttons",
"cards",
"interactive",
]);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "interactive")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "buttons")).toBe(true);
expect(channelSupportsMessageCapability({} as OpenClawConfig, "cards")).toBe(true);
});
it("checks per-channel capabilities", () => {
activateMessageActionTestRegistry();
expect(
supportsChannelMessageButtonsForChannel({ cfg: {} as OpenClawConfig, channel: "discord" }),
listChannelMessageCapabilitiesForChannel({
cfg: {} as OpenClawConfig,
channel: "discord",
}),
).toEqual(["interactive", "buttons"]);
expect(
listChannelMessageCapabilitiesForChannel({
cfg: {} as OpenClawConfig,
channel: "telegram",
}),
).toEqual(["cards"]);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "discord" },
"interactive",
),
).toBe(true);
expect(
supportsChannelMessageButtonsForChannel({ cfg: {} as OpenClawConfig, channel: "telegram" }),
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "telegram" },
"interactive",
),
).toBe(false);
expect(
supportsChannelMessageCardsForChannel({ cfg: {} as OpenClawConfig, channel: "telegram" }),
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "discord" },
"buttons",
),
).toBe(true);
expect(supportsChannelMessageCardsForChannel({ cfg: {} as OpenClawConfig })).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "telegram" },
"buttons",
),
).toBe(false);
expect(
channelSupportsMessageCapabilityForChannel(
{ cfg: {} as OpenClawConfig, channel: "telegram" },
"cards",
),
).toBe(true);
expect(channelSupportsMessageCapabilityForChannel({ cfg: {} as OpenClawConfig }, "cards")).toBe(
false,
);
});
});

View File

@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../../config/config.js";
import { getChannelPlugin, listChannelPlugins } from "./index.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
const trustedRequesterRequiredByChannel: Readonly<
@ -30,58 +31,52 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
return Array.from(actions);
}
export function supportsChannelMessageButtons(cfg: OpenClawConfig): boolean {
return supportsMessageFeature(cfg, (actions) => actions?.supportsButtons?.({ cfg }) === true);
}
export function supportsChannelMessageButtonsForChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): boolean {
return supportsMessageFeatureForChannel(
params,
(actions) => actions.supportsButtons?.(params) === true,
);
}
export function supportsChannelMessageCards(cfg: OpenClawConfig): boolean {
return supportsMessageFeature(cfg, (actions) => actions?.supportsCards?.({ cfg }) === true);
}
export function supportsChannelMessageCardsForChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): boolean {
return supportsMessageFeatureForChannel(
params,
(actions) => actions.supportsCards?.(params) === true,
);
}
function supportsMessageFeature(
function listCapabilities(
actions: ChannelActions,
cfg: OpenClawConfig,
check: (actions: ChannelActions) => boolean,
): boolean {
): readonly ChannelMessageCapability[] {
return actions.getCapabilities?.({ cfg }) ?? [];
}
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
const capabilities = new Set<ChannelMessageCapability>();
for (const plugin of listChannelPlugins()) {
if (plugin.actions && check(plugin.actions)) {
return true;
if (!plugin.actions) {
continue;
}
for (const capability of listCapabilities(plugin.actions, cfg)) {
capabilities.add(capability);
}
}
return false;
return Array.from(capabilities);
}
function supportsMessageFeatureForChannel(
export function listChannelMessageCapabilitiesForChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): ChannelMessageCapability[] {
if (!params.channel) {
return [];
}
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : [];
}
export function channelSupportsMessageCapability(
cfg: OpenClawConfig,
capability: ChannelMessageCapability,
): boolean {
return listChannelMessageCapabilities(cfg).includes(capability);
}
export function channelSupportsMessageCapabilityForChannel(
params: {
cfg: OpenClawConfig;
channel?: string;
},
check: (actions: ChannelActions) => boolean,
capability: ChannelMessageCapability,
): boolean {
if (!params.channel) {
return false;
}
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
return plugin?.actions ? check(plugin.actions) : false;
return listChannelMessageCapabilitiesForChannel(params).includes(capability);
}
export async function dispatchChannelMessageAction(

View File

@ -0,0 +1,9 @@
export const CHANNEL_MESSAGE_CAPABILITIES = [
"interactive",
"buttons",
"cards",
"components",
"blocks",
] as const;
export type ChannelMessageCapability = (typeof CHANNEL_MESSAGE_CAPABILITIES)[number];

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { reduceInteractiveReply } from "./interactive.js";
describe("reduceInteractiveReply", () => {
it("walks authored blocks in order", () => {
const order = reduceInteractiveReply(
{
blocks: [
{ type: "text", text: "first" },
{ type: "buttons", buttons: [{ label: "Retry", value: "retry" }] },
{ type: "select", options: [{ label: "Alpha", value: "alpha" }] },
],
},
[] as string[],
(state, block) => {
state.push(block.type);
return state;
},
);
expect(order).toEqual(["text", "buttons", "select"]);
});
it("returns the initial state when interactive payload is missing", () => {
expect(reduceInteractiveReply(undefined, 3, (value) => value + 1)).toBe(3);
});
});

View File

@ -0,0 +1,13 @@
import type { InteractiveReply, InteractiveReplyBlock } from "../../../interactive/payload.js";
export function reduceInteractiveReply<TState>(
interactive: InteractiveReply | undefined,
initialState: TState,
reduce: (state: TState, block: InteractiveReplyBlock, index: number) => TState,
): TState {
let state = initialState;
for (const [index, block] of (interactive?.blocks ?? []).entries()) {
state = reduce(state, block, index);
}
return state;
}

View File

@ -100,4 +100,71 @@ describe("slackOutbound sendPayload", () => {
await expect(run()).rejects.toThrow(/blocks must be an array/i);
expect(sendMock).not.toHaveBeenCalled();
});
it("sends media before a separate interactive blocks message", async () => {
const { run, sendMock, to } = createHarness({
payload: {
text: "Approval required",
mediaUrl: "https://example.com/image.png",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
},
],
},
},
sendResults: [{ messageId: "sl-media" }, { messageId: "sl-controls" }],
});
const result = await run();
expect(sendMock).toHaveBeenCalledTimes(2);
expect(sendMock).toHaveBeenNthCalledWith(
1,
to,
"",
expect.objectContaining({
mediaUrl: "https://example.com/image.png",
}),
);
expect(sendMock.mock.calls[0]?.[2]).not.toHaveProperty("blocks");
expect(sendMock).toHaveBeenNthCalledWith(
2,
to,
"Approval required",
expect.objectContaining({
blocks: [
expect.objectContaining({
type: "actions",
}),
],
}),
);
expect(result).toMatchObject({ channel: "slack", messageId: "sl-controls" });
});
it("fails when merged Slack blocks exceed the platform limit", async () => {
const { run, sendMock } = createHarness({
payload: {
channelData: {
slack: {
blocks: Array.from({ length: 50 }, () => ({ type: "divider" })),
},
},
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Allow", value: "pluginbind:approval-123:o" }],
},
],
},
},
});
await expect(run()).rejects.toThrow(/Slack blocks cannot exceed 50 items/i);
expect(sendMock).not.toHaveBeenCalled();
});
});

View File

@ -1,10 +1,34 @@
import { parseSlackBlocksInput } from "../../../../extensions/slack/src/blocks-input.js";
import {
buildSlackInteractiveBlocks,
type SlackBlock,
} from "../../../../extensions/slack/src/blocks-render.js";
import { sendMessageSlack, type SlackSendIdentity } from "../../../../extensions/slack/src/send.js";
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import { resolveOutboundSendDep } from "../../../infra/outbound/send-deps.js";
import {
resolveInteractiveTextFallback,
type InteractiveReply,
} from "../../../interactive/payload.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import type { ChannelOutboundAdapter } from "../types.js";
import { sendTextMediaPayload } from "./direct-text-media.js";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequence,
sendTextMediaPayload,
} from "./direct-text-media.js";
const SLACK_MAX_BLOCKS = 50;
function resolveRenderedInteractiveBlocks(
interactive?: InteractiveReply,
): SlackBlock[] | undefined {
if (!interactive) {
return undefined;
}
const blocks = buildSlackInteractiveBlocks(interactive);
return blocks.length > 0 ? blocks : undefined;
}
function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined {
if (!identity) {
@ -97,12 +121,29 @@ async function sendSlackOutboundMessage(params: {
return { channel: "slack" as const, ...result };
}
function resolveSlackBlocks(channelData: Record<string, unknown> | undefined) {
const slackData = channelData?.slack;
function resolveSlackBlocks(payload: {
channelData?: Record<string, unknown>;
interactive?: InteractiveReply;
}) {
const slackData = payload.channelData?.slack;
const renderedInteractive = resolveRenderedInteractiveBlocks(payload.interactive);
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
return renderedInteractive;
}
let existingBlocks: SlackBlock[] | undefined;
existingBlocks = parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks) as
| SlackBlock[]
| undefined;
const mergedBlocks = [...(existingBlocks ?? []), ...(renderedInteractive ?? [])];
if (mergedBlocks.length === 0) {
return undefined;
}
return parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks);
if (mergedBlocks.length > SLACK_MAX_BLOCKS) {
throw new Error(
`Slack blocks cannot exceed ${SLACK_MAX_BLOCKS} items after interactive render`,
);
}
return mergedBlocks;
}
export const slackOutbound: ChannelOutboundAdapter = {
@ -110,15 +151,61 @@ export const slackOutbound: ChannelOutboundAdapter = {
chunker: null,
textChunkLimit: 4000,
sendPayload: async (ctx) => {
const blocks = resolveSlackBlocks(ctx.payload.channelData);
const payload = {
...ctx.payload,
text:
resolveInteractiveTextFallback({
text: ctx.payload.text,
interactive: ctx.payload.interactive,
}) ?? "",
};
const blocks = resolveSlackBlocks(payload);
if (!blocks) {
return await sendTextMediaPayload({ channel: "slack", ctx, adapter: slackOutbound });
return await sendTextMediaPayload({
channel: "slack",
ctx: {
...ctx,
payload,
},
adapter: slackOutbound,
});
}
const mediaUrls = resolvePayloadMediaUrls(payload);
if (mediaUrls.length === 0) {
return await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
});
}
await sendPayloadMediaSequence({
text: "",
mediaUrls,
send: async ({ text, mediaUrl }) =>
await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
threadId: ctx.threadId,
identity: ctx.identity,
}),
});
return await sendSlackOutboundMessage({
cfg: ctx.cfg,
to: ctx.to,
text: ctx.payload.text ?? "",
mediaUrl: ctx.payload.mediaUrl,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
blocks,
accountId: ctx.accountId,

View File

@ -1,3 +1,4 @@
import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js";
import {
extractSlackToolSend,
listSlackMessageActions,
@ -10,6 +11,16 @@ import type { ChannelMessageActionAdapter } from "./types.js";
export function createSlackActions(providerId: string): ChannelMessageActionAdapter {
return {
listActions: ({ cfg }) => listSlackMessageActions(cfg),
getCapabilities: ({ cfg }) => {
const capabilities = new Set<"interactive" | "blocks">();
if (listSlackMessageActions(cfg).includes("send")) {
capabilities.add("blocks");
}
if (isSlackInteractiveRepliesEnabled({ cfg })) {
capabilities.add("interactive");
}
return Array.from(capabilities);
},
extractToolSend: ({ args }) => extractSlackToolSend(args),
handleAction: async (ctx) => {
return await handleSlackMessageAction({

View File

@ -7,6 +7,7 @@ import type { GatewayClientMode, GatewayClientName } from "../../utils/message-c
import type { ChatType } from "../chat-type.js";
import type { ChatChannelId } from "../registry.js";
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
import type { ChannelMessageCapability } from "./message-capabilities.js";
export type ChannelId = ChatChannelId | (string & {});
@ -372,8 +373,7 @@ export type ChannelMessageActionAdapter = {
*/
listActions?: (params: { cfg: OpenClawConfig }) => ChannelMessageActionName[];
supportsAction?: (params: { action: ChannelMessageActionName }) => boolean;
supportsButtons?: (params: { cfg: OpenClawConfig }) => boolean;
supportsCards?: (params: { cfg: OpenClawConfig }) => boolean;
getCapabilities?: (params: { cfg: OpenClawConfig }) => readonly ChannelMessageCapability[];
extractToolSend?: (params: { args: Record<string, unknown> }) => ChannelToolSend | null;
handleAction?: (ctx: ChannelMessageActionContext) => Promise<AgentToolResult<unknown>>;
};

View File

@ -1,8 +1,10 @@
import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js";
export { CHANNEL_MESSAGE_ACTION_NAMES } from "./message-action-names.js";
export { CHANNEL_MESSAGE_CAPABILITIES } from "./message-capabilities.js";
export type ChannelMessageActionName = ChannelMessageActionNameFromList;
export type { ChannelMessageCapability } from "./message-capabilities.js";
export type {
ChannelAuthAdapter,

View File

@ -15,6 +15,10 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
"--media <path-or-url>",
"Attach media (image/audio/video/document). Accepts local paths or URLs.",
)
.option(
"--interactive <json>",
"Shared interactive payload as JSON (buttons/selects rendered natively by supported channels)",
)
.option(
"--buttons <json>",
"Telegram inline keyboard buttons as JSON (array of button rows)",

View File

@ -1,14 +1,12 @@
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { truncateUtf16Safe } from "../../utils.js";
import { shouldSkipHeartbeatOnlyDelivery } from "../heartbeat-policy.js";
type DeliveryPayload = {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
channelData?: Record<string, unknown>;
isError?: boolean;
};
type DeliveryPayload = Pick<
ReplyPayload,
"text" | "mediaUrl" | "mediaUrls" | "interactive" | "channelData" | "isError"
>;
export function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
@ -65,8 +63,9 @@ export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) {
const isDeliverable = (p: DeliveryPayload) => {
const text = (p?.text ?? "").trim();
const hasMedia = Boolean(p?.mediaUrl) || (p?.mediaUrls?.length ?? 0) > 0;
const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0;
const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0;
return text || hasMedia || hasChannelData;
return text || hasMedia || hasInteractive || hasChannelData;
};
for (let i = payloads.length - 1; i >= 0; i--) {
if (payloads[i]?.isError) {

View File

@ -404,6 +404,38 @@ describe("deliverOutboundPayloads", () => {
]);
});
it("renders shared interactive payloads into telegram buttons", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });
await deliverTelegramPayload({
sendTelegram,
payload: {
text: "Approval required",
interactive: {
blocks: [
{
type: "buttons",
buttons: [
{ label: "Allow once", value: "allow-once", style: "success" },
{ label: "Always allow", value: "allow-always", style: "primary" },
{ label: "Deny", value: "deny", style: "danger" },
],
},
],
},
},
});
const sendOpts = sendTelegram.mock.calls[0]?.[2] as { buttons?: unknown } | undefined;
expect(sendOpts?.buttons).toEqual([
[
{ text: "Allow once", callback_data: "allow-once", style: "success" },
{ text: "Always allow", callback_data: "allow-always", style: "primary" },
{ text: "Deny", callback_data: "deny", style: "danger" },
],
]);
});
it("scopes media local roots to the active agent workspace when agentId is provided", async () => {
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" });

View File

@ -238,17 +238,22 @@ function hasChannelDataPayload(payload: ReplyPayload): boolean {
return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0);
}
function hasInteractivePayload(payload: ReplyPayload): boolean {
return (payload.interactive?.blocks.length ?? 0) > 0;
}
function normalizePayloadForChannelDelivery(
payload: ReplyPayload,
channelId: string,
): ReplyPayload | null {
const hasMedia = hasMediaPayload(payload);
const hasChannelData = hasChannelDataPayload(payload);
const hasInteractive = hasInteractivePayload(payload);
const rawText = typeof payload.text === "string" ? payload.text : "";
const normalizedText =
channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText;
if (!normalizedText.trim()) {
if (!hasMedia && !hasChannelData) {
if (!hasMedia && !hasInteractive && !hasChannelData) {
return null;
}
return {
@ -299,6 +304,7 @@ function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
return {
text: payload.text ?? "",
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
interactive: payload.interactive,
channelData: payload.channelData,
};
}
@ -697,7 +703,10 @@ async function deliverOutboundPayloadsCore(
threadId: params.threadId ?? undefined,
forceDocument: params.forceDocument,
};
if (handler.sendPayload && effectivePayload.channelData) {
if (
handler.sendPayload &&
(effectivePayload.channelData || hasInteractivePayload(effectivePayload))
) {
const delivery = await handler.sendPayload(effectivePayload, sendOverrides);
results.push(delivery);
emitMessageSent({

View File

@ -422,3 +422,20 @@ export function parseComponentsParam(params: Record<string, unknown>): void {
throw new Error("--components must be valid JSON");
}
}
export function parseInteractiveParam(params: Record<string, unknown>): void {
const raw = params.interactive;
if (typeof raw !== "string") {
return;
}
const trimmed = raw.trim();
if (!trimmed) {
delete params.interactive;
return;
}
try {
params.interactive = JSON.parse(trimmed) as unknown;
} catch {
throw new Error("--interactive must be valid JSON");
}
}

View File

@ -186,6 +186,46 @@ describe("runMessageAction context isolation", () => {
).rejects.toThrow(/message required/i);
});
it("allows send when only shared interactive payloads are provided", async () => {
const result = await runDrySend({
cfg: {
channels: {
telegram: {
botToken: "telegram-test",
},
},
} as OpenClawConfig,
actionParams: {
channel: "telegram",
target: "123456",
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
},
],
},
},
});
expect(result.kind).toBe("send");
});
it("allows send when only Slack blocks are provided", async () => {
const result = await runDrySend({
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
blocks: [{ type: "divider" }],
},
toolContext: { currentChannelId: "C12345678" },
});
expect(result.kind).toBe("send");
});
it.each([
{
name: "structured poll params",

View File

@ -34,6 +34,7 @@ import {
parseButtonsParam,
parseCardParam,
parseComponentsParam,
parseInteractiveParam,
readBooleanParam,
resolveAttachmentMediaPolicy,
resolveSlackAutoThreadId,
@ -403,12 +404,18 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const hasButtons = Array.isArray(params.buttons) && params.buttons.length > 0;
const hasCard = params.card != null && typeof params.card === "object";
const hasComponents = params.components != null && typeof params.components === "object";
const hasInteractive = params.interactive != null && typeof params.interactive === "object";
const hasBlocks =
(Array.isArray(params.blocks) && params.blocks.length > 0) ||
(typeof params.blocks === "string" && params.blocks.trim().length > 0);
const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
let message =
readStringParam(params, "message", {
required: !mediaHint && !hasCard && !hasComponents,
required:
!mediaHint && !hasButtons && !hasCard && !hasComponents && !hasInteractive && !hasBlocks,
allowEmpty: true,
}) ?? "";
if (message.includes("\\n")) {
@ -474,7 +481,16 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
message = "";
}
}
if (!message.trim() && !mediaUrl && mergedMediaUrls.length === 0 && !hasCard && !hasComponents) {
if (
!message.trim() &&
!mediaUrl &&
mergedMediaUrls.length === 0 &&
!hasButtons &&
!hasCard &&
!hasComponents &&
!hasInteractive &&
!hasBlocks
) {
throw new Error("send requires text or media");
}
params.message = message;
@ -714,6 +730,7 @@ export async function runMessageAction(
parseButtonsParam(params);
parseCardParam(params);
parseComponentsParam(params);
parseInteractiveParam(params);
const action = input.action;
if (action === "broadcast") {

View File

@ -5,10 +5,12 @@ import {
shouldSuppressReasoningPayload,
} from "../../auto-reply/reply/reply-payloads.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { InteractiveReply } from "../../interactive/payload.js";
export type NormalizedOutboundPayload = {
text: string;
mediaUrls: string[];
interactive?: InteractiveReply;
channelData?: Record<string, unknown>;
};
@ -16,6 +18,7 @@ export type OutboundPayloadJson = {
text: string;
mediaUrl: string | null;
mediaUrls?: string[];
interactive?: InteractiveReply;
channelData?: Record<string, unknown>;
};
@ -89,15 +92,18 @@ export function normalizeOutboundPayloads(
const normalizedPayloads: NormalizedOutboundPayload[] = [];
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const interactive = payload.interactive;
const channelData = payload.channelData;
const hasChannelData = Boolean(channelData && Object.keys(channelData).length > 0);
const hasInteractive = Boolean(interactive?.blocks.length);
const text = payload.text ?? "";
if (!text && mediaUrls.length === 0 && !hasChannelData) {
if (!text && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) {
continue;
}
normalizedPayloads.push({
text,
mediaUrls,
...(hasInteractive ? { interactive } : {}),
...(hasChannelData ? { channelData } : {}),
});
}
@ -113,6 +119,7 @@ export function normalizeOutboundPayloadsForJson(
text: payload.text ?? "",
mediaUrl: payload.mediaUrl ?? null,
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined),
interactive: payload.interactive,
channelData: payload.channelData,
});
}

153
src/interactive/payload.ts Normal file
View File

@ -0,0 +1,153 @@
export type InteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
export type InteractiveReplyButton = {
label: string;
value: string;
style?: InteractiveButtonStyle;
};
export type InteractiveReplyOption = {
label: string;
value: string;
};
export type InteractiveReplyTextBlock = {
type: "text";
text: string;
};
export type InteractiveReplyButtonsBlock = {
type: "buttons";
buttons: InteractiveReplyButton[];
};
export type InteractiveReplySelectBlock = {
type: "select";
placeholder?: string;
options: InteractiveReplyOption[];
};
export type InteractiveReplyBlock =
| InteractiveReplyTextBlock
| InteractiveReplyButtonsBlock
| InteractiveReplySelectBlock;
export type InteractiveReply = {
blocks: InteractiveReplyBlock[];
};
function readTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeButtonStyle(value: unknown): InteractiveButtonStyle | undefined {
const style = readTrimmedString(value)?.toLowerCase();
return style === "primary" || style === "secondary" || style === "success" || style === "danger"
? style
: undefined;
}
function normalizeInteractiveButton(raw: unknown): InteractiveReplyButton | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const label = readTrimmedString(record.label) ?? readTrimmedString(record.text);
const value =
readTrimmedString(record.value) ??
readTrimmedString(record.callbackData) ??
readTrimmedString(record.callback_data);
if (!label || !value) {
return undefined;
}
return {
label,
value,
style: normalizeButtonStyle(record.style),
};
}
function normalizeInteractiveOption(raw: unknown): InteractiveReplyOption | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const label = readTrimmedString(record.label) ?? readTrimmedString(record.text);
const value = readTrimmedString(record.value);
if (!label || !value) {
return undefined;
}
return { label, value };
}
function normalizeInteractiveBlock(raw: unknown): InteractiveReplyBlock | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const type = readTrimmedString(record.type)?.toLowerCase();
if (type === "text") {
const text = readTrimmedString(record.text);
return text ? { type: "text", text } : undefined;
}
if (type === "buttons") {
const buttons = Array.isArray(record.buttons)
? record.buttons
.map((entry) => normalizeInteractiveButton(entry))
.filter((entry): entry is InteractiveReplyButton => Boolean(entry))
: [];
return buttons.length > 0 ? { type: "buttons", buttons } : undefined;
}
if (type === "select") {
const options = Array.isArray(record.options)
? record.options
.map((entry) => normalizeInteractiveOption(entry))
.filter((entry): entry is InteractiveReplyOption => Boolean(entry))
: [];
return options.length > 0
? {
type: "select",
placeholder: readTrimmedString(record.placeholder),
options,
}
: undefined;
}
return undefined;
}
export function normalizeInteractiveReply(raw: unknown): InteractiveReply | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
const record = raw as Record<string, unknown>;
const blocks = Array.isArray(record.blocks)
? record.blocks
.map((entry) => normalizeInteractiveBlock(entry))
.filter((entry): entry is InteractiveReplyBlock => Boolean(entry))
: [];
return blocks.length > 0 ? { blocks } : undefined;
}
export function hasInteractiveReplyBlocks(value: unknown): value is InteractiveReply {
return Boolean(normalizeInteractiveReply(value));
}
export function resolveInteractiveTextFallback(params: {
text?: string;
interactive?: InteractiveReply;
}): string | undefined {
const text = readTrimmedString(params.text);
if (text) {
return params.text;
}
const interactiveText = (params.interactive?.blocks ?? [])
.filter((block): block is InteractiveReplyTextBlock => block.type === "text")
.map((block) => block.text.trim())
.filter(Boolean)
.join("\n\n");
return interactiveText || params.text;
}

View File

@ -105,6 +105,7 @@ export type {
PluginHookInboundClaimResult,
PluginInteractiveDiscordHandlerContext,
PluginInteractiveHandlerRegistration,
PluginInteractiveSlackHandlerContext,
PluginInteractiveTelegramHandlerContext,
PluginLogger,
ProviderAuthContext,

View File

@ -1,7 +1,9 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { parseSlackBlocksInput } from "../../extensions/slack/src/blocks-input.js";
import { buildSlackInteractiveBlocks } from "../../extensions/slack/src/blocks-render.js";
import { readNumberParam, readStringParam } from "../agents/tools/common.js";
import type { ChannelMessageActionContext } from "../channels/plugins/types.js";
import { normalizeInteractiveReply } from "../interactive/payload.js";
type SlackActionInvoke = (
action: Record<string, unknown>,
@ -37,7 +39,9 @@ export async function handleSlackMessageAction(params: {
allowEmpty: true,
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const blocks = readSlackBlocksParam(actionParams);
const interactive = normalizeInteractiveReply(actionParams.interactive);
const interactiveBlocks = interactive ? buildSlackInteractiveBlocks(interactive) : undefined;
const blocks = readSlackBlocksParam(actionParams) ?? interactiveBlocks;
if (!content && !mediaUrl && !blocks) {
throw new Error("Slack send requires message, blocks, or media.");
}
@ -52,9 +56,9 @@ export async function handleSlackMessageAction(params: {
to,
content: content ?? "",
mediaUrl: mediaUrl ?? undefined,
blocks,
accountId,
threadTs: threadId ?? replyTo ?? undefined,
...(blocks ? { blocks } : {}),
},
cfg,
ctx.toolContext,

View File

@ -1,8 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { Button, Row, type TopLevelComponents } from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
import type { ReplyPayload } from "../auto-reply/types.js";
import { expandHomePrefix } from "../infra/home-dir.js";
import { writeJsonAtomic } from "../infra/json-files.js";
@ -119,24 +117,6 @@ function getPluginBindingGlobalState(): PluginBindingGlobalState {
});
}
class PluginBindingApprovalButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: {
approvalId: string;
decision: PluginBindingApprovalDecision;
label: string;
style: ButtonStyle;
}) {
super();
this.customId = buildPluginBindingApprovalCustomId(params.approvalId, params.decision);
this.label = params.label;
this.style = params.style;
}
}
function resolveApprovalsPath(): string {
return expandHomePrefix(APPROVALS_PATH);
}
@ -236,54 +216,33 @@ function isLegacyPluginBindingRecord(params: {
);
}
function buildDiscordButtonRow(
function buildApprovalInteractiveReply(
approvalId: string,
labels?: { once?: string; always?: string; deny?: string },
): TopLevelComponents[] {
return [
new Row([
new PluginBindingApprovalButton({
approvalId,
decision: "allow-once",
label: labels?.once ?? "Allow once",
style: ButtonStyle.Success,
}),
new PluginBindingApprovalButton({
approvalId,
decision: "allow-always",
label: labels?.always ?? "Always allow",
style: ButtonStyle.Primary,
}),
new PluginBindingApprovalButton({
approvalId,
decision: "deny",
label: labels?.deny ?? "Deny",
style: ButtonStyle.Danger,
}),
]),
];
}
function buildTelegramButtons(approvalId: string) {
return [
[
): NonNullable<ReplyPayload["interactive"]> {
return {
blocks: [
{
text: "Allow once",
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-once"),
style: "success" as const,
},
{
text: "Always allow",
callback_data: buildPluginBindingApprovalCustomId(approvalId, "allow-always"),
style: "primary" as const,
},
{
text: "Deny",
callback_data: buildPluginBindingApprovalCustomId(approvalId, "deny"),
style: "danger" as const,
type: "buttons",
buttons: [
{
label: "Allow once",
value: buildPluginBindingApprovalCustomId(approvalId, "allow-once"),
style: "success",
},
{
label: "Always allow",
value: buildPluginBindingApprovalCustomId(approvalId, "allow-always"),
style: "primary",
},
{
label: "Deny",
value: buildPluginBindingApprovalCustomId(approvalId, "deny"),
style: "danger",
},
],
},
],
];
};
}
function createApprovalRequestId(): string {
@ -547,14 +506,7 @@ export function markPluginBindingFallbackNoticeShown(bindingId: string): void {
function buildPendingReply(request: PendingPluginBindingRequest): ReplyPayload {
return {
text: buildApprovalMessage(request),
channelData: {
telegram: {
buttons: buildTelegramButtons(request.id),
},
discord: {
components: buildDiscordButtonRow(request.id),
},
},
interactive: buildApprovalInteractiveReply(request.id),
};
}

View File

@ -147,6 +147,72 @@ describe("plugin interactive handlers", () => {
);
});
it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
const handler = vi.fn(async () => ({ handled: true }));
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "slack",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
const baseParams = {
channel: "slack" as const,
data: "codex:approve:thread-1",
interactionId: "slack-ix-1",
ctx: {
channel: "slack" as const,
accountId: "default",
interactionId: "slack-ix-1",
conversationId: "C123",
parentConversationId: "C123",
threadId: "1710000000.000100",
senderId: "U123",
senderUsername: "ada",
auth: { isAuthorizedSender: true },
interaction: {
kind: "button" as const,
actionId: "codex",
blockId: "codex_actions",
messageTs: "1710000000.000200",
threadTs: "1710000000.000100",
value: "approve:thread-1",
selectedValues: ["approve:thread-1"],
selectedLabels: ["Approve"],
triggerId: "trigger-1",
responseUrl: "https://hooks.slack.test/response",
},
},
respond: {
acknowledge: vi.fn(async () => {}),
reply: vi.fn(async () => {}),
followUp: vi.fn(async () => {}),
editMessage: vi.fn(async () => {}),
},
};
const first = await dispatchPluginInteractiveHandler(baseParams);
const duplicate = await dispatchPluginInteractiveHandler(baseParams);
expect(first).toEqual({ matched: true, handled: true, duplicate: false });
expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
channel: "slack",
conversationId: "C123",
threadId: "1710000000.000100",
interaction: expect.objectContaining({
namespace: "codex",
payload: "approve:thread-1",
actionId: "codex",
messageTs: "1710000000.000200",
}),
}),
);
});
it("does not consume dedupe keys when a handler throws", async () => {
const handler = vi
.fn(async () => ({ handled: true }))

View File

@ -9,6 +9,8 @@ import type {
PluginInteractiveButtons,
PluginInteractiveDiscordHandlerRegistration,
PluginInteractiveHandlerRegistration,
PluginInteractiveSlackHandlerContext,
PluginInteractiveSlackHandlerRegistration,
PluginInteractiveTelegramHandlerRegistration,
PluginInteractiveTelegramHandlerContext,
} from "./types.js";
@ -59,6 +61,21 @@ type DiscordInteractiveDispatchContext = Omit<
>;
};
type SlackInteractiveDispatchContext = Omit<
PluginInteractiveSlackHandlerContext,
| "interaction"
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding"
> & {
interaction: Omit<
PluginInteractiveSlackHandlerContext["interaction"],
"data" | "namespace" | "payload"
>;
};
const interactiveHandlers = new Map<string, RegisteredInteractiveHandler>();
const callbackDedupe = createDedupeCache({
ttlMs: 5 * 60_000,
@ -134,6 +151,15 @@ export function registerPluginInteractiveHandler(
pluginName: opts?.pluginName,
pluginRoot: opts?.pluginRoot,
});
} else if (registration.channel === "slack") {
interactiveHandlers.set(key, {
...registration,
namespace,
channel: "slack",
pluginId,
pluginName: opts?.pluginName,
pluginRoot: opts?.pluginRoot,
});
} else {
interactiveHandlers.set(key, {
...registration,
@ -181,11 +207,21 @@ export async function dispatchPluginInteractiveHandler(params: {
respond: PluginInteractiveDiscordHandlerContext["respond"];
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "telegram" | "discord";
channel: "slack";
data: string;
interactionId: string;
ctx: SlackInteractiveDispatchContext;
respond: PluginInteractiveSlackHandlerContext["respond"];
}): Promise<InteractiveDispatchResult>;
export async function dispatchPluginInteractiveHandler(params: {
channel: "telegram" | "discord" | "slack";
data: string;
callbackId?: string;
interactionId?: string;
ctx: TelegramInteractiveDispatchContext | DiscordInteractiveDispatchContext;
ctx:
| TelegramInteractiveDispatchContext
| DiscordInteractiveDispatchContext
| SlackInteractiveDispatchContext;
respond:
| {
reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise<void>;
@ -197,7 +233,8 @@ export async function dispatchPluginInteractiveHandler(params: {
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
}
| PluginInteractiveDiscordHandlerContext["respond"];
| PluginInteractiveDiscordHandlerContext["respond"]
| PluginInteractiveSlackHandlerContext["respond"];
}): Promise<InteractiveDispatchResult> {
const match = resolveNamespaceMatch(params.channel, params.data);
if (!match) {
@ -212,7 +249,8 @@ export async function dispatchPluginInteractiveHandler(params: {
let result:
| ReturnType<PluginInteractiveTelegramHandlerRegistration["handler"]>
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>;
| ReturnType<PluginInteractiveDiscordHandlerRegistration["handler"]>
| ReturnType<PluginInteractiveSlackHandlerRegistration["handler"]>;
if (params.channel === "telegram") {
const pluginRoot = match.registration.pluginRoot;
const { callbackMessage, ...handlerContext } = params.ctx as TelegramInteractiveDispatchContext;
@ -284,7 +322,7 @@ export async function dispatchPluginInteractiveHandler(params: {
});
},
});
} else {
} else if (params.channel === "discord") {
const pluginRoot = match.registration.pluginRoot;
result = (
match.registration as RegisteredInteractiveHandler &
@ -352,6 +390,74 @@ export async function dispatchPluginInteractiveHandler(params: {
});
},
});
} else {
const pluginRoot = match.registration.pluginRoot;
const handlerContext = params.ctx as SlackInteractiveDispatchContext;
result = (
match.registration as RegisteredInteractiveHandler & PluginInteractiveSlackHandlerRegistration
).handler({
...handlerContext,
channel: "slack",
interaction: {
...handlerContext.interaction,
data: params.data,
namespace: match.namespace,
payload: match.payload,
},
respond: params.respond as PluginInteractiveSlackHandlerContext["respond"],
requestConversationBinding: async (bindingParams) => {
if (!pluginRoot) {
return {
status: "error",
message: "This interaction cannot bind the current conversation.",
};
}
return requestPluginConversationBinding({
pluginId: match.registration.pluginId,
pluginName: match.registration.pluginName,
pluginRoot,
requestedBySenderId: handlerContext.senderId,
conversation: {
channel: "slack",
accountId: handlerContext.accountId,
conversationId: handlerContext.conversationId,
parentConversationId: handlerContext.parentConversationId,
threadId: handlerContext.threadId,
},
binding: bindingParams,
});
},
detachConversationBinding: async () => {
if (!pluginRoot) {
return { removed: false };
}
return detachPluginConversationBinding({
pluginRoot,
conversation: {
channel: "slack",
accountId: handlerContext.accountId,
conversationId: handlerContext.conversationId,
parentConversationId: handlerContext.parentConversationId,
threadId: handlerContext.threadId,
},
});
},
getCurrentConversationBinding: async () => {
if (!pluginRoot) {
return null;
}
return getCurrentPluginConversationBinding({
pluginRoot,
conversation: {
channel: "slack",
accountId: handlerContext.accountId,
conversationId: handlerContext.conversationId,
parentConversationId: handlerContext.parentConversationId,
threadId: handlerContext.threadId,
},
});
},
});
}
const resolved = await result;
if (dedupeKey) {

View File

@ -796,7 +796,7 @@ export type OpenClawPluginCommandDefinition = {
handler: PluginCommandHandler;
};
export type PluginInteractiveChannel = "telegram" | "discord";
export type PluginInteractiveChannel = "telegram" | "discord" | "slack";
export type PluginInteractiveButtons = Array<
Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
@ -881,6 +881,53 @@ export type PluginInteractiveDiscordHandlerContext = {
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type PluginInteractiveSlackHandlerResult = {
handled?: boolean;
} | void;
export type PluginInteractiveSlackHandlerContext = {
channel: "slack";
accountId: string;
interactionId: string;
conversationId: string;
parentConversationId?: string;
senderId?: string;
senderUsername?: string;
threadId?: string;
auth: {
isAuthorizedSender: boolean;
};
interaction: {
kind: "button" | "select";
data: string;
namespace: string;
payload: string;
actionId: string;
blockId?: string;
messageTs?: string;
threadTs?: string;
value?: string;
selectedValues?: string[];
selectedLabels?: string[];
triggerId?: string;
responseUrl?: string;
};
respond: {
acknowledge: () => Promise<void>;
reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise<void>;
followUp: (params: {
text: string;
responseType?: "ephemeral" | "in_channel";
}) => Promise<void>;
editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise<void>;
};
requestConversationBinding: (
params?: PluginConversationBindingRequestParams,
) => Promise<PluginConversationBindingRequestResult>;
detachConversationBinding: () => Promise<{ removed: boolean }>;
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type PluginInteractiveTelegramHandlerRegistration = {
channel: "telegram";
namespace: string;
@ -897,9 +944,18 @@ export type PluginInteractiveDiscordHandlerRegistration = {
) => Promise<PluginInteractiveDiscordHandlerResult> | PluginInteractiveDiscordHandlerResult;
};
export type PluginInteractiveSlackHandlerRegistration = {
channel: "slack";
namespace: string;
handler: (
ctx: PluginInteractiveSlackHandlerContext,
) => Promise<PluginInteractiveSlackHandlerResult> | PluginInteractiveSlackHandlerResult;
};
export type PluginInteractiveHandlerRegistration =
| PluginInteractiveTelegramHandlerRegistration
| PluginInteractiveDiscordHandlerRegistration;
| PluginInteractiveDiscordHandlerRegistration
| PluginInteractiveSlackHandlerRegistration;
export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
export type OpenClawPluginHttpRouteMatch = "exact" | "prefix";