diff --git a/CHANGELOG.md b/CHANGELOG.md index af662e9b6d7..f5dd4304a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts index 7572279b5c2..cabd3338019 100644 --- a/src/telegram/bot-native-commands.plugin-auth.test.ts +++ b/src/telegram/bot-native-commands.plugin-auth.test.ts @@ -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 Promise> = {}; + const setMyCommands = vi.fn().mockResolvedValue(undefined); + const log = vi.fn(); + const bot = { + api: { + setMyCommands, + sendMessage: vi.fn(), + }, + command: (name: string, handler: (ctx: unknown) => Promise) => { + handlers[name] = handler; + }, + } as const; + + registerTelegramNativeCommands({ + bot: bot as unknown as Parameters[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", diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 48594c1e262..9d154ccc53b 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -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.", ); }); }); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 3983af3691b..38266f4f035 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -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 {