mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 18:04:59 +00:00
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 {
|
import {
|
||||||
buildCappedTelegramMenuCommands,
|
buildCappedTelegramMenuCommands,
|
||||||
buildPluginTelegramMenuCommands,
|
buildPluginTelegramMenuCommands,
|
||||||
|
hashCommandList,
|
||||||
syncTelegramMenuCommands,
|
syncTelegramMenuCommands,
|
||||||
} from "./bot-native-command-menu.js";
|
} from "./bot-native-command-menu.js";
|
||||||
|
|
||||||
@@ -108,6 +109,66 @@ describe("bot-native-command-menu", () => {
|
|||||||
expect(callOrder).toEqual(["delete", "set"]);
|
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 () => {
|
it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => {
|
||||||
const deleteMyCommands = vi.fn(async () => undefined);
|
const deleteMyCommands = vi.fn(async () => undefined);
|
||||||
const setMyCommands = vi
|
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 type { Bot } from "grammy";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import {
|
import {
|
||||||
normalizeTelegramCommandName,
|
normalizeTelegramCommandName,
|
||||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||||
@@ -101,13 +106,59 @@ export function buildCappedTelegramMenuCommands(params: {
|
|||||||
return { commandsToRegister, totalCommands, maxCommands, overflowCount };
|
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: {
|
export function syncTelegramMenuCommands(params: {
|
||||||
bot: Bot;
|
bot: Bot;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
commandsToRegister: TelegramMenuCommand[];
|
commandsToRegister: TelegramMenuCommand[];
|
||||||
|
accountId?: string;
|
||||||
}): void {
|
}): void {
|
||||||
const { bot, runtime, commandsToRegister } = params;
|
const { bot, runtime, commandsToRegister, accountId } = params;
|
||||||
const sync = async () => {
|
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.
|
// Keep delete -> set ordering to avoid stale deletions racing after fresh registrations.
|
||||||
if (typeof bot.api.deleteMyCommands === "function") {
|
if (typeof bot.api.deleteMyCommands === "function") {
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
@@ -118,6 +169,7 @@ export function syncTelegramMenuCommands(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (commandsToRegister.length === 0) {
|
if (commandsToRegister.length === 0) {
|
||||||
|
await writeCachedCommandHash(accountId, currentHash);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +181,7 @@ export function syncTelegramMenuCommands(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.setMyCommands(retryCommands),
|
fn: () => bot.api.setMyCommands(retryCommands),
|
||||||
});
|
});
|
||||||
|
await writeCachedCommandHash(accountId, currentHash);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isBotCommandsTooMuchError(err)) {
|
if (!isBotCommandsTooMuchError(err)) {
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
}
|
}
|
||||||
// Telegram only limits the setMyCommands payload (menu entries).
|
// Telegram only limits the setMyCommands payload (menu entries).
|
||||||
// Keep hidden commands callable by registering handlers for the full catalog.
|
// Keep hidden commands callable by registering handlers for the full catalog.
|
||||||
syncTelegramMenuCommands({ bot, runtime, commandsToRegister });
|
syncTelegramMenuCommands({ bot, runtime, commandsToRegister, accountId });
|
||||||
|
|
||||||
const resolveCommandRuntimeContext = (params: {
|
const resolveCommandRuntimeContext = (params: {
|
||||||
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||||
|
|||||||
Reference in New Issue
Block a user