426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
382 lines
8.0 KiB
TypeScript
382 lines
8.0 KiB
TypeScript
/**
|
|
* Turns neutral command specs into Discord REST slash-command payloads with
|
|
* button-based argument menus. Consumed by the slash-command registration path
|
|
* when syncing commands to the Discord application.
|
|
*/
|
|
import {
|
|
type APIApplicationCommandOption,
|
|
ApplicationCommandOptionType,
|
|
ButtonStyle,
|
|
type RESTPostAPIChatInputApplicationCommandsJSONBody,
|
|
} from "discord-api-types/v10";
|
|
|
|
/**
|
|
* Command argument definition
|
|
*/
|
|
export interface CommandArgDefinition {
|
|
name: string;
|
|
description: string;
|
|
type: "string" | "number" | "boolean";
|
|
required?: boolean;
|
|
choices?: Array<{ label: string; value: string }>;
|
|
}
|
|
|
|
/**
|
|
* Native command specification
|
|
*/
|
|
export interface NativeCommandSpec {
|
|
name: string;
|
|
description: string;
|
|
acceptsArgs?: boolean;
|
|
args?: CommandArgDefinition[];
|
|
ephemeralDefault?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Command argument values
|
|
*/
|
|
export type CommandArgValues = Record<string, string | number | boolean>;
|
|
|
|
/**
|
|
* Parsed command arguments
|
|
*/
|
|
export interface CommandArgs {
|
|
values: CommandArgValues;
|
|
raw?: string;
|
|
}
|
|
|
|
/**
|
|
* Result of building command options
|
|
*/
|
|
export interface BuiltCommandOption {
|
|
name: string;
|
|
description: string;
|
|
type: ApplicationCommandOptionType;
|
|
required?: boolean;
|
|
choices?: Array<{ name: string; value: string | number }>;
|
|
}
|
|
|
|
/**
|
|
* Button specification for argument menus
|
|
*/
|
|
export interface CommandArgButton {
|
|
label: string;
|
|
customId: string;
|
|
style: ButtonStyle;
|
|
}
|
|
|
|
/**
|
|
* Row of buttons for argument menus
|
|
*/
|
|
export interface CommandArgButtonRow {
|
|
buttons: CommandArgButton[];
|
|
}
|
|
|
|
/**
|
|
* Argument menu specification
|
|
*/
|
|
export interface CommandArgMenu {
|
|
content: string;
|
|
rows: CommandArgButtonRow[];
|
|
}
|
|
|
|
/**
|
|
* Key for command argument custom IDs
|
|
*/
|
|
export const COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg";
|
|
|
|
/**
|
|
* Builds Discord command options from argument definitions
|
|
*/
|
|
export function buildDiscordCommandOptions(
|
|
args?: CommandArgDefinition[],
|
|
): BuiltCommandOption[] | undefined {
|
|
if (!args || args.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return args.map((arg) => {
|
|
const required = arg.required ?? false;
|
|
|
|
if (arg.type === "number") {
|
|
return {
|
|
name: arg.name,
|
|
description: arg.description,
|
|
type: ApplicationCommandOptionType.Number,
|
|
required,
|
|
};
|
|
}
|
|
|
|
if (arg.type === "boolean") {
|
|
return {
|
|
name: arg.name,
|
|
description: arg.description,
|
|
type: ApplicationCommandOptionType.Boolean,
|
|
required,
|
|
};
|
|
}
|
|
|
|
const choices =
|
|
arg.choices && arg.choices.length > 0 && arg.choices.length <= 25
|
|
? arg.choices.map((choice) => ({
|
|
name: choice.label,
|
|
value: choice.value,
|
|
}))
|
|
: undefined;
|
|
|
|
return {
|
|
name: arg.name,
|
|
description: arg.description,
|
|
type: ApplicationCommandOptionType.String,
|
|
required,
|
|
choices,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds a complete Discord slash command JSON body
|
|
*/
|
|
export function buildDiscordSlashCommand(
|
|
spec: NativeCommandSpec,
|
|
): RESTPostAPIChatInputApplicationCommandsJSONBody {
|
|
const options = buildDiscordCommandOptions(spec.args);
|
|
|
|
const commandOptions: APIApplicationCommandOption[] | undefined =
|
|
options?.map(
|
|
(opt) =>
|
|
({
|
|
name: opt.name,
|
|
description: opt.description,
|
|
type: opt.type,
|
|
required: opt.required,
|
|
choices: opt.choices,
|
|
}) as APIApplicationCommandOption,
|
|
) ??
|
|
(spec.acceptsArgs
|
|
? [
|
|
{
|
|
name: "input",
|
|
description: "Command input",
|
|
type: ApplicationCommandOptionType.String,
|
|
required: false,
|
|
} as APIApplicationCommandOption,
|
|
]
|
|
: undefined);
|
|
|
|
return {
|
|
name: spec.name,
|
|
description: spec.description,
|
|
options: commandOptions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Encodes a command argument value for use in custom IDs
|
|
*/
|
|
export function encodeCommandArgValue(value: string): string {
|
|
return encodeURIComponent(value);
|
|
}
|
|
|
|
/**
|
|
* Decodes a command argument value from a custom ID
|
|
*/
|
|
export function decodeCommandArgValue(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a custom ID for a command argument button
|
|
*/
|
|
export function buildCommandArgCustomId(params: {
|
|
command: string;
|
|
arg: string;
|
|
value: string;
|
|
userId: string;
|
|
}): string {
|
|
return [
|
|
`${COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeCommandArgValue(params.command)}`,
|
|
`arg=${encodeCommandArgValue(params.arg)}`,
|
|
`value=${encodeCommandArgValue(params.value)}`,
|
|
`user=${encodeCommandArgValue(params.userId)}`,
|
|
].join(";");
|
|
}
|
|
|
|
/**
|
|
* Parses command argument data from a custom ID
|
|
*/
|
|
export function parseCommandArgCustomId(
|
|
customId: string,
|
|
): { command: string; arg: string; value: string; userId: string } | null {
|
|
if (!customId.startsWith(COMMAND_ARG_CUSTOM_ID_KEY)) {
|
|
return null;
|
|
}
|
|
|
|
const parts = customId.split(";");
|
|
const data: Record<string, string> = {};
|
|
|
|
for (const part of parts) {
|
|
const [key, value] = part.split("=");
|
|
if (key && value) {
|
|
// Handle the first part which has the prefix
|
|
const cleanKey = key.replace(`${COMMAND_ARG_CUSTOM_ID_KEY}:`, "");
|
|
data[cleanKey] = decodeCommandArgValue(value);
|
|
}
|
|
}
|
|
|
|
if (!data.command || !data.arg || !data.value || !data.user) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
command: data.command,
|
|
arg: data.arg,
|
|
value: data.value,
|
|
userId: data.user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Chunks an array into smaller arrays of a specified size
|
|
*/
|
|
function chunkArray<T>(items: T[], size: number): T[][] {
|
|
if (size <= 0) {
|
|
return [items];
|
|
}
|
|
const rows: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) {
|
|
rows.push(items.slice(i, i + size));
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Builds an argument menu with buttons for command selection
|
|
*/
|
|
export function buildCommandArgMenu(params: {
|
|
commandName: string;
|
|
arg: CommandArgDefinition;
|
|
choices: Array<{ value: string; label: string }>;
|
|
userId: string;
|
|
title?: string;
|
|
buttonsPerRow?: number;
|
|
}): CommandArgMenu {
|
|
const {
|
|
commandName,
|
|
arg,
|
|
choices,
|
|
userId,
|
|
title,
|
|
buttonsPerRow = 4,
|
|
} = params;
|
|
|
|
const rows = chunkArray(choices.slice(0, 20), buttonsPerRow).map(
|
|
(rowChoices) => ({
|
|
buttons: rowChoices.map((choice) => ({
|
|
label: choice.label.slice(0, 80),
|
|
customId: buildCommandArgCustomId({
|
|
command: commandName,
|
|
arg: arg.name,
|
|
value: choice.value,
|
|
userId,
|
|
}),
|
|
style: ButtonStyle.Secondary,
|
|
})),
|
|
}),
|
|
);
|
|
|
|
const content =
|
|
title ?? `Choose ${arg.description || arg.name} for /${commandName}.`;
|
|
|
|
return { content, rows };
|
|
}
|
|
|
|
/**
|
|
* Checks if an error is a Discord "Unknown Interaction" error
|
|
*/
|
|
export function isUnknownInteractionError(error: unknown): boolean {
|
|
if (!error || typeof error !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const err = error as {
|
|
code?: number;
|
|
status?: number;
|
|
message?: string;
|
|
rawError?: { code?: number; message?: string };
|
|
};
|
|
|
|
// Discord error code 10062 = Unknown Interaction
|
|
if (err.code === 10062 || err.rawError?.code === 10062) {
|
|
return true;
|
|
}
|
|
|
|
if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) {
|
|
return true;
|
|
}
|
|
|
|
if (/Unknown interaction/i.test(err.rawError?.message ?? "")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Safely executes a Discord interaction call, catching expired interaction errors
|
|
*/
|
|
export async function safeInteractionCall<T>(
|
|
fn: () => Promise<T>,
|
|
onExpired?: () => void,
|
|
): Promise<T | null> {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
if (isUnknownInteractionError(error)) {
|
|
onExpired?.();
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates command arguments from a single value
|
|
*/
|
|
export function createCommandArgs(
|
|
argName: string,
|
|
value: string | number | boolean,
|
|
): CommandArgs {
|
|
return {
|
|
values: { [argName]: value },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Serializes command arguments to a string
|
|
*/
|
|
export function serializeCommandArgs(args?: CommandArgs): string {
|
|
if (!args?.values) {
|
|
return "";
|
|
}
|
|
|
|
return Object.entries(args.values)
|
|
.map(([key, value]) => `${key}=${String(value)}`)
|
|
.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Builds the full command text from arguments
|
|
*/
|
|
export function buildCommandText(
|
|
commandName: string,
|
|
args?: CommandArgs,
|
|
): string {
|
|
const argsText = serializeCommandArgs(args);
|
|
return argsText ? `/${commandName} ${argsText}` : `/${commandName}`;
|
|
}
|