feat(plugin-sdk): add typed presentation command actions (#88721)

* feat(plugin-sdk): add typed presentation command actions

* test: use shared env helper in telegram bot tests

* test: expect typed approval actions

* test: expect typed sdk approval actions
This commit is contained in:
Peter Steinberger
2026-05-31 18:48:45 +01:00
committed by GitHub
parent 4b1d2faa99
commit d641126c1d
44 changed files with 1398 additions and 188 deletions

View File

@@ -1,2 +1,2 @@
f1da4b7930475e4be33cb05b8c239f728c7338eb1e8df9b7905bbae94d62da9e plugin-sdk-api-baseline.json
6fd007eede80893680d65c6f245eafb9e6301a1e4306530b0134fd5b3da0cddb plugin-sdk-api-baseline.jsonl
eadfc9b897a05664735f8e2abcb70cb3f33c19427c20802bf8b035520b7a2ea1 plugin-sdk-api-baseline.json
8e10e093068d73b9ac50d3f265bf7d892652b0392c677be4e332248499cf7ed0 plugin-sdk-api-baseline.jsonl

View File

@@ -52,8 +52,14 @@ type MessagePresentationBlock =
| { type: "buttons"; buttons: MessagePresentationButton[] }
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] };
type MessagePresentationAction =
| { type: "command"; command: string }
| { type: "callback"; value: string };
type MessagePresentationButton = {
label: string;
action?: MessagePresentationAction;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
url?: string;
webApp?: { url: string };
@@ -67,7 +73,9 @@ type MessagePresentationButton = {
type MessagePresentationOption = {
label: string;
value: string;
action?: MessagePresentationAction;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
};
type ReplyPayloadDelivery = {
@@ -83,8 +91,13 @@ type ReplyPayloadDelivery = {
Button semantics:
- `value` is an application action value routed back through the channel's
existing interaction path when the channel supports clickable controls.
- `action.type: "command"` runs a native slash command through core's command
path. Use this for built-in command buttons and menus.
- `action.type: "callback"` carries opaque plugin data through the channel's
interaction path. Channel plugins must not reinterpret callback data as slash
commands.
- `value` is the legacy opaque callback value. New controls should use `action`
so channel plugins can map commands and callbacks without guessing from text.
- `url` is a link button. It can exist without `value`.
- `webApp` describes a channel-native web app button. Telegram renders this
as `web_app` and only supports it in private chats. `web_app` is still
@@ -106,7 +119,8 @@ Button semantics:
Select semantics:
- `options[].value` is the selected application value.
- `options[].action` has the same command/callback meaning as button `action`.
- `options[].value` is the legacy selected application value.
- `placeholder` is advisory and may be ignored by channels without native
select support.
- If a channel does not support selects, fallback text lists the labels.

View File

@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import { resolveAgentDir, resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime";
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -40,6 +39,10 @@ import {
handleCodexPluginsSubcommand,
type CodexPluginsManagementIO,
} from "./command-plugins-management.js";
import {
buildCodexCommandPickerPresentation,
type CodexCommandPickerButton,
} from "./command-presentation.js";
import {
codexControlRequest,
readCodexStatusProbes,
@@ -241,30 +244,12 @@ export function resetCodexDiagnosticsFeedbackStateForTests(): void {
pendingCodexDiagnosticsConfirmationTokensByScope.clear();
}
type CodexPickerButton = { label: string; command: string };
function buildPickerPresentation(title: string, prompt: string, buttons: CodexPickerButton[]) {
return {
title,
blocks: [
{ type: "text", text: prompt },
{
type: "buttons",
buttons: buttons.map((button) => ({
label: button.label,
value: button.command,
})),
},
],
} satisfies MessagePresentation;
}
/**
* No-arg `/codex` picker. Core owns the native command tree; channels render
* the portable buttons as inline controls when their transport can.
* No-arg `/codex` picker. Codex owns the command tree; channels render the
* portable command actions as inline controls when their transport can.
*/
function buildCodexSubcommandPickerReply(): PluginCommandResult {
const verbs: CodexPickerButton[] = [
const verbs: CodexCommandPickerButton[] = [
{ label: "plugins", command: "/codex plugins menu" },
{ label: "permissions", command: "/codex permissions menu" },
{ label: "fast", command: "/codex fast menu" },
@@ -287,14 +272,18 @@ function buildCodexSubcommandPickerReply(): PluginCommandResult {
return {
text: fallbackTextLines.join("\n"),
presentation: buildPickerPresentation("Codex commands", "Pick a Codex subcommand:", verbs),
presentation: buildCodexCommandPickerPresentation(
"Codex commands",
"Pick a Codex subcommand:",
verbs,
),
};
}
/** Sub-picker for `/codex fast menu` (on / off / status). */
function buildCodexFastMenuReply(): PluginCommandResult {
const modes = ["on", "off", "status"] as const;
const buttons: CodexPickerButton[] = [
const buttons: CodexCommandPickerButton[] = [
...modes.map((mode) => ({ label: mode, command: `/codex fast ${mode}` })),
{ label: "back", command: "/codex" },
];
@@ -307,14 +296,18 @@ function buildCodexFastMenuReply(): PluginCommandResult {
];
return {
text: fallbackTextLines.join("\n"),
presentation: buildPickerPresentation("Codex fast mode", "Pick a Codex fast mode:", buttons),
presentation: buildCodexCommandPickerPresentation(
"Codex fast mode",
"Pick a Codex fast mode:",
buttons,
),
};
}
/** Sub-picker for `/codex permissions menu` (default / yolo / status). */
function buildCodexPermissionsMenuReply(): PluginCommandResult {
const modes = ["default", "yolo", "status"] as const;
const buttons: CodexPickerButton[] = [
const buttons: CodexCommandPickerButton[] = [
...modes.map((mode) => ({ label: mode, command: `/codex permissions ${mode}` })),
{ label: "back", command: "/codex" },
];
@@ -327,7 +320,7 @@ function buildCodexPermissionsMenuReply(): PluginCommandResult {
];
return {
text: fallbackTextLines.join("\n"),
presentation: buildPickerPresentation(
presentation: buildCodexCommandPickerPresentation(
"Codex permissions",
"Pick a Codex permissions mode:",
buttons,
@@ -338,7 +331,7 @@ function buildCodexPermissionsMenuReply(): PluginCommandResult {
/** Sub-picker for `/codex computer-use menu` (status / install). */
function buildCodexComputerUseMenuReply(): PluginCommandResult {
const actions = ["status", "install"] as const;
const buttons: CodexPickerButton[] = [
const buttons: CodexCommandPickerButton[] = [
...actions.map((action) => ({
label: action,
command: `/codex computer-use ${action}`,
@@ -356,7 +349,7 @@ function buildCodexComputerUseMenuReply(): PluginCommandResult {
];
return {
text: fallbackTextLines.join("\n"),
presentation: buildPickerPresentation(
presentation: buildCodexCommandPickerPresentation(
"Codex computer-use",
"Pick a Codex computer-use action:",
buttons,
@@ -1149,8 +1142,18 @@ async function requestCodexDiagnosticsFeedbackApproval(
{
type: "buttons",
buttons: [
{ label: "Send diagnostics", value: confirmCommand, style: "danger" },
{ label: "Cancel", value: cancelCommand, style: "secondary" },
{
label: "Send diagnostics",
action: { type: "command", command: confirmCommand },
value: confirmCommand,
style: "danger",
},
{
label: "Cancel",
action: { type: "command", command: cancelCommand },
value: cancelCommand,
style: "secondary",
},
],
},
],

View File

@@ -40,12 +40,14 @@ const fakeCtx: PluginCommandContext = {
getCurrentConversationBinding: async () => null,
};
function buttonValues(result: PluginCommandResult): string[] {
function buttonCommands(result: PluginCommandResult): string[] {
const block = result.presentation?.blocks.find((candidate) => candidate.type === "buttons");
if (!block || block.type !== "buttons") {
throw new Error("expected button presentation");
}
return block.buttons.map((button) => button.value ?? "");
return block.buttons.map((button) =>
button.action?.type === "command" ? button.action.command : "",
);
}
describe("Codex /codex plugins subcommand", () => {
@@ -86,7 +88,7 @@ describe("Codex /codex plugins subcommand", () => {
const result = await handleCodexPluginsSubcommand(fakeCtx, ["menu"], io);
expect(result.text).toContain("/codex plugins list");
expect(buttonValues(result)).toEqual([
expect(buttonCommands(result)).toEqual([
"/codex plugins list",
"/codex plugins enable",
"/codex plugins disable",
@@ -111,14 +113,14 @@ describe("Codex /codex plugins subcommand", () => {
const enableResult = await handleCodexPluginsSubcommand(fakeCtx, ["enable"], io);
expect(enableResult.text).toContain("/codex plugins enable google-calendar");
expect(buttonValues(enableResult)).toEqual([
expect(buttonCommands(enableResult)).toEqual([
"/codex plugins enable google-calendar",
"/codex plugins menu",
]);
const disableResult = await handleCodexPluginsSubcommand(fakeCtx, ["disable"], io);
expect(disableResult.text).toContain("/codex plugins disable notion");
expect(buttonValues(disableResult)).toEqual([
expect(buttonCommands(disableResult)).toEqual([
"/codex plugins disable notion",
"/codex plugins menu",
]);

View File

@@ -1,6 +1,9 @@
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
import { formatCodexDisplayText } from "./command-formatters.js";
import {
buildCodexCommandPickerPresentation,
type CodexCommandPickerButton,
} from "./command-presentation.js";
/**
* Lightweight read/write surface over the Openclaw config file. Plugged in by
@@ -34,24 +37,6 @@ export type CodexPluginsConfigBlock = {
const POLICY_REFRESH_HINT =
"New Codex conversations pick this up automatically. Use /new or /reset to refresh the current one.";
type CodexPickerButton = { label: string; command: string };
function buildPickerPresentation(title: string, prompt: string, buttons: CodexPickerButton[]) {
return {
title,
blocks: [
{ type: "text", text: prompt },
{
type: "buttons",
buttons: buttons.map((button) => ({
label: button.label,
value: button.command,
})),
},
],
} satisfies MessagePresentation;
}
export async function handleCodexPluginsSubcommand(
ctx: PluginCommandContext,
rest: string[],
@@ -123,7 +108,7 @@ export async function handleCodexPluginsSubcommand(
}
function buildPluginsMenuReply(): PluginCommandResult {
const buttons: CodexPickerButton[] = [
const buttons: CodexCommandPickerButton[] = [
{ label: "list", command: "/codex plugins list" },
{ label: "enable", command: "/codex plugins enable" },
{ label: "disable", command: "/codex plugins disable" },
@@ -142,7 +127,7 @@ function buildPluginsMenuReply(): PluginCommandResult {
].join("\n");
return {
text,
presentation: buildPickerPresentation(
presentation: buildCodexCommandPickerPresentation(
"Codex sub-plugins",
"Pick a Codex sub-plugin action:",
buttons,
@@ -172,7 +157,7 @@ function buildPluginNamePickerReply(
"Type '/codex plugins list' to inspect configured sub-plugins.",
"Type '/codex plugins menu' to go back to the plugins menu.",
].join("\n"),
presentation: buildPickerPresentation(
presentation: buildCodexCommandPickerPresentation(
"Codex sub-plugins",
"Pick another Codex sub-plugin action:",
[
@@ -183,7 +168,7 @@ function buildPluginNamePickerReply(
};
}
const buttons: CodexPickerButton[] = [
const buttons: CodexCommandPickerButton[] = [
...eligible.map(([key]) => ({
label: formatCodexDisplayText(key),
command: `/codex plugins ${verb} ${key}`,
@@ -196,17 +181,14 @@ function buildPluginNamePickerReply(
...eligible.map(([key], index) => ` ${index + 1}. /codex plugins ${verb} ${key}`),
"",
...(verb === "enable" && !globalEnabled
? [
"Global codexPlugins.enabled is off; enabling one configured sub-plugin turns it on.",
"",
]
? ["Global codexPlugins.enabled is off; enabling one configured sub-plugin turns it on.", ""]
: []),
"Type '/codex plugins menu' to go back to the plugins menu.",
].join("\n");
return {
text,
presentation: buildPickerPresentation(
presentation: buildCodexCommandPickerPresentation(
"Codex sub-plugins",
`Pick a Codex sub-plugin to ${verb}:`,
buttons,

View File

@@ -0,0 +1,23 @@
import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
export type CodexCommandPickerButton = { label: string; command: string };
export function buildCodexCommandPickerPresentation(
title: string,
prompt: string,
buttons: CodexCommandPickerButton[],
): MessagePresentation {
return {
title,
blocks: [
{ type: "text", text: prompt },
{
type: "buttons",
buttons: buttons.map((button) => ({
label: button.label,
action: { type: "command", command: button.command },
})),
},
],
};
}

View File

@@ -151,12 +151,14 @@ function expectResultTextContains(result: PluginCommandResult, expected: string)
expect(requireResultText(result)).toContain(expected);
}
function buttonValues(result: PluginCommandResult): string[] {
function buttonCommands(result: PluginCommandResult): string[] {
const block = result.presentation?.blocks.find((candidate) => candidate.type === "buttons");
if (!block || block.type !== "buttons") {
throw new Error("expected button presentation");
}
return block.buttons.map((button) => button.value ?? "");
return block.buttons.map((button) =>
button.action?.type === "command" ? button.action.command : "",
);
}
function installAuthProfileStore(store: AuthProfileStore, config: PluginCommandContext["config"]) {
@@ -279,7 +281,7 @@ describe("codex command", () => {
const result = await handleCodexCommand(createContext(""));
expectResultTextContains(result, "/codex plugins menu");
expect(buttonValues(result)).toEqual([
expect(buttonCommands(result)).toEqual([
"/codex plugins menu",
"/codex permissions menu",
"/codex fast menu",
@@ -297,7 +299,7 @@ describe("codex command", () => {
});
expectResultTextContains(result, "/codex plugins enable");
expect(buttonValues(result)).toContain("/codex plugins list");
expect(buttonCommands(result)).toContain("/codex plugins list");
});
it("lists Codex sub-plugins through the /codex plugins command surface", async () => {
@@ -2182,11 +2184,13 @@ describe("codex command", () => {
buttons: [
{
label: "Send diagnostics",
action: { type: "command", command: `/codex diagnostics confirm ${token}` },
value: `/codex diagnostics confirm ${token}`,
style: "danger",
},
{
label: "Cancel",
action: { type: "command", command: `/codex diagnostics cancel ${token}` },
value: `/codex diagnostics cancel ${token}`,
style: "secondary",
},

View File

@@ -94,6 +94,9 @@ function createButtonComponent(params: {
kind: params.modalId ? "modal-trigger" : "button",
label: params.spec.label,
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
...(params.spec.callbackDataKind !== undefined
? { callbackDataKind: params.spec.callbackDataKind }
: {}),
...(params.modalId !== undefined ? { modalId: params.modalId } : {}),
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
@@ -127,6 +130,9 @@ function createSelectComponent(params: {
kind: "select",
label,
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
...(params.spec.callbackDataKind !== undefined
? { callbackDataKind: params.spec.callbackDataKind }
: {}),
selectType,
...(options ? { options } : {}),
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),

View File

@@ -4,6 +4,7 @@ import type {
DiscordComponentBlock,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentCallbackDataKind,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,
DiscordComponentSectionAccessory,
@@ -49,6 +50,20 @@ function readOptionalString(value: unknown): string | undefined {
return trimmed ? trimmed : undefined;
}
function readOptionalCallbackDataKind(
value: unknown,
label: string,
): DiscordComponentCallbackDataKind | undefined {
const kind = readOptionalString(value);
if (kind === undefined) {
return undefined;
}
if (kind === "command" || kind === "callback") {
return kind;
}
throw new Error(`${label} must be one of command, callback`);
}
function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
if (value === undefined) {
return undefined;
@@ -187,6 +202,10 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
style,
url,
callbackData: readOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
emoji: readOptionalEmoji(obj.emoji, `${label}.emoji`),
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
@@ -209,6 +228,10 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
return {
type,
callbackData: readOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
placeholder: readOptionalString(obj.placeholder),
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }),
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }),

View File

@@ -23,6 +23,7 @@ export type {
DiscordComponentBuildResult,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentCallbackDataKind,
DiscordComponentEntry,
DiscordComponentMessageSpec,
DiscordComponentModalFieldType,

View File

@@ -3,6 +3,7 @@ import type { TopLevelComponents } from "./internal/discord.js";
export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";
export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel";
export type DiscordComponentCallbackDataKind = "command" | "callback";
export type DiscordComponentModalFieldType =
| "text"
@@ -17,6 +18,7 @@ export type DiscordComponentButtonSpec = {
style?: DiscordComponentButtonStyle;
url?: string;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
/** Internal use only: bypass dynamic component ids with a fixed custom id. */
internalCustomId?: string;
emoji?: {
@@ -46,6 +48,7 @@ export type DiscordComponentSelectOption = {
export type DiscordComponentSelectSpec = {
type?: DiscordComponentSelectType;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
placeholder?: string;
minValues?: number;
maxValues?: number;
@@ -136,6 +139,7 @@ export type DiscordComponentEntry = {
kind: "button" | "select" | "modal-trigger";
label: string;
callbackData?: string;
callbackDataKind?: DiscordComponentCallbackDataKind;
selectType?: DiscordComponentSelectType;
options?: Array<{ value: string; label: string }>;
modalId?: string;

View File

@@ -128,14 +128,21 @@ async function handleDiscordComponentEvent(params: {
}
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
if (consumed.callbackData) {
const selectedCallbackData =
consumed.kind === "select" &&
consumed.callbackDataKind === "callback" &&
params.values?.length === 1
? params.values[0]?.trim()
: undefined;
const pluginCallbackData = consumed.callbackData ?? selectedCallbackData;
if (pluginCallbackData) {
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
ctx: params.ctx,
interaction: params.interaction,
interactionCtx,
channelCtx,
isAuthorizedSender: commandAuthorized,
data: consumed.callbackData,
data: pluginCallbackData,
kind: consumed.kind === "select" ? "select" : "button",
values,
messageId: consumed.messageId ?? params.interaction.message?.id,
@@ -144,11 +151,21 @@ async function handleDiscordComponentEvent(params: {
return;
}
}
// Preserve explicit callback payloads for button fallbacks so Discord
// behaves like Telegram when buttons carry synthetic command text. Select
// fallbacks still need their chosen values in the synthesized event text.
// Command actions opt into synthetic command fallback. Opaque callback actions
// are plugin data only; falling through as slash commands would execute data.
const buttonCallbackFallback =
consumed.kind === "button" && consumed.callbackDataKind !== "callback"
? consumed.callbackData?.trim()
: undefined;
const selectedCommandFallback =
consumed.kind === "select" &&
consumed.callbackDataKind === "command" &&
params.values?.length === 1
? params.values[0]?.trim()
: undefined;
const eventText =
(consumed.kind === "button" ? consumed.callbackData?.trim() : undefined) ||
buttonCallbackFallback ||
selectedCommandFallback ||
(await loadComponentsRuntime()).formatDiscordComponentEventText({
kind: consumed.kind === "select" ? "select" : "button",
label: consumed.label,

View File

@@ -397,6 +397,27 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("does not execute opaque callback actions as built-in fallback commands", async () => {
registerDiscordComponentEntries({
entries: [
createButtonEntry({
callbackData: "/codex permissions yolo",
callbackDataKind: "callback",
}),
],
modals: [],
});
const button = createDiscordComponentButton(createComponentContext());
const { interaction, reply } = createComponentButtonInteraction();
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("preserves selected values for select fallback when no plugin handler matches", async () => {
registerDiscordComponentEntries({
entries: [
@@ -426,6 +447,74 @@ describe("discord component interactions", () => {
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("uses selected command action values for select fallback", async () => {
registerDiscordComponentEntries({
entries: [
{
id: "sel_1",
kind: "select",
label: "Pick",
messageId: "msg-1",
sessionKey: "session-1",
agentId: "agent-1",
accountId: "default",
callbackDataKind: "command",
selectType: "string",
options: [{ value: "/codex permissions yolo", label: "Yolo" }],
},
],
modals: [],
});
const select = createDiscordComponentStringSelect(createComponentContext());
const { interaction, reply } = createComponentSelectInteraction({
values: ["/codex permissions yolo"],
});
await select.run(interaction, { cid: "sel_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe("/codex permissions yolo");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
it("dispatches selected callback action values to plugin interactive handlers", async () => {
registerDiscordComponentEntries({
entries: [
{
id: "sel_1",
kind: "select",
label: "Pick",
messageId: "msg-1",
sessionKey: "session-1",
agentId: "agent-1",
accountId: "default",
callbackDataKind: "callback",
selectType: "string",
options: [{ value: "inspect:123", label: "Inspect" }],
},
],
modals: [],
});
const select = createDiscordComponentStringSelect(createComponentContext());
const { interaction, reply } = createComponentSelectInteraction({
values: ["inspect:123"],
});
await select.run(interaction, { cid: "sel_1" } as ComponentData);
const pluginDispatch = mockCallArg(
dispatchPluginInteractiveHandlerMock,
-1,
"dispatchPluginInteractiveHandler",
) as { data?: unknown; ctx?: { interaction?: { values?: unknown } } };
expect(pluginDispatch.data).toBe("inspect:123");
expect(pluginDispatch.ctx?.interaction?.values).toEqual(["Inspect"]);
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Selected Inspect from "Pick".');
});
it("keeps reusable buttons active after use", async () => {
registerDiscordComponentEntries({
entries: [createButtonEntry({ reusable: true })],

View File

@@ -134,7 +134,7 @@ describe("buildDiscordInteractiveComponents", () => {
{ type: "context", text: "main branch" },
{
type: "buttons",
buttons: [{ label: "Open", value: "open" }],
buttons: [{ label: "Open", action: { type: "command", command: "/codex open" } }],
},
],
}),
@@ -145,7 +145,46 @@ describe("buildDiscordInteractiveComponents", () => {
{ type: "text", text: "-# main branch" },
{
type: "actions",
buttons: [{ label: "Open", style: "secondary", callbackData: "open" }],
buttons: [
{
label: "Open",
style: "secondary",
callbackData: "/codex open",
callbackDataKind: "command",
},
],
},
],
});
});
it("marks typed callback actions as opaque callback data", () => {
expect(
buildDiscordPresentationComponents({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Opaque",
action: { type: "callback", value: "/codex permissions yolo" },
},
],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
buttons: [
{
label: "Opaque",
style: "secondary",
callbackData: "/codex permissions yolo",
callbackDataKind: "callback",
},
],
},
],
});
@@ -208,4 +247,93 @@ describe("buildDiscordInteractiveComponents", () => {
],
});
});
it("preserves typed command actions for command-only select options", () => {
expect(
buildDiscordPresentationComponents({
blocks: [
{
type: "select",
placeholder: "Pick",
options: [
{
label: "Run",
action: { type: "command", command: "/codex permissions yolo" },
value: "/codex permissions yolo",
},
],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
select: {
type: "string",
placeholder: "Pick",
callbackDataKind: "command",
options: [{ label: "Run", value: "/codex permissions yolo" }],
},
},
],
});
});
it("marks typed callback actions for callback-only select options", () => {
expect(
buildDiscordPresentationComponents({
blocks: [
{
type: "select",
placeholder: "Pick",
options: [
{
label: "Inspect",
action: { type: "callback", value: "inspect:123" },
value: "inspect:123",
},
],
},
],
}),
).toEqual({
blocks: [
{
type: "actions",
select: {
type: "string",
placeholder: "Pick",
callbackDataKind: "callback",
options: [{ label: "Inspect", value: "inspect:123" }],
},
},
],
});
});
it("does not render mixed command and callback select actions", () => {
expect(
buildDiscordPresentationComponents({
blocks: [
{
type: "select",
placeholder: "Pick",
options: [
{
label: "Run",
action: { type: "command", command: "/codex run" },
value: "/codex run",
},
{
label: "Inspect",
action: { type: "callback", value: "inspect:123" },
value: "inspect:123",
},
],
},
],
}),
).toBeUndefined();
});
});

View File

@@ -1,9 +1,13 @@
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import {
reduceInteractiveReply,
resolveMessagePresentationControlValue,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveButtonStyle,
InteractiveReply,
MessagePresentation,
MessagePresentationButton,
MessagePresentationOption,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
DiscordComponentButtonSpec,
@@ -17,6 +21,46 @@ function resolveDiscordInteractiveButtonStyle(
return style ?? "secondary";
}
function applyDiscordButtonCallback(
spec: DiscordComponentButtonSpec,
button: MessagePresentationButton,
): void {
const callbackData = resolveMessagePresentationControlValue(button);
if (!callbackData) {
return;
}
spec.callbackData = callbackData;
if (button.action?.type === "command" || button.action?.type === "callback") {
spec.callbackDataKind = button.action.type;
}
}
function resolveDiscordSelectOptionValue(option: MessagePresentationOption): string | undefined {
return resolveMessagePresentationControlValue(option);
}
function resolveDiscordSelectCallbackDataKind(
options: MessagePresentationOption[],
): "command" | "callback" | "mixed" | undefined {
const renderableOptions = options.filter((option) => resolveDiscordSelectOptionValue(option));
if (
renderableOptions.length > 0 &&
renderableOptions.every((option) => option.action?.type === "command")
) {
return "command";
}
if (
renderableOptions.length > 0 &&
renderableOptions.every((option) => option.action?.type === "callback")
) {
return "callback";
}
if (renderableOptions.some((option) => option.action)) {
return "mixed";
}
return undefined;
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
/**
@@ -54,9 +98,7 @@ export function buildDiscordInteractiveComponents(
label: button.label,
style: button.url ? "link" : resolveDiscordInteractiveButtonStyle(button.style),
};
if (button.value) {
spec.callbackData = button.value;
}
applyDiscordButtonCallback(spec, button);
if (button.url) {
spec.url = button.url;
}
@@ -73,15 +115,26 @@ export function buildDiscordInteractiveComponents(
return state;
}
if (block.type === "select" && block.options.length > 0) {
const options = block.options
.map((option) => ({
label: option.label,
value: resolveDiscordSelectOptionValue(option),
}))
.filter((option): option is { label: string; value: string } => Boolean(option.value));
if (options.length === 0) {
return state;
}
const callbackDataKind = resolveDiscordSelectCallbackDataKind(block.options);
if (callbackDataKind === "mixed") {
return state;
}
state.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
options,
callbackDataKind,
},
});
}
@@ -123,15 +176,26 @@ export function buildDiscordPresentationComponents(
continue;
}
if (block.type === "select" && block.options.length > 0) {
const options = block.options
.map((option) => ({
label: option.label,
value: resolveDiscordSelectOptionValue(option),
}))
.filter((option): option is { label: string; value: string } => Boolean(option.value));
if (options.length === 0) {
continue;
}
const callbackDataKind = resolveDiscordSelectCallbackDataKind(block.options);
if (callbackDataKind === "mixed") {
continue;
}
spec.blocks?.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
options,
callbackDataKind,
},
});
}
@@ -154,9 +218,7 @@ function appendDiscordPresentationButtonBlocks(
label: button.label,
style: button.url ? "link" : resolveDiscordInteractiveButtonStyle(button.style),
};
if (button.value) {
component.callbackData = button.value;
}
applyDiscordButtonCallback(component, button);
if (button.url) {
component.url = button.url;
}

View File

@@ -451,6 +451,36 @@ describe("feishuPlugin actions", () => {
).toBe(false);
});
it("does not render callback action buttons as Feishu quick commands", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Inspect", action: { type: "callback", value: "inspect:123" } }],
},
],
},
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
const card = requireRecord(sendCardArgs.card, "card");
const elements = requireArray(requireRecord(card.body, "card body").elements, "card elements");
expect(elements).toEqual([{ tag: "markdown", content: "- Inspect" }]);
});
it("renders legacy web_app presentation buttons as native Feishu link buttons", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });

View File

@@ -40,6 +40,16 @@ function resolveFeishuButtonUrl(button: MessagePresentationButton): string | und
return button.url ?? button.webApp?.url ?? button.web_app?.url;
}
function resolveFeishuCommandButtonValue(button: MessagePresentationButton): string | undefined {
if (button.action?.type === "callback") {
return undefined;
}
if (button.action?.type === "command") {
return button.action.command;
}
return button.value;
}
function mapFeishuButtonType(style: MessagePresentationButton["style"]) {
if (style === "primary" || style === "success") {
return "primary";
@@ -69,13 +79,14 @@ function buildFeishuPayloadButton(
behaviors.push({ type: "open_url", default_url: safeUrl });
}
}
if (button.value) {
const value = resolveFeishuCommandButtonValue(button);
if (value) {
behaviors.push({
type: "callback",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.payload.button",
q: button.value,
q: value,
}),
});
}

View File

@@ -449,7 +449,7 @@ describe("mattermostPlugin", () => {
expect(options.replyToId).toBe("post-root");
});
it("maps presentation buttons without using legacy interactive conversion", async () => {
it("maps legacy presentation buttons without using interactive conversion", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
@@ -463,7 +463,11 @@ describe("mattermostPlugin", () => {
{
type: "buttons",
buttons: [
{ label: "Open", value: "open", style: "primary" },
{
label: "Open",
value: "open",
style: "primary",
},
{ label: "Docs", url: "https://example.com/docs" },
],
},
@@ -480,10 +484,72 @@ describe("mattermostPlugin", () => {
"Deploy finished\n\n- Open\n- Docs: https://example.com/docs",
);
expect(options.buttons).toStrictEqual([
[{ text: "Open", callback_data: "open", style: "primary" }],
[
{
id: "open",
text: "Open",
callback_data: "open",
context: { callback_data: "open" },
style: "primary",
},
],
]);
});
it("does not render callback action buttons that Mattermost cannot round-trip", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Pick",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Inspect", action: { type: "callback", value: "inspect" } }],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "Pick\n\n- Inspect");
expect(options.buttons).toBeUndefined();
});
it("does not render command action buttons that Mattermost cannot execute", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "Pick",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Plugins", action: { type: "command", command: "/codex" } }],
},
],
},
},
cfg,
accountId: "default",
}),
);
const options = expectSingleMattermostSend("channel:CHAN1", "Pick\n\n- Plugins");
expect(options.buttons).toBeUndefined();
});
it("falls back to trimmed replyTo when replyToId is blank", async () => {
const cfg = createMattermostTestConfig();
@@ -556,7 +622,15 @@ describe("mattermostPlugin", () => {
"Deploy finished\n\n- Open\n- Docs: https://example.com/docs",
);
expect(options.buttons).toStrictEqual([
[{ text: "Open", callback_data: "open", style: "primary" }],
[
{
id: "open",
text: "Open",
callback_data: "open",
context: { callback_data: "open" },
style: "primary",
},
],
]);
});

View File

@@ -18,6 +18,7 @@ import {
type MessagePresentation,
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
resolveMessagePresentationControlValue,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolvePayloadMediaUrls, sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload";
@@ -65,18 +66,27 @@ function buildMattermostPresentationButtons(presentation: MessagePresentation) {
return presentation.blocks
.filter((block) => block.type === "buttons")
.map((block) =>
block.buttons.flatMap((button) =>
button.value
block.buttons.flatMap((button) => {
if (button.action) {
return [];
}
const value = resolveMessagePresentationControlValue(button);
return value
? [
{
id: value,
text: button.label,
callback_data: button.value,
callback_data: value,
context: {
callback_data: value,
},
style: button.style,
},
]
: [],
),
);
: [];
}),
)
.filter((row) => row.length > 0);
}
const MATTERMOST_PRESENTATION_CAPABILITIES = {
@@ -274,6 +284,7 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
const mediaUrl =
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
const buttons = presentation ? buildMattermostPresentationButtons(presentation) : [];
const result = await (
await loadMattermostChannelRuntime()
@@ -281,7 +292,7 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
cfg,
accountId: resolvedAccountId,
replyToId,
buttons: presentation ? buildMattermostPresentationButtons(presentation) : undefined,
buttons: buttons.length > 0 ? buttons : undefined,
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
mediaUrl,
});

View File

@@ -1,5 +1,6 @@
import {
adaptMessagePresentationForChannel,
resolveMessagePresentationControlValue,
type MessagePresentation,
} from "openclaw/plugin-sdk/interactive-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -74,11 +75,12 @@ export function buildMSTeamsPresentationCard(params: {
});
continue;
}
if (button.value) {
const value = resolveMessagePresentationControlValue(button);
if (value) {
actions.push({
type: "Action.Submit",
title: button.label,
data: { value: button.value, label: button.label },
data: button.action?.type === "command" ? value : { value, label: button.label },
});
}
}

View File

@@ -24,6 +24,28 @@ describe("buildMSTeamsPresentationCard", () => {
});
});
it("submits command actions as command text", () => {
expect(
buildMSTeamsPresentationCard({
presentation: {
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
},
],
},
],
},
}),
).toMatchObject({
actions: [{ type: "Action.Submit", title: "Plugins", data: "/codex plugins menu" }],
});
});
it("renders web app button links as open-url actions", () => {
expect(
buildMSTeamsPresentationCard({

View File

@@ -1,5 +1,9 @@
import type { Block, KnownBlock } from "@slack/web-api";
import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
import {
reduceInteractiveReply,
resolveMessagePresentationControlValue,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveReply,
MessagePresentation,
@@ -45,10 +49,32 @@ function resolveSlackButtonStyle(
return undefined;
}
function resolveSlackControlValue(control: {
action?: { type: "command"; command: string } | { type: "callback"; value: string };
value?: string;
}): string | undefined {
if (control.action?.type === "command") {
const command = normalizeOptionalString(control.action.command);
if (command && parseExecApprovalCommandText(command)) {
return command;
}
const legacyValue = normalizeOptionalString(control.value);
return legacyValue && parseExecApprovalCommandText(legacyValue) ? legacyValue : undefined;
}
return resolveMessagePresentationControlValue(control);
}
function isWithinSlackLimit(value: string, maxLength: number): boolean {
return value.length <= maxLength;
}
function isRenderableSlackOption(option: {
label: string;
value: string | undefined;
}): option is { label: string; value: string } {
return option.value !== undefined && isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX);
}
function readSlackBlockId(block: SlackBlock): string | undefined {
const value = (block as { block_id?: unknown }).block_id;
return typeof value === "string" ? value : undefined;
@@ -115,9 +141,10 @@ export function buildSlackInteractiveBlocks(
if (block.type === "buttons") {
const elements = block.buttons
.flatMap((button, choiceIndex) => {
const callbackData = resolveSlackControlValue(button);
const value =
button.value && isWithinSlackLimit(button.value, SLACK_BUTTON_VALUE_MAX)
? button.value
callbackData && isWithinSlackLimit(callbackData, SLACK_BUTTON_VALUE_MAX)
? callbackData
: undefined;
const url =
button.url && isWithinSlackLimit(button.url, SLACK_BUTTON_URL_MAX)
@@ -154,7 +181,11 @@ export function buildSlackInteractiveBlocks(
return state;
}
const options = block.options
.filter((option) => isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX))
.map((option) => ({
label: option.label,
value: resolveSlackControlValue(option),
}))
.filter(isRenderableSlackOption)
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
if (options.length === 0) {
return state;
@@ -259,9 +290,10 @@ function buildSlackPresentationButtonBlock(
): SlackBlock | undefined {
const elements = block.buttons
.flatMap((button, choiceIndex) => {
const callbackData = resolveSlackControlValue(button);
const value =
button.value && isWithinSlackLimit(button.value, SLACK_BUTTON_VALUE_MAX)
? button.value
callbackData && isWithinSlackLimit(callbackData, SLACK_BUTTON_VALUE_MAX)
? callbackData
: undefined;
const url =
button.url && isWithinSlackLimit(button.url, SLACK_BUTTON_URL_MAX) ? button.url : undefined;
@@ -299,7 +331,11 @@ function buildSlackPresentationSelectBlock(
selectIndex: number,
): SlackBlock | undefined {
const options = block.options
.filter((option) => isWithinSlackLimit(option.value, SLACK_OPTION_VALUE_MAX))
.map((option) => ({
label: option.label,
value: resolveSlackControlValue(option),
}))
.filter(isRenderableSlackOption)
.slice(0, SLACK_STATIC_SELECT_OPTIONS_MAX);
return options.length > 0
? {

View File

@@ -315,7 +315,13 @@ describe("buildSlackPresentationBlocks", () => {
{ type: "text", text: "Pick" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve", style: "success" }],
buttons: [
{
label: "Approve",
action: { type: "callback", value: "approve" },
style: "success",
},
],
},
],
});
@@ -344,6 +350,60 @@ describe("buildSlackPresentationBlocks", () => {
},
]);
});
it("does not render generic command actions that Slack cannot execute", () => {
const blocks = buildSlackPresentationBlocks({
blocks: [
{ type: "text", text: "Pick" },
{
type: "buttons",
buttons: [{ label: "Plugins", action: { type: "command", command: "/codex plugins" } }],
},
],
});
expect(blocks).toEqual([
{
type: "section",
text: { type: "mrkdwn", text: "Pick" },
},
]);
});
it("keeps exec approval commands on Slack's approval path", () => {
const blocks = buildSlackPresentationBlocks({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Approve",
action: { type: "command", command: "/approve req-1 allow-once" },
},
],
},
],
});
expect(blocks).toEqual([
{
type: "actions",
block_id: "openclaw_reply_buttons_1",
elements: [
{
type: "button",
action_id: "openclaw:reply_button:1:1",
text: {
type: "plain_text",
text: "Approve",
emoji: true,
},
value: "/approve req-1 allow-once",
},
],
},
]);
});
});
describe("resolveSlackReplyBlocks", () => {

View File

@@ -142,6 +142,7 @@ import {
resolveModelSelection,
type ProviderInfo,
} from "./model-buttons.js";
import { parseTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import { buildInlineKeyboard } from "./send.js";
export const registerTelegramHandlers = ({
@@ -2051,8 +2052,12 @@ export const registerTelegramHandlers = ({
const isGroup =
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
const nativeCallbackCommand = parseTelegramNativeCommandCallbackData(data);
const callbackCommandText = nativeCallbackCommand ?? data;
const approvalCallback = parseExecApprovalCommandText(callbackCommandText);
const opaqueCallbackData = parseTelegramOpaqueCallbackData(data);
const callbackCommandText = nativeCallbackCommand ?? (opaqueCallbackData ? "" : data);
const pluginCallbackData = opaqueCallbackData ?? data;
const approvalCallback = parseExecApprovalCommandText(
nativeCallbackCommand ?? (opaqueCallbackData ? "" : data),
);
const isApprovalCallback = approvalCallback !== null;
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
@@ -2141,7 +2146,7 @@ export const registerTelegramHandlers = ({
}
const runtimeCfg = telegramDeps.getRuntimeConfig();
const pluginCallback = await dispatchTelegramPluginInteractiveHandler({
data,
data: pluginCallbackData,
callbackId: callback.id,
ctx: {
accountId,
@@ -2338,6 +2343,10 @@ export const registerTelegramHandlers = ({
return;
}
if (opaqueCallbackData) {
return;
}
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];

View File

@@ -593,8 +593,8 @@ describe("registerTelegramNativeCommands", () => {
const presentation = {
blocks: [
{
kind: "actions",
buttons: [{ label: "Approve", action: { type: "command", value: "/approve yes" } }],
type: "buttons",
buttons: [{ label: "Approve", action: { type: "callback", value: "/approve yes" } }],
},
],
};

View File

@@ -5,10 +5,12 @@ import {
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { TelegramGroupConfig } from "openclaw/plugin-sdk/config-contracts";
import type { GetReplyOptions, MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { sanitizeTerminalText } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramBotOptions } from "./bot.types.js";
import type { TelegramGetChat } from "./bot/types.js";
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
const harness = await import("./bot.create-telegram-bot.test-harness.js");
const pluginStateTestRuntime = await import("openclaw/plugin-sdk/plugin-state-test-runtime");
const conversationRuntime = await import("openclaw/plugin-sdk/conversation-runtime");
@@ -145,29 +147,6 @@ function mockTelegramConfigWrites() {
return vi.spyOn(configMutation, "mutateConfigFile").mockResolvedValue({} as never);
}
async function withEnvAsync(env: Record<string, string | undefined>, fn: () => Promise<void>) {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(env)) {
previous.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
await fn();
} finally {
for (const [key, value] of previous) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function flushTelegramTestMicrotasks() {
await Promise.resolve();
await Promise.resolve();
@@ -1063,6 +1042,34 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
});
it("does not route opaque callback_query payloads as synthetic commands", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(
onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as
| ((ctx: Record<string, unknown>) => Promise<void>)
| undefined,
"callback_query handler",
);
await callbackHandler({
callbackQuery: {
id: "cbq-opaque-1",
data: buildTelegramOpaqueCallbackData("/codex permissions yolo"),
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 10,
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-opaque-1");
});
it("toggles OC_MULTI buttons without routing through the generic callback message path", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(

View File

@@ -9,6 +9,7 @@ import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js";
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
const {
answerCallbackQuerySpy,
commandSpy,
@@ -653,7 +654,7 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-approve-capability-free");
});
it("resolves plugin approval callbacks through the shared approval resolver", async () => {
it("resolves opaque plugin approval callbacks through the shared approval resolver", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
editMessageTextSpy.mockClear();
@@ -712,6 +713,53 @@ describe("createTelegramBot", () => {
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-approve");
});
it("does not resolve opaque approval-shaped plugin callbacks", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
resolveExecApprovalSpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
execApprovals: {
enabled: true,
approvers: ["9"],
target: "dm",
},
},
},
});
createTelegramBot({ token: "tok" });
const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
if (!callbackHandler) {
throw new Error("Expected Telegram callback_query handler");
}
await callbackHandler({
callbackQuery: {
id: "cbq-opaque-plugin-approve",
data: buildTelegramOpaqueCallbackData("/approve plugin:138e9b8c allow-once"),
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 1234, type: "private" },
date: 1736380800,
message_id: 25,
text: "Plugin callback.",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(resolveExecApprovalSpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-opaque-plugin-approve");
});
it("blocks approval callbacks from telegram users who are not exec approvers", async () => {
onSpy.mockClear();
editMessageReplyMarkupSpy.mockClear();

View File

@@ -4,6 +4,10 @@ import {
buildTelegramPresentationButtons,
} from "./button-types.js";
import { describeTelegramInteractiveButtonBehavior } from "./button-types.test-helpers.js";
import {
buildTelegramOpaqueCallbackData,
parseTelegramOpaqueCallbackData,
} from "./native-command-callback-data.js";
describeTelegramInteractiveButtonBehavior();
@@ -55,8 +59,17 @@ describe("buildTelegramPresentationButtons", () => {
{
type: "buttons",
buttons: [
{ label: "Keep", value: "/codex plugins menu" },
{ label: "Drop", value: `/codex plugins enable ${"x".repeat(80)}` },
{
label: "Keep",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Drop",
action: {
type: "command",
command: `/codex plugins enable ${"x".repeat(80)}`,
},
},
],
},
],
@@ -72,6 +85,51 @@ describe("buildTelegramPresentationButtons", () => {
]);
});
it("keeps legacy raw slash-valued callbacks as callbacks", () => {
expect(
buildTelegramPresentationButtons({
blocks: [
{
type: "buttons",
buttons: [{ label: "Raw", value: "/not-a-native-command" }],
},
],
}),
).toEqual([[{ text: "Raw", callback_data: "/not-a-native-command", style: undefined }]]);
});
it("marks typed callbacks as opaque callback data", () => {
const callbackData = buildTelegramOpaqueCallbackData("/not-a-native-command");
expect(
buildTelegramPresentationButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Raw", action: { type: "callback", value: "/not-a-native-command" } },
],
},
],
}),
).toEqual([[{ text: "Raw", callback_data: callbackData, style: undefined }]]);
expect(parseTelegramOpaqueCallbackData(callbackData)).toBe("/not-a-native-command");
});
it("keeps legacy values that look like opaque callback prefixes raw", () => {
expect(parseTelegramOpaqueCallbackData("tgcb1:inspect:123")).toBeNull();
expect(
buildTelegramPresentationButtons({
blocks: [
{
type: "buttons",
buttons: [{ label: "Raw", value: "tgcb1:inspect:123" }],
},
],
}),
).toEqual([[{ text: "Raw", callback_data: "tgcb1:inspect:123", style: undefined }]]);
});
it("keeps shortened plugin approval callbacks on the approval bypass path", () => {
const approvalId = `plugin:${"a".repeat(36)}`;
expect(
@@ -93,4 +151,51 @@ describe("buildTelegramPresentationButtons", () => {
],
]);
});
it("keeps typed approval commands on the compact approval bypass path", () => {
const approvalId = `plugin:${"a".repeat(36)}`;
expect(
buildTelegramPresentationButtons({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow",
action: { type: "command", command: `/approve ${approvalId} allow-always` },
},
],
},
],
}),
).toEqual([
[
{
text: "Allow",
callback_data: `/approve ${approvalId} always`,
style: undefined,
},
],
]);
});
it("keeps approval-shaped typed callbacks opaque", () => {
const callbackData = buildTelegramOpaqueCallbackData("/approve plugin:123 allow-once");
expect(
buildTelegramPresentationButtons({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugin",
action: { type: "callback", value: "/approve plugin:123 allow-once" },
},
],
},
],
}),
).toEqual([[{ text: "Plugin", callback_data: callbackData, style: undefined }]]);
});
});

View File

@@ -9,7 +9,10 @@ import {
type MessagePresentationButton,
} from "openclaw/plugin-sdk/interactive-runtime";
import { sanitizeTelegramCallbackData } from "./approval-callback-data.js";
import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js";
import {
buildTelegramNativeCommandCallbackData,
buildTelegramOpaqueCallbackData,
} from "./native-command-callback-data.js";
export type TelegramButtonStyle = "danger" | "success" | "primary";
@@ -42,7 +45,7 @@ function toTelegramInlineButton(
style,
};
}
const callbackData = button.value ? toTelegramCallbackData(button.value) : undefined;
const callbackData = toTelegramCallbackData(button);
if (callbackData) {
return {
text: button.label,
@@ -60,19 +63,22 @@ function toTelegramInlineButton(
return undefined;
}
function toTelegramCallbackData(value: string): string | undefined {
const sanitizedValue = sanitizeTelegramCallbackData(value);
if (!sanitizedValue) {
return undefined;
function toTelegramCallbackData(button: MessagePresentationButton): string | undefined {
if (button.action?.type === "command") {
const command = button.action.command.trim();
if (!command) {
return undefined;
}
if (parseExecApprovalCommandText(command)) {
return sanitizeTelegramCallbackData(command);
}
const callbackData = buildTelegramNativeCommandCallbackData(command);
return sanitizeTelegramCallbackData(callbackData);
}
if (parseExecApprovalCommandText(sanitizedValue)) {
return sanitizedValue;
if (button.action?.type === "callback") {
return sanitizeTelegramCallbackData(buildTelegramOpaqueCallbackData(button.action.value));
}
const commandText = sanitizedValue.trim();
const callbackData = commandText.startsWith("/")
? buildTelegramNativeCommandCallbackData(commandText)
: sanitizedValue;
return sanitizeTelegramCallbackData(callbackData);
return button.value ? sanitizeTelegramCallbackData(button.value) : undefined;
}
function chunkInteractiveButtons(
@@ -135,6 +141,7 @@ export function buildTelegramPresentationButtons(
chunkInteractiveButtons(
block.options.map((option) => ({
label: option.label,
action: option.action,
value: option.value,
})),
rows,

View File

@@ -1,4 +1,5 @@
const TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX = "tgcmd:";
const TELEGRAM_OPAQUE_CALLBACK_PREFIX = "tgcb1:";
export function buildTelegramNativeCommandCallbackData(commandText: string): string {
return `${TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX}${commandText}`;
@@ -15,3 +16,36 @@ export function parseTelegramNativeCommandCallbackData(data?: string | null): st
const commandText = trimmed.slice(TELEGRAM_NATIVE_COMMAND_CALLBACK_PREFIX.length).trim();
return commandText.startsWith("/") ? commandText : null;
}
export function buildTelegramOpaqueCallbackData(value: string): string {
return `${TELEGRAM_OPAQUE_CALLBACK_PREFIX}${checksumTelegramOpaqueCallbackValue(value)}:${value}`;
}
export function parseTelegramOpaqueCallbackData(data?: string | null): string | null {
if (!data) {
return null;
}
if (!data.startsWith(TELEGRAM_OPAQUE_CALLBACK_PREFIX)) {
return null;
}
const encoded = data.slice(TELEGRAM_OPAQUE_CALLBACK_PREFIX.length);
const separatorIndex = encoded.indexOf(":");
if (separatorIndex <= 0) {
return null;
}
const checksum = encoded.slice(0, separatorIndex);
const value = encoded.slice(separatorIndex + 1);
if (!value || checksum !== checksumTelegramOpaqueCallbackValue(value)) {
return null;
}
return value;
}
function checksumTelegramOpaqueCallbackValue(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36).slice(0, 5).padStart(5, "0");
}

View File

@@ -81,11 +81,23 @@ export type AgentRuntimeProviderHandle = {
export type AgentRuntimeInteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
export type AgentRuntimeMessagePresentationAction =
| {
type: "command";
command: string;
}
| {
type: "callback";
value: string;
};
/** Portable action control exposed to agent runtime reply payloads. */
export type AgentRuntimeMessagePresentationButton = {
/** User-visible button label. */
label: string;
/** Callback command or opaque value sent when pressed. */
/** Typed action sent when pressed. */
action?: AgentRuntimeMessagePresentationAction;
/** Legacy opaque callback value sent when pressed. */
value?: string;
/** External URL opened by the button. */
url?: string;
@@ -103,8 +115,10 @@ export type AgentRuntimeMessagePresentationButton = {
export type AgentRuntimeMessagePresentationOption = {
/** User-visible option label. */
label: string;
/** Callback command or opaque value sent when selected. */
value: string;
/** Typed action sent when selected. */
action?: AgentRuntimeMessagePresentationAction;
/** Legacy opaque callback value sent when selected. */
value?: string;
};
/**

View File

@@ -1216,8 +1216,11 @@ describe("message tool schema scoping", () => {
});
const properties = getToolProperties(tool);
const actionEnum = getActionEnum(properties);
const presentationSchemaJson = JSON.stringify(properties.presentation);
expect(properties).toHaveProperty("presentation");
expect(presentationSchemaJson).toContain('"action"');
expect(presentationSchemaJson).toContain('"command"');
expect(properties.components).toBeUndefined();
expect(properties.blocks).toBeUndefined();
expect(properties.buttons).toBeUndefined();
@@ -1945,6 +1948,7 @@ describe("message tool reasoning tag sanitization", () => {
buttons: [
{
label: "<think>button rationale</think>Approve",
action: { type: "command", command: "/codex approve" },
value: "approve",
},
],
@@ -1970,7 +1974,13 @@ describe("message tool reasoning tag sanitization", () => {
{ type: "text", text: "Ship it" },
{
type: "buttons",
buttons: [{ label: "Approve", value: "approve" }],
buttons: [
{
label: "Approve",
action: { type: "command", command: "/codex approve" },
value: "approve",
},
],
},
{
type: "select",

View File

@@ -301,13 +301,26 @@ function buildRoutingSchema() {
};
}
const presentationActionSchema = Type.Union([
Type.Object({
type: Type.Literal("command"),
command: Type.String(),
}),
Type.Object({
type: Type.Literal("callback"),
value: Type.String(),
}),
]);
const presentationOptionSchema = Type.Object({
label: Type.String(),
value: Type.String(),
action: Type.Optional(presentationActionSchema),
value: Type.Optional(Type.String()),
});
const presentationButtonSchema = Type.Object({
label: Type.String(),
action: Type.Optional(presentationActionSchema),
value: Type.Optional(Type.String()),
url: Type.Optional(Type.String()),
webApp: Type.Optional(Type.Object({ url: Type.String() })),

View File

@@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import { createPluginRegistry, type PluginRecord } from "../../plugins/registry.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import type { PluginCommandContext } from "../../plugins/types.js";
import type { PluginCommandContext, PluginCommandHandler } from "../../plugins/types.js";
import type { MsgContext } from "../templating.js";
import { createDiagnosticsCommandHandler } from "./commands-diagnostics.js";
import type { HandleCommandsParams } from "./commands-types.js";
@@ -161,7 +161,7 @@ function registerCodexDiagnosticsCommandForTest(
handler: (ctx: PluginCommandContext) => Promise<unknown>,
) {
const calls: PluginCommandContext[] = [];
const commandHandler = vi.fn(async (ctx: PluginCommandContext) => {
const commandHandler = vi.fn<PluginCommandHandler>(async (ctx) => {
calls.push(ctx);
await handler(ctx);
if (ctx.diagnosticsPreviewOnly) {
@@ -201,11 +201,19 @@ function registerCodexDiagnosticsCommandForTest(
buttons: [
{
label: "Send diagnostics",
action: {
type: "command",
command: "/codex diagnostics confirm abc123def456",
},
value: "/codex diagnostics confirm abc123def456",
style: "danger" as const,
},
{
label: "Cancel",
action: {
type: "command",
command: "/codex diagnostics cancel abc123def456",
},
value: "/codex diagnostics cancel abc123def456",
style: "secondary" as const,
},

View File

@@ -6,7 +6,7 @@ import type { SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
import type { InteractiveReply } from "../../interactive/payload.js";
import type { InteractiveReply, MessagePresentationAction } from "../../interactive/payload.js";
import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js";
import type { PluginCommandDiagnosticsSession, PluginCommandResult } from "../../plugins/types.js";
import type { ReplyPayload } from "../types.js";
@@ -604,6 +604,7 @@ function rewriteInteractive(interactive: InteractiveReply): InteractiveReply {
...block,
buttons: block.buttons.map((button) => ({
...button,
...(button.action ? { action: rewritePresentationAction(button.action) } : {}),
...(button.value ? { value: rewriteCodexDiagnosticsCommandPrefix(button.value) } : {}),
})),
};
@@ -613,7 +614,8 @@ function rewriteInteractive(interactive: InteractiveReply): InteractiveReply {
...block,
options: block.options.map((option) => ({
...option,
value: rewriteCodexDiagnosticsCommandPrefix(option.value),
...(option.action ? { action: rewritePresentationAction(option.action) } : {}),
...(option.value ? { value: rewriteCodexDiagnosticsCommandPrefix(option.value) } : {}),
})),
};
}
@@ -622,6 +624,19 @@ function rewriteInteractive(interactive: InteractiveReply): InteractiveReply {
};
}
function rewritePresentationAction(action: MessagePresentationAction): MessagePresentationAction {
if (action.type === "command") {
return {
type: "command",
command: rewriteCodexDiagnosticsCommandPrefix(action.command),
};
}
return {
type: "callback",
value: rewriteCodexDiagnosticsCommandPrefix(action.value),
};
}
function rewriteCodexDiagnosticsCommandPrefix(value: string): string {
return value
.replaceAll(`${CODEX_DIAGNOSTICS_COMMAND} confirm`, `${DIAGNOSTICS_COMMAND} confirm`)

View File

@@ -80,6 +80,94 @@ describe("presentation capability limits", () => {
]);
});
it("applies callback byte limits to typed command actions", () => {
const buttons = applyPresentationActionLimits(
[
{
label: "Keep",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Drop",
action: { type: "command", command: `/codex plugins enable ${"x".repeat(20)}` },
},
],
{
limits: {
actions: {
maxValueBytes: 24,
},
},
},
);
expect(buttons).toEqual([
{
label: "Keep",
action: { type: "command", command: "/codex plugins menu" },
},
]);
});
it("keeps typed button actions when only the legacy fallback exceeds value limits", () => {
const buttons = applyPresentationActionLimits(
[
{
label: "Keep",
value: "legacy-value-that-is-too-long",
action: { type: "command", command: "/short" },
},
],
{
limits: {
actions: {
maxValueBytes: 8,
},
},
},
);
expect(buttons).toEqual([
{
label: "Keep",
action: { type: "command", command: "/short" },
},
]);
});
it("keeps typed select actions when only the legacy fallback exceeds value limits", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {
blocks: [
{
type: "select",
options: [
{
label: "Keep",
value: "legacy-value-that-is-too-long",
action: { type: "callback", value: "short" },
},
],
},
],
},
capabilities: {
limits: {
selects: {
maxValueBytes: 8,
},
},
},
});
expect(presentation.blocks).toEqual([
{
type: "select",
options: [{ label: "Keep", action: { type: "callback", value: "short" } }],
},
]);
});
it("adapts button and select blocks without touching text blocks", () => {
const presentation = adaptMessagePresentationForChannel({
presentation: {

View File

@@ -1,4 +1,5 @@
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { resolveMessagePresentationActionValue } from "../../../interactive/payload.js";
import type {
MessagePresentation,
MessagePresentationBlock,
@@ -201,9 +202,14 @@ function adaptButton(
limits: ActionLimits | undefined,
): MessagePresentationButton | undefined {
const hasLinkTarget = Boolean(button.url || button.webApp || button.web_app);
const valueFits = fitsByteLimit(button.value, limits?.maxValueBytes);
const actionValue = resolveMessagePresentationActionValue(button.action);
const valueFits =
button.value === undefined || fitsByteLimit(button.value, limits?.maxValueBytes);
const actionFits = actionValue === undefined || fitsByteLimit(actionValue, limits?.maxValueBytes);
const hasRenderableControl =
(button.value !== undefined && valueFits) || (actionValue !== undefined && actionFits);
if (
(!valueFits && !hasLinkTarget) ||
(!hasRenderableControl && !hasLinkTarget) ||
(button.disabled === true && limits?.supportsDisabled !== true)
) {
return undefined;
@@ -215,6 +221,9 @@ function adaptButton(
if (!valueFits) {
delete adapted.value;
}
if (!actionFits) {
delete adapted.action;
}
if (limits?.supportsStyles === false) {
delete adapted.style;
}
@@ -293,13 +302,24 @@ function adaptOption(
option: MessagePresentationOption,
limits: SelectLimits | undefined,
): MessagePresentationOption | undefined {
if (!fitsByteLimit(option.value, limits?.maxValueBytes)) {
const actionValue = resolveMessagePresentationActionValue(option.action);
const valueFits =
option.value === undefined || fitsByteLimit(option.value, limits?.maxValueBytes);
const actionFits = actionValue === undefined || fitsByteLimit(actionValue, limits?.maxValueBytes);
if (!(option.value !== undefined && valueFits) && !(actionValue !== undefined && actionFits)) {
return undefined;
}
return {
const adapted: MessagePresentationOption = {
...option,
label: truncateText(option.label, limits?.maxLabelLength),
};
if (!valueFits) {
delete adapted.value;
}
if (!actionFits) {
delete adapted.action;
}
return adapted;
}
function adaptSelectBlock(

View File

@@ -265,16 +265,28 @@ describe("exec approval reply helpers", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve req-1 allow-once",
},
value: "/approve req-1 allow-once",
style: "success",
},
{
label: "Allow Always",
action: {
type: "command",
command: "/approve req-1 allow-always",
},
value: "/approve req-1 allow-always",
style: "primary",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve req-1 deny",
},
value: "/approve req-1 deny",
style: "danger",
},
@@ -332,11 +344,19 @@ describe("exec approval reply helpers", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve req-ask-always allow-once",
},
value: "/approve req-ask-always allow-once",
style: "success",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve req-ask-always deny",
},
value: "/approve req-ask-always deny",
style: "danger",
},
@@ -444,9 +464,24 @@ describe("exec approval reply helpers", () => {
{
type: "buttons",
buttons: [
{ label: "Allow Once", value: "/approve req-1 allow-once", style: "success" },
{ label: "Allow Always", value: "/approve req-1 allow-always", style: "primary" },
{ label: "Deny", value: "/approve req-1 deny", style: "danger" },
{
label: "Allow Once",
action: { type: "command", command: "/approve req-1 allow-once" },
value: "/approve req-1 allow-once",
style: "success",
},
{
label: "Allow Always",
action: { type: "command", command: "/approve req-1 allow-always" },
value: "/approve req-1 allow-always",
style: "primary",
},
{
label: "Deny",
action: { type: "command", command: "/approve req-1 deny" },
value: "/approve req-1 deny",
style: "danger",
},
],
},
],

View File

@@ -162,6 +162,7 @@ function buildApprovalInteractiveButtons(
): InteractiveReplyButton[] {
return descriptors.map((descriptor) => ({
label: descriptor.label,
action: { type: "command", command: descriptor.command },
value: descriptor.command,
style: descriptor.style,
}));
@@ -172,6 +173,7 @@ function buildApprovalPresentationButtons(
): MessagePresentationButton[] {
return descriptors.map((descriptor) => ({
label: descriptor.label,
action: { type: "command", command: descriptor.command },
value: descriptor.command,
style: descriptor.style,
}));

View File

@@ -144,16 +144,28 @@ describe("plugin approval forwarding", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve plugin-req-1 allow-once",
},
value: "/approve plugin-req-1 allow-once",
style: "success",
},
{
label: "Allow Always",
action: {
type: "command",
command: "/approve plugin-req-1 allow-always",
},
value: "/approve plugin-req-1 allow-always",
style: "primary",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve plugin-req-1 deny",
},
value: "/approve plugin-req-1 deny",
style: "danger",
},
@@ -187,11 +199,19 @@ describe("plugin approval forwarding", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve plugin-req-1 allow-once",
},
value: "/approve plugin-req-1 allow-once",
style: "success",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve plugin-req-1 deny",
},
value: "/approve plugin-req-1 deny",
style: "danger",
},

View File

@@ -165,6 +165,63 @@ describe("interactive payload helpers", () => {
);
});
it("normalizes typed presentation actions and bridges them to legacy values", () => {
const normalized = normalizeMessagePresentation({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
},
],
},
],
});
expect(normalized).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
},
],
},
],
});
expect(presentationToInteractiveReply(normalized!)).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Plugins",
action: { type: "command", command: "/codex plugins menu" },
value: "/codex plugins menu",
},
{
label: "Approve",
action: { type: "callback", value: "/approve req allow-once" },
value: "/approve req allow-once",
},
],
},
],
});
});
it("converts only presentation controls for native component renderers", () => {
const presentation = {
title: "Deploy approval",

View File

@@ -12,11 +12,29 @@ export type MessagePresentationTone = "info" | "success" | "warning" | "danger"
/** Button style hint for renderers that support styled actions. */
export type MessagePresentationButtonStyle = InteractiveButtonStyle;
/** Portable typed action behind a button or select option. */
export type MessagePresentationAction =
| {
/** Run a core/plugin slash command through the target channel's native command path. */
type: "command";
command: string;
}
| {
/** Opaque callback value interpreted by the target channel/plugin. */
type: "callback";
value: string;
};
/** Portable action control rendered as a button or link by channel adapters. */
export type MessagePresentationButton = {
/** User-visible button label. */
label: string;
/** Callback command or opaque value sent when the button is pressed. */
/** Typed action sent when the button is pressed. */
action?: MessagePresentationAction;
/**
* Legacy opaque callback value sent when the button is pressed.
* Prefer action for new presentation controls.
*/
value?: string;
/** External URL opened by the button instead of sending a callback value. */
url?: string;
@@ -44,10 +62,31 @@ export type MessagePresentationButton = {
export type MessagePresentationOption = {
/** User-visible option label. */
label: string;
/** Callback command or opaque value sent when the option is selected. */
value: string;
/** Typed action sent when the option is selected. */
action?: MessagePresentationAction;
/** Legacy opaque callback value sent when the option is selected. */
value?: string;
};
export function resolveMessagePresentationActionValue(
action: MessagePresentationAction | undefined,
): string | undefined {
if (action?.type === "command") {
return action.command;
}
if (action?.type === "callback") {
return action.value;
}
return undefined;
}
export function resolveMessagePresentationControlValue(control: {
action?: MessagePresentationAction;
value?: string;
}): string | undefined {
return resolveMessagePresentationActionValue(control.action) ?? control.value;
}
/**
* @deprecated Use MessagePresentationButton.
*/
@@ -176,6 +215,23 @@ function normalizePresentationTone(value: unknown): MessagePresentationTone | un
: undefined;
}
function normalizePresentationAction(raw: unknown): MessagePresentationAction | undefined {
const record = toRecord(raw);
if (!record) {
return undefined;
}
const type = normalizeOptionalLowercaseString(record.type);
if (type === "command") {
const command = normalizeOptionalString(record.command);
return command ? { type: "command", command } : undefined;
}
if (type === "callback") {
const value = normalizeOptionalString(record.value);
return value ? { type: "callback", value } : undefined;
}
return undefined;
}
function normalizeButton(raw: unknown): InteractiveReplyButton | undefined {
const record = toRecord(raw);
if (!record) {
@@ -186,10 +242,11 @@ function normalizeButton(raw: unknown): InteractiveReplyButton | undefined {
normalizeOptionalString(record.value) ??
normalizeOptionalString(record.callbackData) ??
normalizeOptionalString(record.callback_data);
const action = normalizePresentationAction(record.action);
const url = normalizeOptionalString(record.url);
const webAppRecord = toRecord(record.webApp) ?? toRecord(record.web_app);
const webAppUrl = normalizeOptionalString(webAppRecord?.url);
if (!label || (!value && !url && !webAppUrl)) {
if (!label || (!action && !value && !url && !webAppUrl)) {
return undefined;
}
const priority =
@@ -198,6 +255,7 @@ function normalizeButton(raw: unknown): InteractiveReplyButton | undefined {
: undefined;
return {
label,
...(action ? { action } : {}),
...(value ? { value } : {}),
...(url ? { url } : {}),
...(webAppUrl ? { webApp: { url: webAppUrl } } : {}),
@@ -214,11 +272,13 @@ function normalizeOption(raw: unknown): InteractiveReplyOption | undefined {
return undefined;
}
const label = normalizeOptionalString(record.label) ?? normalizeOptionalString(record.text);
const value = normalizeOptionalString(record.value);
const action = normalizePresentationAction(record.action);
const value =
normalizeOptionalString(record.value) ?? resolveMessagePresentationActionValue(action);
if (!label || !value) {
return undefined;
}
return { label, value };
return { label, ...(action ? { action } : {}), value };
}
function normalizeList<T>(value: unknown, normalizeEntry: (entry: unknown) => T | undefined): T[] {
@@ -341,14 +401,24 @@ export function presentationToInteractiveReply(
}
if (block.type === "buttons") {
const buttons = block.buttons
.filter((button) => button.value || button.url || button.webApp || button.web_app)
.filter(
(button) =>
button.action || button.value || button.url || button.webApp || button.web_app,
)
.map((button) => {
const interactiveButton: InteractiveReplyButton = {
label: button.label,
style: button.style,
};
if (button.action) {
interactiveButton.action = button.action;
}
if (button.value) {
interactiveButton.value = button.value;
} else if (button.action?.type === "command") {
interactiveButton.value = button.action.command;
} else if (button.action?.type === "callback") {
interactiveButton.value = button.action.value;
}
if (button.url) {
interactiveButton.url = button.url;
@@ -377,7 +447,16 @@ export function presentationToInteractiveReply(
blocks.push({
type: "select",
placeholder: block.placeholder,
options: block.options,
options: block.options.map((option) => {
const interactiveOption: InteractiveReplyOption = {
label: option.label,
value: resolveMessagePresentationControlValue(option) ?? option.value,
};
if (option.action) {
interactiveOption.action = option.action;
}
return interactiveOption;
}),
});
}
}

View File

@@ -23,16 +23,28 @@ describe("plugin-sdk/approval-renderers", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve plugin:approval-123 allow-once",
},
value: "/approve plugin:approval-123 allow-once",
style: "success",
},
{
label: "Allow Always",
action: {
type: "command",
command: "/approve plugin:approval-123 allow-always",
},
value: "/approve plugin:approval-123 allow-always",
style: "primary",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve plugin:approval-123 deny",
},
value: "/approve plugin:approval-123 deny",
style: "danger",
},
@@ -70,16 +82,28 @@ describe("plugin-sdk/approval-renderers", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve plugin-approval-123 allow-once",
},
value: "/approve plugin-approval-123 allow-once",
style: "success",
},
{
label: "Allow Always",
action: {
type: "command",
command: "/approve plugin-approval-123 allow-always",
},
value: "/approve plugin-approval-123 allow-always",
style: "primary",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve plugin-approval-123 deny",
},
value: "/approve plugin-approval-123 deny",
style: "danger",
},
@@ -126,11 +150,19 @@ describe("plugin-sdk/approval-renderers", () => {
buttons: [
{
label: "Allow Once",
action: {
type: "command",
command: "/approve plugin-approval-123 allow-once",
},
value: "/approve plugin-approval-123 allow-once",
style: "success",
},
{
label: "Deny",
action: {
type: "command",
command: "/approve plugin-approval-123 deny",
},
value: "/approve plugin-approval-123 deny",
style: "danger",
},

View File

@@ -13,6 +13,7 @@ export type {
InteractiveReplySelectBlock,
InteractiveReplyTextBlock,
MessagePresentation,
MessagePresentationAction,
MessagePresentationBlock,
MessagePresentationButton,
MessagePresentationButtonStyle,
@@ -39,5 +40,7 @@ export {
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
resolveMessagePresentationActionValue,
resolveMessagePresentationControlValue,
resolveInteractiveTextFallback,
} from "../interactive/payload.js";