diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts new file mode 100644 index 0000000000..a1b77e9438 --- /dev/null +++ b/src/telegram/bot-native-command-menu.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildCappedTelegramMenuCommands, + buildPluginTelegramMenuCommands, + syncTelegramMenuCommands, +} from "./bot-native-command-menu.js"; + +describe("bot-native-command-menu", () => { + it("caps menu entries to Telegram limit", () => { + const allCommands = Array.from({ length: 105 }, (_, i) => ({ + command: `cmd_${i}`, + description: `Command ${i}`, + })); + + const result = buildCappedTelegramMenuCommands({ allCommands }); + + expect(result.commandsToRegister).toHaveLength(100); + expect(result.totalCommands).toBe(105); + expect(result.maxCommands).toBe(100); + expect(result.overflowCount).toBe(5); + expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" }); + expect(result.commandsToRegister[99]).toEqual({ + command: "cmd_99", + description: "Command 99", + }); + }); + + it("validates plugin command specs and reports conflicts", () => { + const existingCommands = new Set(["native"]); + + const result = buildPluginTelegramMenuCommands({ + specs: [ + { name: "valid", description: " Works " }, + { name: "bad-name!", description: "Bad" }, + { name: "native", description: "Conflicts with native" }, + { name: "valid", description: "Duplicate plugin name" }, + { name: "empty", description: " " }, + ], + existingCommands, + }); + + expect(result.commands).toEqual([{ command: "valid", description: "Works" }]); + expect(result.issues).toContain( + 'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).', + ); + expect(result.issues).toContain( + 'Plugin command "/native" conflicts with an existing Telegram command.', + ); + expect(result.issues).toContain('Plugin command "/valid" is duplicated.'); + expect(result.issues).toContain('Plugin command "/empty" is missing a description.'); + }); + + it("deletes stale commands before setting new menu", async () => { + const callOrder: string[] = []; + const deleteMyCommands = vi.fn(async () => { + callOrder.push("delete"); + }); + const setMyCommands = vi.fn(async () => { + callOrder.push("set"); + }); + + syncTelegramMenuCommands({ + bot: { + api: { + deleteMyCommands, + setMyCommands, + }, + } as unknown as Parameters[0]["bot"], + runtime: {} as Parameters[0]["runtime"], + commandsToRegister: [{ command: "cmd", description: "Command" }], + }); + + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalled(); + }); + + expect(callOrder).toEqual(["delete", "set"]); + }); +}); diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts new file mode 100644 index 0000000000..98fcf1e0d3 --- /dev/null +++ b/src/telegram/bot-native-command-menu.ts @@ -0,0 +1,104 @@ +import type { Bot } from "grammy"; +import type { RuntimeEnv } from "../runtime.js"; +import { + normalizeTelegramCommandName, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../config/telegram-custom-commands.js"; +import { withTelegramApiErrorLogging } from "./api-logging.js"; + +export const TELEGRAM_MAX_COMMANDS = 100; + +export type TelegramMenuCommand = { + command: string; + description: string; +}; + +type TelegramPluginCommandSpec = { + name: string; + description: string; +}; + +export function buildPluginTelegramMenuCommands(params: { + specs: TelegramPluginCommandSpec[]; + existingCommands: Set; +}): { commands: TelegramMenuCommand[]; issues: string[] } { + const { specs, existingCommands } = params; + const commands: TelegramMenuCommand[] = []; + const issues: string[] = []; + const pluginCommandNames = new Set(); + + for (const spec of specs) { + const normalized = normalizeTelegramCommandName(spec.name); + if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + issues.push( + `Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`, + ); + continue; + } + const description = spec.description.trim(); + if (!description) { + issues.push(`Plugin command "/${normalized}" is missing a description.`); + continue; + } + if (existingCommands.has(normalized)) { + if (pluginCommandNames.has(normalized)) { + issues.push(`Plugin command "/${normalized}" is duplicated.`); + } else { + issues.push(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`); + } + continue; + } + pluginCommandNames.add(normalized); + existingCommands.add(normalized); + commands.push({ command: normalized, description }); + } + + return { commands, issues }; +} + +export function buildCappedTelegramMenuCommands(params: { + allCommands: TelegramMenuCommand[]; + maxCommands?: number; +}): { + commandsToRegister: TelegramMenuCommand[]; + totalCommands: number; + maxCommands: number; + overflowCount: number; +} { + const { allCommands } = params; + const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS; + const totalCommands = allCommands.length; + const overflowCount = Math.max(0, totalCommands - maxCommands); + const commandsToRegister = allCommands.slice(0, maxCommands); + return { commandsToRegister, totalCommands, maxCommands, overflowCount }; +} + +export function syncTelegramMenuCommands(params: { + bot: Bot; + runtime: RuntimeEnv; + commandsToRegister: TelegramMenuCommand[]; +}): void { + const { bot, runtime, commandsToRegister } = params; + const sync = async () => { + // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. + if (typeof bot.api.deleteMyCommands === "function") { + await withTelegramApiErrorLogging({ + operation: "deleteMyCommands", + runtime, + fn: () => bot.api.deleteMyCommands(), + }).catch(() => {}); + } + + if (commandsToRegister.length === 0) { + return; + } + + await withTelegramApiErrorLogging({ + operation: "setMyCommands", + runtime, + fn: () => bot.api.setMyCommands(commandsToRegister), + }); + }; + + void sync().catch(() => {}); +} diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 9d154ccc53..2c0ce29d71 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -112,7 +112,7 @@ describe("registerTelegramNativeCommands", () => { expect(registeredCommands).toHaveLength(100); expect(registeredCommands).toEqual(customCommands.slice(0, 100)); expect(runtimeLog).toHaveBeenCalledWith( - "Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce skill/custom commands.", + "Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.", ); }); }); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 38266f4f03..c6c6aea4f3 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -26,10 +26,6 @@ import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gat import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; -import { - normalizeTelegramCommandName, - TELEGRAM_COMMAND_NAME_PATTERN, -} from "../config/telegram-custom-commands.js"; import { danger, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; @@ -42,6 +38,11 @@ import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js"; +import { + buildCappedTelegramMenuCommands, + buildPluginTelegramMenuCommands, + syncTelegramMenuCommands, +} from "./bot-native-command-menu.js"; import { TelegramUpdateKeyContext } from "./bot-updates.js"; import { TelegramBotOptions } from "./bot.js"; import { deliverReplies } from "./bot/delivery.js"; @@ -321,86 +322,41 @@ export const registerTelegramNativeCommands = ({ } const customCommands = customResolution.commands; const pluginCommandSpecs = getPluginCommandSpecs(); - const pluginCommands: Array<{ command: string; description: string }> = []; const existingCommands = new Set( [ ...nativeCommands.map((command) => command.name), ...customCommands.map((command) => command.command), ].map((command) => command.toLowerCase()), ); - const pluginCommandNames = new Set(); - for (const spec of pluginCommandSpecs) { - const normalized = normalizeTelegramCommandName(spec.name); - if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { - runtime.error?.( - danger( - `Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`, - ), - ); - continue; - } - const description = spec.description.trim(); - if (!description) { - runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`)); - continue; - } - if (existingCommands.has(normalized)) { - runtime.error?.( - danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`), - ); - continue; - } - if (pluginCommandNames.has(normalized)) { - runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`)); - continue; - } - pluginCommandNames.add(normalized); - existingCommands.add(normalized); - pluginCommands.push({ command: normalized, description }); + const pluginCatalog = buildPluginTelegramMenuCommands({ + specs: pluginCommandSpecs, + existingCommands, + }); + for (const issue of pluginCatalog.issues) { + runtime.error?.(danger(issue)); } const allCommandsFull: Array<{ command: string; description: string }> = [ ...nativeCommands.map((command) => ({ command: command.name, description: command.description, })), - ...pluginCommands, + ...pluginCatalog.commands, ...customCommands, ]; - const TELEGRAM_MAX_COMMANDS = 100; - if (allCommandsFull.length > TELEGRAM_MAX_COMMANDS) { + const { commandsToRegister, totalCommands, maxCommands, overflowCount } = + buildCappedTelegramMenuCommands({ + allCommands: allCommandsFull, + }); + if (overflowCount > 0) { runtime.log?.( - `Telegram limits bots to ${TELEGRAM_MAX_COMMANDS} commands. ` + - `${allCommandsFull.length} configured; registering first ${TELEGRAM_MAX_COMMANDS}. ` + - `Use channels.telegram.commands.native: false to disable, or reduce skill/custom commands.`, + `Telegram limits bots to ${maxCommands} commands. ` + + `${totalCommands} configured; registering first ${maxCommands}. ` + + `Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`, ); } // Telegram only limits the setMyCommands payload (menu entries). - const commandsToRegister = allCommandsFull.slice(0, TELEGRAM_MAX_COMMANDS); - - // Clear stale commands before registering new ones to prevent - // leftover commands from deleted skills persisting across restarts (#5717). - // Chain delete → set so a late-resolving delete cannot wipe newly registered commands. - const registerCommands = () => { - if (commandsToRegister.length > 0) { - withTelegramApiErrorLogging({ - operation: "setMyCommands", - runtime, - fn: () => bot.api.setMyCommands(commandsToRegister), - }).catch(() => {}); - } - }; - if (typeof bot.api.deleteMyCommands === "function") { - withTelegramApiErrorLogging({ - operation: "deleteMyCommands", - runtime, - fn: () => bot.api.deleteMyCommands(), - }) - .catch(() => {}) - .then(registerCommands) - .catch(() => {}); - } else { - registerCommands(); - } + // Keep hidden commands callable by registering handlers for the full catalog. + syncTelegramMenuCommands({ bot, runtime, commandsToRegister }); if (commandsToRegister.length > 0) { if (typeof (bot as unknown as { command?: unknown }).command !== "function") { @@ -643,7 +599,7 @@ export const registerTelegramNativeCommands = ({ }); } - for (const pluginCommand of pluginCommands) { + for (const pluginCommand of pluginCatalog.commands) { bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) {