fix: enforce Telegram 100-command limit with warning (#5787) (#15844)

* fix: enforce Telegram 100-command limit with warning (#5787)

Telegram's setMyCommands API rejects requests with more than 100 commands.
When skills + custom + plugin commands exceed the limit, truncate to 100
and warn the user instead of silently failing on every startup.

* fix: enforce Telegram menu cap + keep hidden commands callable (#15844) (thanks @battman21)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Owen
2026-02-14 11:51:00 +11:00
committed by GitHub
parent aa6d8b27ac
commit 11ab1c6937
4 changed files with 71 additions and 8 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.

View File

@@ -23,6 +23,67 @@ vi.mock("../pairing/pairing-store.js", () => ({
}));
describe("registerTelegramNativeCommands (plugin auth)", () => {
it("caps menu registration at 100 while leaving hidden plugin handlers available", () => {
const specs = Array.from({ length: 101 }, (_, i) => ({
name: `cmd_${i}`,
description: `Command ${i}`,
}));
getPluginCommandSpecs.mockReturnValue(specs);
matchPluginCommand.mockReset();
executePluginCommand.mockReset();
deliverReplies.mockReset();
const handlers: Record<string, (ctx: unknown) => Promise<void>> = {};
const setMyCommands = vi.fn().mockResolvedValue(undefined);
const log = vi.fn();
const bot = {
api: {
setMyCommands,
sendMessage: vi.fn(),
},
command: (name: string, handler: (ctx: unknown) => Promise<void>) => {
handlers[name] = handler;
},
} as const;
registerTelegramNativeCommands({
bot: bot as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
cfg: {} as OpenClawConfig,
runtime: { log } as RuntimeEnv,
accountId: "default",
telegramCfg: {} as TelegramAccountConfig,
allowFrom: [],
groupAllowFrom: [],
replyToMode: "off",
textLimit: 4000,
useAccessGroups: false,
nativeEnabled: false,
nativeSkillsEnabled: false,
nativeDisabledExplicit: false,
resolveGroupPolicy: () =>
({
allowlistEnabled: false,
allowed: true,
}) as ChannelGroupPolicy,
resolveTelegramGroupConfig: () => ({
groupConfig: undefined,
topicConfig: undefined,
}),
shouldSkipUpdate: () => false,
opts: { token: "token" },
});
const registered = setMyCommands.mock.calls[0]?.[0] as Array<{
command: string;
description: string;
}>;
expect(registered).toHaveLength(100);
expect(registered[0]).toEqual({ command: "cmd_0", description: "Command 0" });
expect(registered[99]).toEqual({ command: "cmd_99", description: "Command 99" });
expect(log).toHaveBeenCalledWith(expect.stringContaining("registering first 100"));
expect(Object.keys(handlers)).toHaveLength(101);
});
it("allows requireAuth:false plugin command even when sender is unauthorized", async () => {
const command = {
name: "plugin",

View File

@@ -112,7 +112,7 @@ describe("registerTelegramNativeCommands", () => {
expect(registeredCommands).toHaveLength(100);
expect(registeredCommands).toEqual(customCommands.slice(0, 100));
expect(runtimeLog).toHaveBeenCalledWith(
"telegram: truncating 120 commands to 100 (Telegram Bot API limit)",
"Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce skill/custom commands.",
);
});
});

View File

@@ -366,25 +366,26 @@ export const registerTelegramNativeCommands = ({
...pluginCommands,
...customCommands,
];
// Telegram Bot API limits commands to 100 per scope.
// Truncate with a warning rather than failing with BOT_COMMANDS_TOO_MUCH.
const TELEGRAM_MAX_COMMANDS = 100;
if (allCommandsFull.length > TELEGRAM_MAX_COMMANDS) {
runtime.log?.(
`telegram: truncating ${allCommandsFull.length} commands to ${TELEGRAM_MAX_COMMANDS} (Telegram Bot API limit)`,
`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.`,
);
}
const allCommands = allCommandsFull.slice(0, TELEGRAM_MAX_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 (allCommands.length > 0) {
if (commandsToRegister.length > 0) {
withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands(allCommands),
fn: () => bot.api.setMyCommands(commandsToRegister),
}).catch(() => {});
}
};
@@ -401,7 +402,7 @@ export const registerTelegramNativeCommands = ({
registerCommands();
}
if (allCommands.length > 0) {
if (commandsToRegister.length > 0) {
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
} else {