fix: trim menu descriptions before dropping commands (#61129) (thanks @neeravmakwana)

* fix(telegram): trim menu descriptions before dropping commands

* fix: note Telegram command menu trimming (#61129) (thanks @neeravmakwana)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Neerav Makwana
2026-04-04 22:35:16 -04:00
committed by GitHub
parent f217e6b72d
commit 22175faaec
5 changed files with 190 additions and 6 deletions

View File

@@ -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

View File

@@ -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"]);

View File

@@ -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. */

View File

@@ -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<typeof registerTelegramNativeCommands>[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();

View File

@@ -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).