diff --git a/CHANGELOG.md b/CHANGELOG.md index 8faa6b7d777..15f5ba55836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai - Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm. - MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys. - CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010. +- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana. ## 2026.4.2 diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 52ee12f3f24..7cf8027de0f 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -4,6 +4,7 @@ import { buildPluginTelegramMenuCommands, hashCommandList, syncTelegramMenuCommands, + TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET, } from "./bot-native-command-menu.js"; type SyncMenuOptions = { @@ -52,6 +53,47 @@ describe("bot-native-command-menu", () => { }); }); + it("shortens descriptions before dropping commands to fit Telegram payload budget", () => { + const allCommands = Array.from({ length: 92 }, (_, i) => ({ + command: `cmd_${i}`, + description: "x".repeat(100), + })); + + const result = buildCappedTelegramMenuCommands({ allCommands }); + + expect(result.commandsToRegister).toHaveLength(92); + expect(result.descriptionTrimmed).toBe(true); + expect(result.textBudgetDropCount).toBe(0); + const totalText = result.commandsToRegister.reduce( + (total, command) => total + command.command.length + command.description.length, + 0, + ); + expect(totalText).toBeLessThanOrEqual(TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET); + expect(result.commandsToRegister.every((command) => command.description.length <= 56)).toBe( + true, + ); + }); + + it("drops tail commands only when minimal descriptions still cannot fit the payload budget", () => { + const allCommands = [ + { command: "alpha_cmd", description: "First command" }, + { command: "bravo_cmd", description: "Second command" }, + { command: "charlie_cmd", description: "Third command" }, + ]; + + const result = buildCappedTelegramMenuCommands({ + allCommands, + maxTotalChars: 20, + }); + + expect(result.commandsToRegister).toEqual([ + { command: "alpha_cmd", description: "F" }, + { command: "bravo_cmd", description: "S" }, + ]); + expect(result.descriptionTrimmed).toBe(true); + expect(result.textBudgetDropCount).toBe(1); + }); + it("validates plugin command specs and reports conflicts", () => { const existingCommands = new Set(["native"]); diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts index e39c48b7c2a..e4ed748fe88 100644 --- a/extensions/telegram/src/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -10,7 +10,9 @@ import { withTelegramApiErrorLogging } from "./api-logging.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; export const TELEGRAM_MAX_COMMANDS = 100; +export const TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET = 5700; const TELEGRAM_COMMAND_RETRY_RATIO = 0.8; +const TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH = 1; export type TelegramMenuCommand = { command: string; @@ -22,6 +24,73 @@ type TelegramPluginCommandSpec = { description: unknown; }; +function countTelegramCommandText(value: string): number { + return Array.from(value).length; +} + +function truncateTelegramCommandText(value: string, maxLength: number): string { + if (maxLength <= 0) { + return ""; + } + const chars = Array.from(value); + if (chars.length <= maxLength) { + return value; + } + if (maxLength === 1) { + return chars[0] ?? ""; + } + return `${chars.slice(0, maxLength - 1).join("")}…`; +} + +function fitTelegramCommandsWithinTextBudget( + commands: TelegramMenuCommand[], + maxTotalChars: number, +): { + commands: TelegramMenuCommand[]; + descriptionTrimmed: boolean; + textBudgetDropCount: number; +} { + let candidateCommands = [...commands]; + while (candidateCommands.length > 0) { + const commandNameChars = candidateCommands.reduce( + (total, command) => total + countTelegramCommandText(command.command), + 0, + ); + const descriptionBudget = maxTotalChars - commandNameChars; + const minimumDescriptionBudget = + candidateCommands.length * TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH; + if (descriptionBudget < minimumDescriptionBudget) { + candidateCommands = candidateCommands.slice(0, -1); + continue; + } + + const descriptionCap = Math.max( + TELEGRAM_MIN_COMMAND_DESCRIPTION_LENGTH, + Math.floor(descriptionBudget / candidateCommands.length), + ); + let descriptionTrimmed = false; + const fittedCommands = candidateCommands.map((command) => { + const description = truncateTelegramCommandText(command.description, descriptionCap); + if (description !== command.description) { + descriptionTrimmed = true; + return { ...command, description }; + } + return command; + }); + return { + commands: fittedCommands, + descriptionTrimmed, + textBudgetDropCount: commands.length - fittedCommands.length, + }; + } + + return { + commands: [], + descriptionTrimmed: false, + textBudgetDropCount: commands.length, + }; +} + function readErrorTextField(value: unknown, key: "description" | "message"): string | undefined { if (!value || typeof value !== "object" || !(key in value)) { return undefined; @@ -109,18 +178,35 @@ export function buildPluginTelegramMenuCommands(params: { export function buildCappedTelegramMenuCommands(params: { allCommands: TelegramMenuCommand[]; maxCommands?: number; + maxTotalChars?: number; }): { commandsToRegister: TelegramMenuCommand[]; totalCommands: number; maxCommands: number; overflowCount: number; + maxTotalChars: number; + descriptionTrimmed: boolean; + textBudgetDropCount: number; } { const { allCommands } = params; const maxCommands = params.maxCommands ?? TELEGRAM_MAX_COMMANDS; + const maxTotalChars = params.maxTotalChars ?? TELEGRAM_TOTAL_COMMAND_TEXT_BUDGET; const totalCommands = allCommands.length; const overflowCount = Math.max(0, totalCommands - maxCommands); - const commandsToRegister = allCommands.slice(0, maxCommands); - return { commandsToRegister, totalCommands, maxCommands, overflowCount }; + const { + commands: commandsToRegister, + descriptionTrimmed, + textBudgetDropCount, + } = fitTelegramCommandsWithinTextBudget(allCommands.slice(0, maxCommands), maxTotalChars); + return { + commandsToRegister, + totalCommands, + maxCommands, + overflowCount, + maxTotalChars, + descriptionTrimmed, + textBudgetDropCount, + }; } /** Compute a stable hash of the command list for change detection. */ diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index e10e2d13b62..0738fc27c6c 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -101,6 +101,44 @@ describe("registerTelegramNativeCommands", () => { ); }); + it("keeps sub-100 commands by shortening long descriptions to fit Telegram payload budget", async () => { + const cfg: OpenClawConfig = { + commands: { native: false }, + }; + const customCommands = Array.from({ length: 92 }, (_, index) => ({ + command: `cmd_${index}`, + description: `Command ${index} ` + "x".repeat(120), + })); + const setMyCommands = vi.fn().mockResolvedValue(undefined); + const runtimeLog = vi.fn(); + + registerTelegramNativeCommands({ + ...createNativeCommandTestParams(cfg), + bot: { + api: { + setMyCommands, + sendMessage: vi.fn().mockResolvedValue(undefined), + }, + command: vi.fn(), + } as unknown as Parameters[0]["bot"], + runtime: { log: runtimeLog } as unknown as RuntimeEnv, + telegramCfg: { customCommands } as TelegramAccountConfig, + nativeEnabled: false, + nativeSkillsEnabled: false, + }); + + const registeredCommands = await waitForRegisteredCommands(setMyCommands); + expect(registeredCommands).toHaveLength(92); + expect( + registeredCommands.some( + (entry) => entry.description.length < customCommands[0]!.description.length, + ), + ).toBe(true); + expect(runtimeLog).toHaveBeenCalledWith( + "Telegram menu text exceeded the conservative 5700-character payload budget; shortening descriptions to keep 92 commands visible.", + ); + }); + it("normalizes hyphenated native command names for Telegram registration", async () => { const setMyCommands = vi.fn().mockResolvedValue(undefined); const command = vi.fn(); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 7e58eda9100..08e5ba22fc9 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -560,10 +560,17 @@ export const registerTelegramNativeCommands = ({ ...(nativeEnabled ? pluginCatalog.commands : []), ...customCommands, ]; - const { commandsToRegister, totalCommands, maxCommands, overflowCount } = - buildCappedTelegramMenuCommands({ - allCommands: allCommandsFull, - }); + const { + commandsToRegister, + totalCommands, + maxCommands, + overflowCount, + maxTotalChars, + descriptionTrimmed, + textBudgetDropCount, + } = buildCappedTelegramMenuCommands({ + allCommands: allCommandsFull, + }); if (overflowCount > 0) { runtime.log?.( `Telegram limits bots to ${maxCommands} commands. ` + @@ -571,6 +578,16 @@ export const registerTelegramNativeCommands = ({ `Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.`, ); } + if (descriptionTrimmed) { + runtime.log?.( + `Telegram menu text exceeded the conservative ${maxTotalChars}-character payload budget; shortening descriptions to keep ${commandsToRegister.length} commands visible.`, + ); + } + if (textBudgetDropCount > 0) { + runtime.log?.( + `Telegram menu text still exceeded the conservative ${maxTotalChars}-character payload budget after shortening descriptions; registering first ${commandsToRegister.length} commands.`, + ); + } const syncTelegramMenuCommands = telegramDeps.syncTelegramMenuCommands ?? syncTelegramMenuCommandsRuntime; // Telegram only limits the setMyCommands payload (menu entries).