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:
committed by
Peter Steinberger
parent
4a2329e0af
commit
10fb632c9e
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
Reference in New Issue
Block a user