import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { listNativeCommandSpecsForConfig, type NativeCommandSpec, } from "openclaw/plugin-sdk/native-command-registry"; import type { createClickClackClient } from "./http-client.js"; import type { CoreConfig } from "./types.js"; const CLICKCLACK_COMMAND_PATTERN = /^[a-z0-9_-]{1,32}$/u; const CLICKCLACK_MAX_COMMANDS = 100; const CLICKCLACK_MAX_DESCRIPTION_LENGTH = 100; const CLICKCLACK_MAX_ARGS_HINT_LENGTH = 100; type ClickClackCommandMenuEntry = { command: string; description: string; args_hint: string; }; type ClickClackCommandMenuLogger = { debug?: (message: string) => void; warn?: (message: string) => void; }; function truncateCodePoints(value: string, maxLength: number): string { return Array.from(value).slice(0, maxLength).join(""); } function commandArgsHint(spec: NativeCommandSpec): string { if (spec.args?.length) { return truncateCodePoints( spec.args.map((arg) => (arg.required ? `<${arg.name}>` : `[${arg.name}]`)).join(" "), CLICKCLACK_MAX_ARGS_HINT_LENGTH, ); } return spec.acceptsArgs ? "[args]" : ""; } function errorStatus(error: unknown): number | undefined { if (!error || typeof error !== "object" || !("status" in error)) { return undefined; } const status = (error as { status?: unknown }).status; return typeof status === "number" ? status : undefined; } function mapNativeCommandSpecsToClickClackMenu( specs: NativeCommandSpec[], log?: ClickClackCommandMenuLogger, ): ClickClackCommandMenuEntry[] { const commands: ClickClackCommandMenuEntry[] = []; const seen = new Set(); const invalidNames = new Set(); for (const spec of specs) { if (spec.isAlias) { continue; } const command = spec.name.trim().toLowerCase(); if (!CLICKCLACK_COMMAND_PATTERN.test(command)) { invalidNames.add(spec.name.trim() || ""); continue; } if (seen.has(command)) { continue; } seen.add(command); const description = spec.description.trim() || command; commands.push({ command, description: truncateCodePoints(description, CLICKCLACK_MAX_DESCRIPTION_LENGTH), args_hint: commandArgsHint(spec), }); } if (invalidNames.size > 0) { log?.warn?.( `ClickClack command menu skipped invalid native command names: ${[...invalidNames] .map((name) => JSON.stringify(name)) .join(", ")}`, ); } return commands.slice(0, CLICKCLACK_MAX_COMMANDS); } export async function syncClickClackCommandMenu(params: { accountId: string; cfg: CoreConfig; client: ReturnType; log?: ClickClackCommandMenuLogger; }): Promise { try { // Native specs are the Phase 7c scope. Skill, plugin, and custom command // catalogs can be added later when their ClickClack semantics are defined. const specs = listNativeCommandSpecsForConfig(params.cfg, { provider: "clickclack" }); const commands = mapNativeCommandSpecsToClickClackMenu(specs, params.log); await params.client.setBotCommands(commands); } catch (error) { const status = errorStatus(error); const messagePrefix = `[${params.accountId}] ClickClack command menu sync`; if (status === 403) { params.log?.warn?.( `${messagePrefix} skipped: ${formatErrorMessage(error)}; verify token/workspace command permissions or set commandMenu: false if menus are not needed`, ); return; } if (status === 404) { params.log?.debug?.( `${messagePrefix} skipped: server does not support /api/bots/self/commands`, ); return; } params.log?.warn?.(`${messagePrefix} failed: ${formatErrorMessage(error)}`); } }