From 10fb632c9e419c648fc72c8e0925e66cb91273d4 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Mon, 2 Mar 2026 10:58:48 -0800 Subject: [PATCH] fix: skip Telegram command sync when menu is unchanged (#32017) Hash the command list and cache it to disk per account. On restart, compare the current hash against the cached one and skip the deleteMyCommands + setMyCommands round-trip when nothing changed. This prevents 429 rate-limit errors when the gateway restarts several times in quick succession. Co-Authored-By: Claude Opus 4.6 --- src/telegram/bot-native-command-menu.test.ts | 61 ++++++++++++++++++++ src/telegram/bot-native-command-menu.ts | 55 +++++++++++++++++- src/telegram/bot-native-commands.ts | 2 +- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index 8077c4244..4f9150d7d 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildCappedTelegramMenuCommands, buildPluginTelegramMenuCommands, + hashCommandList, syncTelegramMenuCommands, } from "./bot-native-command-menu.js"; @@ -108,6 +109,66 @@ describe("bot-native-command-menu", () => { expect(callOrder).toEqual(["delete", "set"]); }); + it("produces a stable hash regardless of command order (#32017)", () => { + const commands = [ + { command: "bravo", description: "B" }, + { command: "alpha", description: "A" }, + ]; + const reversed = [...commands].toReversed(); + expect(hashCommandList(commands)).toBe(hashCommandList(reversed)); + }); + + it("produces different hashes for different command lists (#32017)", () => { + const a = [{ command: "alpha", description: "A" }]; + const b = [{ command: "alpha", description: "Changed" }]; + expect(hashCommandList(a)).not.toBe(hashCommandList(b)); + }); + + it("skips sync when command hash is unchanged (#32017)", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const runtimeLog = vi.fn(); + + // Use a unique accountId so cached hashes from other tests don't interfere. + const accountId = `test-skip-${Date.now()}`; + const commands = [{ command: "skip_test", description: "Skip test command" }]; + + // First sync — no cached hash, should call setMyCommands. + syncTelegramMenuCommands({ + bot: { + api: { deleteMyCommands, setMyCommands }, + } as unknown as Parameters[0]["bot"], + runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters< + typeof syncTelegramMenuCommands + >[0]["runtime"], + commandsToRegister: commands, + accountId, + }); + + await vi.waitFor(() => { + expect(setMyCommands).toHaveBeenCalledTimes(1); + }); + + // Second sync with the same commands — hash is cached, should skip. + syncTelegramMenuCommands({ + bot: { + api: { deleteMyCommands, setMyCommands }, + } as unknown as Parameters[0]["bot"], + runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() } as Parameters< + typeof syncTelegramMenuCommands + >[0]["runtime"], + commandsToRegister: commands, + accountId, + }); + + await vi.waitFor(() => { + expect(runtimeLog).toHaveBeenCalledWith("telegram: command menu unchanged; skipping sync"); + }); + + // setMyCommands should NOT have been called a second time. + expect(setMyCommands).toHaveBeenCalledTimes(1); + }); + it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => { const deleteMyCommands = vi.fn(async () => undefined); const setMyCommands = vi diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index ab41a359c..88a7c1047 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -1,4 +1,9 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { Bot } from "grammy"; +import { resolveStateDir } from "../config/paths.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN, @@ -101,13 +106,59 @@ export function buildCappedTelegramMenuCommands(params: { return { commandsToRegister, totalCommands, maxCommands, overflowCount }; } +/** Compute a stable hash of the command list for change detection. */ +export function hashCommandList(commands: TelegramMenuCommand[]): string { + const sorted = [...commands].toSorted((a, b) => a.command.localeCompare(b.command)); + return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16); +} + +function resolveCommandHashPath(accountId?: string): string { + const stateDir = resolveStateDir(process.env, os.homedir); + const normalized = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default"; + return path.join(stateDir, "telegram", `command-hash-${normalized}.txt`); +} + +async function readCachedCommandHash(accountId?: string): Promise { + try { + return (await fs.readFile(resolveCommandHashPath(accountId), "utf-8")).trim(); + } catch { + return null; + } +} + +async function writeCachedCommandHash(accountId?: string, hash?: string): Promise { + if (!hash) { + return; + } + const filePath = resolveCommandHashPath(accountId); + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, hash, "utf-8"); + } catch { + // Best-effort: failing to cache the hash just means the next restart + // will sync commands again, which is the pre-fix behaviour. + } +} + export function syncTelegramMenuCommands(params: { bot: Bot; runtime: RuntimeEnv; commandsToRegister: TelegramMenuCommand[]; + accountId?: string; }): void { - const { bot, runtime, commandsToRegister } = params; + const { bot, runtime, commandsToRegister, accountId } = params; const sync = async () => { + // Skip sync if the command list hasn't changed since the last successful + // sync. This prevents hitting Telegram's 429 rate limit when the gateway + // is restarted several times in quick succession. + // See: openclaw/openclaw#32017 + const currentHash = hashCommandList(commandsToRegister); + const cachedHash = await readCachedCommandHash(accountId); + if (cachedHash === currentHash) { + runtime.log?.("telegram: command menu unchanged; skipping sync"); + return; + } + // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. if (typeof bot.api.deleteMyCommands === "function") { await withTelegramApiErrorLogging({ @@ -118,6 +169,7 @@ export function syncTelegramMenuCommands(params: { } if (commandsToRegister.length === 0) { + await writeCachedCommandHash(accountId, currentHash); return; } @@ -129,6 +181,7 @@ export function syncTelegramMenuCommands(params: { runtime, fn: () => bot.api.setMyCommands(retryCommands), }); + await writeCachedCommandHash(accountId, currentHash); return; } catch (err) { if (!isBotCommandsTooMuchError(err)) { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0f07fc363..789ca25e7 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -397,7 +397,7 @@ export const registerTelegramNativeCommands = ({ } // Telegram only limits the setMyCommands payload (menu entries). // Keep hidden commands callable by registering handlers for the full catalog. - syncTelegramMenuCommands({ bot, runtime, commandsToRegister }); + syncTelegramMenuCommands({ bot, runtime, commandsToRegister, accountId }); const resolveCommandRuntimeContext = (params: { msg: NonNullable;