refactor(telegram): extract native command menu helpers

This commit is contained in:
Peter Steinberger
2026-02-14 02:02:44 +01:00
parent 2e84ae7019
commit cc2249a431
4 changed files with 207 additions and 68 deletions

View File

@@ -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<typeof syncTelegramMenuCommands>[0]["bot"],
runtime: {} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
commandsToRegister: [{ command: "cmd", description: "Command" }],
});
await vi.waitFor(() => {
expect(setMyCommands).toHaveBeenCalled();
});
expect(callOrder).toEqual(["delete", "set"]);
});
});

View File

@@ -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<string>;
}): { commands: TelegramMenuCommand[]; issues: string[] } {
const { specs, existingCommands } = params;
const commands: TelegramMenuCommand[] = [];
const issues: string[] = [];
const pluginCommandNames = new Set<string>();
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(() => {});
}

View File

@@ -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.",
);
});
});

View File

@@ -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<string>();
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) {