mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
4b1d2faa99
commit
d641126c1d
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
extensions/codex/src/command-presentation.ts
Normal file
23
extensions/codex/src/command-presentation.ts
Normal 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 },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
DiscordComponentBuildResult,
|
||||
DiscordComponentButtonSpec,
|
||||
DiscordComponentButtonStyle,
|
||||
DiscordComponentCallbackDataKind,
|
||||
DiscordComponentEntry,
|
||||
DiscordComponentMessageSpec,
|
||||
DiscordComponentModalFieldType,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() })),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user