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 <noreply@anthropic.com>
This commit is contained in:
scoootscooob
2026-03-02 10:58:48 -08:00
committed by Peter Steinberger
parent 4a2329e0af
commit 10fb632c9e
3 changed files with 116 additions and 2 deletions

View File

@@ -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<typeof syncTelegramMenuCommands>[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<typeof syncTelegramMenuCommands>[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

View File

@@ -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<string | null> {
try {
return (await fs.readFile(resolveCommandHashPath(accountId), "utf-8")).trim();
} catch {
return null;
}
}
async function writeCachedCommandHash(accountId?: string, hash?: string): Promise<void> {
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)) {

View File

@@ -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<TelegramNativeCommandContext["message"]>;