Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
0872ba2499 fix(telegram): scope command-sync hash cache by bot identity (#32059) 2026-03-02 19:24:16 +00:00
scoootscooob
836ad14244 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>
2026-03-02 19:22:54 +00:00
3 changed files with 177 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";
@@ -99,6 +100,8 @@ describe("bot-native-command-menu", () => {
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
runtime: {} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
commandsToRegister: [{ command: "cmd", description: "Command" }],
accountId: `test-delete-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
@@ -108,6 +111,103 @@ 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,
botIdentity: "bot-a",
});
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,
botIdentity: "bot-a",
});
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("does not reuse cached hash across different bot identities", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeLog = vi.fn();
const accountId = `test-bot-identity-${Date.now()}`;
const commands = [{ command: "same", description: "Same" }];
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,
botIdentity: "token-bot-a",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
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,
botIdentity: "token-bot-b",
});
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
expect(runtimeLog).not.toHaveBeenCalledWith("telegram: command menu unchanged; skipping sync");
});
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi
@@ -132,6 +232,8 @@ describe("bot-native-command-menu", () => {
command: `cmd_${i}`,
description: `Command ${i}`,
})),
accountId: `test-retry-${Date.now()}`,
botIdentity: "bot-a",
});
await vi.waitFor(() => {

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,73 @@ 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 hashBotIdentity(botIdentity?: string): string {
const normalized = botIdentity?.trim();
if (!normalized) {
return "no-bot";
}
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
function resolveCommandHashPath(accountId?: string, botIdentity?: string): string {
const stateDir = resolveStateDir(process.env, os.homedir);
const normalizedAccount = accountId?.trim().replace(/[^a-z0-9._-]+/gi, "_") || "default";
const botHash = hashBotIdentity(botIdentity);
return path.join(stateDir, "telegram", `command-hash-${normalizedAccount}-${botHash}.txt`);
}
async function readCachedCommandHash(
accountId?: string,
botIdentity?: string,
): Promise<string | null> {
try {
return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim();
} catch {
return null;
}
}
async function writeCachedCommandHash(
accountId: string | undefined,
botIdentity: string | undefined,
hash: string,
): Promise<void> {
const filePath = resolveCommandHashPath(accountId, botIdentity);
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;
botIdentity?: string;
}): void {
const { bot, runtime, commandsToRegister } = params;
const { bot, runtime, commandsToRegister, accountId, botIdentity } = 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, botIdentity);
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 +183,7 @@ export function syncTelegramMenuCommands(params: {
}
if (commandsToRegister.length === 0) {
await writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
}
@@ -129,6 +195,7 @@ export function syncTelegramMenuCommands(params: {
runtime,
fn: () => bot.api.setMyCommands(retryCommands),
});
await writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
} catch (err) {
if (!isBotCommandsTooMuchError(err)) {

View File

@@ -397,7 +397,13 @@ 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,
botIdentity: opts.token,
});
const resolveCommandRuntimeContext = (params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;