diff --git a/src/telegram/bot-native-command-menu.test.ts b/src/telegram/bot-native-command-menu.test.ts index 4f9150d7d5e..3b521daa4ac 100644 --- a/src/telegram/bot-native-command-menu.test.ts +++ b/src/telegram/bot-native-command-menu.test.ts @@ -100,6 +100,8 @@ describe("bot-native-command-menu", () => { } as unknown as Parameters[0]["bot"], runtime: {} as Parameters[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(() => { diff --git a/src/telegram/bot-native-command-menu.ts b/src/telegram/bot-native-command-menu.ts index 88a7c1047cd..8881b708bcf 100644 --- a/src/telegram/bot-native-command-menu.ts +++ b/src/telegram/bot-native-command-menu.ts @@ -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 { +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 { 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 { - if (!hash) { - return; - } - const filePath = resolveCommandHashPath(accountId); +async function writeCachedCommandHash( + accountId: string | undefined, + botIdentity: string | undefined, + hash: string, +): Promise { + 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)) { diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 789ca25e733..0fd97d9dfe5 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -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;