mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 22:32:12 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user