mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor(telegram): extract native command menu helpers
This commit is contained in:
79
src/telegram/bot-native-command-menu.test.ts
Normal file
79
src/telegram/bot-native-command-menu.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
104
src/telegram/bot-native-command-menu.ts
Normal file
104
src/telegram/bot-native-command-menu.ts
Normal 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(() => {});
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user