mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 07:01:40 +03:00
refactor(telegram): extract native command menu helpers
This commit is contained in:
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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).toHaveLength(100);
|
||||||
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
|
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
|
||||||
expect(runtimeLog).toHaveBeenCalledWith(
|
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 { createReplyPrefixOptions } from "../channels/reply-prefix.js";
|
||||||
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
|
||||||
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.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 { danger, logVerbose } from "../globals.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.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 { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||||
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.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 { TelegramUpdateKeyContext } from "./bot-updates.js";
|
||||||
import { TelegramBotOptions } from "./bot.js";
|
import { TelegramBotOptions } from "./bot.js";
|
||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
@@ -321,86 +322,41 @@ export const registerTelegramNativeCommands = ({
|
|||||||
}
|
}
|
||||||
const customCommands = customResolution.commands;
|
const customCommands = customResolution.commands;
|
||||||
const pluginCommandSpecs = getPluginCommandSpecs();
|
const pluginCommandSpecs = getPluginCommandSpecs();
|
||||||
const pluginCommands: Array<{ command: string; description: string }> = [];
|
|
||||||
const existingCommands = new Set(
|
const existingCommands = new Set(
|
||||||
[
|
[
|
||||||
...nativeCommands.map((command) => command.name),
|
...nativeCommands.map((command) => command.name),
|
||||||
...customCommands.map((command) => command.command),
|
...customCommands.map((command) => command.command),
|
||||||
].map((command) => command.toLowerCase()),
|
].map((command) => command.toLowerCase()),
|
||||||
);
|
);
|
||||||
const pluginCommandNames = new Set<string>();
|
const pluginCatalog = buildPluginTelegramMenuCommands({
|
||||||
for (const spec of pluginCommandSpecs) {
|
specs: pluginCommandSpecs,
|
||||||
const normalized = normalizeTelegramCommandName(spec.name);
|
existingCommands,
|
||||||
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
});
|
||||||
runtime.error?.(
|
for (const issue of pluginCatalog.issues) {
|
||||||
danger(
|
runtime.error?.(danger(issue));
|
||||||
`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 allCommandsFull: Array<{ command: string; description: string }> = [
|
const allCommandsFull: Array<{ command: string; description: string }> = [
|
||||||
...nativeCommands.map((command) => ({
|
...nativeCommands.map((command) => ({
|
||||||
command: command.name,
|
command: command.name,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
})),
|
})),
|
||||||
...pluginCommands,
|
...pluginCatalog.commands,
|
||||||
...customCommands,
|
...customCommands,
|
||||||
];
|
];
|
||||||
const TELEGRAM_MAX_COMMANDS = 100;
|
const { commandsToRegister, totalCommands, maxCommands, overflowCount } =
|
||||||
if (allCommandsFull.length > TELEGRAM_MAX_COMMANDS) {
|
buildCappedTelegramMenuCommands({
|
||||||
|
allCommands: allCommandsFull,
|
||||||
|
});
|
||||||
|
if (overflowCount > 0) {
|
||||||
runtime.log?.(
|
runtime.log?.(
|
||||||
`Telegram limits bots to ${TELEGRAM_MAX_COMMANDS} commands. ` +
|
`Telegram limits bots to ${maxCommands} commands. ` +
|
||||||
`${allCommandsFull.length} configured; registering first ${TELEGRAM_MAX_COMMANDS}. ` +
|
`${totalCommands} configured; registering first ${maxCommands}. ` +
|
||||||
`Use channels.telegram.commands.native: false to disable, or reduce skill/custom commands.`,
|
`Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Telegram only limits the setMyCommands payload (menu entries).
|
// Telegram only limits the setMyCommands payload (menu entries).
|
||||||
const commandsToRegister = allCommandsFull.slice(0, TELEGRAM_MAX_COMMANDS);
|
// Keep hidden commands callable by registering handlers for the full catalog.
|
||||||
|
syncTelegramMenuCommands({ bot, runtime, commandsToRegister });
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandsToRegister.length > 0) {
|
if (commandsToRegister.length > 0) {
|
||||||
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
|
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) => {
|
bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => {
|
||||||
const msg = ctx.message;
|
const msg = ctx.message;
|
||||||
if (!msg) {
|
if (!msg) {
|
||||||
|
|||||||
Reference in New Issue
Block a user