diff --git a/docs/plugin.md b/docs/plugin.md index ee9dfd8b0..56781d16a 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -551,6 +551,7 @@ Notes: - Commands are registered globally and work across all channels - Command names are case-insensitive (`/MyStatus` matches `/mystatus`) - Command names must start with a letter and contain only letters, numbers, hyphens, and underscores +- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegram’s native command list. - Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins - Duplicate command registration across plugins will fail with a diagnostic error diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index be51d5544..b374bad11 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -8,6 +8,7 @@ import { listChatCommandsForConfig, listNativeCommandSpecs, listNativeCommandSpecsForConfig, + normalizeNativeCommandSpecsForSurface, normalizeCommandBody, parseCommandArgs, resolveCommandArgMenu, @@ -45,6 +46,20 @@ describe("commands registry", () => { expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); }); + it("normalizes telegram native command specs", () => { + const specs = [ + { name: "OK", description: "Ok", acceptsArgs: false }, + { name: "bad-name", description: "Bad", acceptsArgs: false }, + { name: "fine_name", description: "Fine", acceptsArgs: false }, + { name: "ok", description: "Dup", acceptsArgs: false }, + ]; + const normalized = normalizeNativeCommandSpecsForSurface({ + surface: "telegram", + specs, + }); + expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]); + }); + it("filters commands based on config flags", () => { const disabled = listChatCommandsForConfig({ commands: { config: false, debug: false }, diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index eb191a24f..e980bbb8c 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -4,6 +4,10 @@ import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.d import { getPluginCommandSpecs } from "../plugins/commands.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { + normalizeTelegramCommandName, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../config/telegram-custom-commands.js"; import type { ChatCommandDefinition, CommandArgChoiceContext, @@ -143,6 +147,37 @@ export function listNativeCommandSpecsForConfig( return extras.length > 0 ? [...base, ...extras] : base; } +function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (surface === "telegram") { + const normalized = normalizeTelegramCommandName(trimmed); + if (!normalized) return null; + if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null; + return normalized; + } + return trimmed; +} + +export function normalizeNativeCommandSpecsForSurface(params: { + surface: string; + specs: NativeCommandSpec[]; +}): NativeCommandSpec[] { + const surface = params.surface.toLowerCase(); + if (!surface) return params.specs; + const normalized: NativeCommandSpec[] = []; + const seen = new Set(); + for (const spec of params.specs) { + const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface); + if (!normalizedName) continue; + const key = normalizedName.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName }); + } + return normalized; +} + export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined { const normalized = name.trim().toLowerCase(); return getChatCommands().find( diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index c3d3a7b74..45a8fa30d 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -6,6 +6,7 @@ import { findCommandByNativeName, listNativeCommandSpecs, listNativeCommandSpecsForConfig, + normalizeNativeCommandSpecsForSurface, parseCommandArgs, resolveCommandArgMenu, } from "../auto-reply/commands-registry.js"; @@ -84,13 +85,28 @@ export const registerTelegramNativeCommands = ({ }: RegisterTelegramNativeCommandsParams) => { const skillCommands = nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; - const nativeCommands = nativeEnabled + const rawNativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : []; + const nativeCommands = normalizeNativeCommandSpecsForSurface({ + surface: "telegram", + specs: rawNativeCommands, + }); const reservedCommands = new Set( - listNativeCommandSpecs().map((command) => command.name.toLowerCase()), + normalizeNativeCommandSpecsForSurface({ + surface: "telegram", + specs: listNativeCommandSpecs(), + }).map((command) => command.name.toLowerCase()), ); - for (const command of skillCommands) { + const reservedSkillSpecs = normalizeNativeCommandSpecsForSurface({ + surface: "telegram", + specs: skillCommands.map((command) => ({ + name: command.name, + description: command.description, + acceptsArgs: true, + })), + }); + for (const command of reservedSkillSpecs) { reservedCommands.add(command.name.toLowerCase()); } const customResolution = resolveTelegramCustomCommands({