fix(telegram): scope command-sync hash cache by bot identity (#32059)

This commit is contained in:
Peter Steinberger
2026-03-02 19:24:16 +00:00
parent 836ad14244
commit 0872ba2499
3 changed files with 77 additions and 16 deletions

View File

@@ -100,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(() => {
@@ -143,6 +145,7 @@ describe("bot-native-command-menu", () => {
>[0]["runtime"],
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
@@ -159,6 +162,7 @@ describe("bot-native-command-menu", () => {
>[0]["runtime"],
commandsToRegister: commands,
accountId,
botIdentity: "bot-a",
});
await vi.waitFor(() => {
@@ -169,6 +173,41 @@ describe("bot-native-command-menu", () => {
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
@@ -193,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

@@ -112,25 +112,38 @@ export function hashCommandList(commands: TelegramMenuCommand[]): string {
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`);
function hashBotIdentity(botIdentity?: string): string {
const normalized = botIdentity?.trim();
if (!normalized) {
return "no-bot";
}
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
async function readCachedCommandHash(accountId?: string): Promise<string | null> {
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), "utf-8")).trim();
return (await fs.readFile(resolveCommandHashPath(accountId, botIdentity), "utf-8")).trim();
} catch {
return null;
}
}
async function writeCachedCommandHash(accountId?: string, hash?: string): Promise<void> {
if (!hash) {
return;
}
const filePath = resolveCommandHashPath(accountId);
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");
@@ -145,15 +158,16 @@ export function syncTelegramMenuCommands(params: {
runtime: RuntimeEnv;
commandsToRegister: TelegramMenuCommand[];
accountId?: string;
botIdentity?: string;
}): void {
const { bot, runtime, commandsToRegister, accountId } = 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);
const cachedHash = await readCachedCommandHash(accountId, botIdentity);
if (cachedHash === currentHash) {
runtime.log?.("telegram: command menu unchanged; skipping sync");
return;
@@ -169,7 +183,7 @@ export function syncTelegramMenuCommands(params: {
}
if (commandsToRegister.length === 0) {
await writeCachedCommandHash(accountId, currentHash);
await writeCachedCommandHash(accountId, botIdentity, currentHash);
return;
}
@@ -181,7 +195,7 @@ export function syncTelegramMenuCommands(params: {
runtime,
fn: () => bot.api.setMyCommands(retryCommands),
});
await writeCachedCommandHash(accountId, currentHash);
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, accountId });
syncTelegramMenuCommands({
bot,
runtime,
commandsToRegister,
accountId,
botIdentity: opts.token,
});
const resolveCommandRuntimeContext = (params: {
msg: NonNullable<TelegramNativeCommandContext["message"]>;