diff --git a/extensions/telegram/src/bot-native-command-menu-state.ts b/extensions/telegram/src/bot-native-command-menu-state.ts new file mode 100644 index 000000000000..850f94e23a79 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-menu-state.ts @@ -0,0 +1,178 @@ +// Owns Telegram command-menu identity, process serialization, and durable locale state. +import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { getOptionalTelegramRuntime } from "./runtime.js"; +import { fingerprintTelegramBotToken } from "./token-fingerprint.js"; +import { resolveTelegramBotUserIdFromToken } from "./token.js"; + +const TELEGRAM_MENU_LOCALE_LEDGER_VERSION = 1; +const TELEGRAM_MENU_LOCALE_LEDGER_NAMESPACE = "telegram.command-menu-locales"; +const TELEGRAM_MENU_LOCALE_LEDGER_MAX_ENTRIES = 1_000; + +type TelegramMenuLocaleLedger = { + version: typeof TELEGRAM_MENU_LOCALE_LEDGER_VERSION; + languageCodes: string[]; +}; + +type TelegramMenuLocaleLedgerHandle = { + store: PluginStateKeyedStore; + value?: TelegramMenuLocaleLedger; +}; + +const syncTails = new Map>(); +// Successful command hashes stay process-local so restarts always republish. +const syncedCommandHashes = new Map(); +const knownLanguageCodes = new Map>(); + +export function resolveTelegramMenuRemoteOwner(params: { + accountId?: string; + botId?: number; + botToken?: string; +}) { + const token = params.botToken?.trim(); + const tokenBotId = resolveTelegramBotUserIdFromToken(token); + const botId = params.botId ?? tokenBotId; + const tokenFingerprint = token ? fingerprintTelegramBotToken(token) : undefined; + const fallbackKey = `${params.accountId ?? "default"}:${tokenFingerprint ?? "unknown"}`; + const queueKey = botId === undefined ? `fallback:${fallbackKey}` : `bot:${botId}`; + return { + queueKey, + hashKey: `${queueKey}:${tokenFingerprint ?? ""}`, + ...(botId === undefined ? {} : { botId: String(botId) }), + }; +} + +export function enqueueTelegramMenuSync(params: { + ownerKey: string; + sync: () => Promise; + onError: (error: unknown) => void; +}): void { + const previous = syncTails.get(params.ownerKey) ?? Promise.resolve(); + // A remote bot owns one mutation lane so reload generations cannot interleave. + const next = previous.then(params.sync).catch((error: unknown) => { + try { + params.onError(error); + } catch { + // Logging failures must not poison the remote owner's next generation. + } + }); + syncTails.set(params.ownerKey, next); + void next.then(() => { + if (syncTails.get(params.ownerKey) === next) { + syncTails.delete(params.ownerKey); + } + }); +} + +export function readTelegramMenuCommandHash(key: string): string | null { + return syncedCommandHashes.get(key) ?? null; +} + +export function writeTelegramMenuCommandHash(key: string, hash: string): void { + syncedCommandHashes.set(key, hash); +} + +export function getProcessKnownTelegramMenuLocales(ownerKey: string): Set { + let locales = knownLanguageCodes.get(ownerKey); + if (!locales) { + locales = new Set(); + knownLanguageCodes.set(ownerKey, locales); + } + return locales; +} + +export function normalizeTelegramMenuLanguageCode(languageCode: string): string | null { + const normalized = languageCode.trim().toLowerCase(); + return /^[a-z]{2}$/.test(normalized) ? normalized : null; +} + +function parseTelegramMenuLocaleLedger(value: unknown): TelegramMenuLocaleLedger | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as { version?: unknown; languageCodes?: unknown }; + if ( + candidate.version !== TELEGRAM_MENU_LOCALE_LEDGER_VERSION || + !Array.isArray(candidate.languageCodes) + ) { + return null; + } + const languageCodes: string[] = []; + for (const languageCode of candidate.languageCodes) { + if ( + typeof languageCode !== "string" || + normalizeTelegramMenuLanguageCode(languageCode) !== languageCode + ) { + return null; + } + languageCodes.push(languageCode); + } + const sortedLanguageCodes = languageCodes.toSorted(); + if ( + new Set(languageCodes).size !== languageCodes.length || + languageCodes.some((languageCode, index) => languageCode !== sortedLanguageCodes[index]) + ) { + return null; + } + return { version: TELEGRAM_MENU_LOCALE_LEDGER_VERSION, languageCodes }; +} + +export async function readTelegramMenuLocaleLedger(params: { + botId: string; + runtime: RuntimeEnv; +}): Promise { + const telegramRuntime = getOptionalTelegramRuntime(); + if (!telegramRuntime) { + params.runtime.error?.( + `Telegram command menu locale ledger unavailable for bot ${params.botId}: runtime not initialized`, + ); + return null; + } + try { + const store = telegramRuntime.state.openKeyedStore({ + namespace: TELEGRAM_MENU_LOCALE_LEDGER_NAMESPACE, + maxEntries: TELEGRAM_MENU_LOCALE_LEDGER_MAX_ENTRIES, + overflowPolicy: "reject-new", + }); + const stored = await store.lookup(params.botId); + if (stored === undefined) { + return { store }; + } + const value = parseTelegramMenuLocaleLedger(stored); + if (!value) { + params.runtime.error?.( + `Telegram command menu locale ledger is malformed for bot ${params.botId}; preserving it for recovery.`, + ); + return null; + } + return { store, value }; + } catch (error) { + params.runtime.error?.( + `Telegram command menu locale ledger unavailable for bot ${params.botId}: ${String(error)}`, + ); + return null; + } +} + +export async function persistTelegramMenuLocaleLedger(params: { + botId: string; + read: TelegramMenuLocaleLedgerHandle; + languageCodes: string[]; +}): Promise { + const current = params.read.value?.languageCodes ?? []; + if ( + current.length === params.languageCodes.length && + current.every((languageCode, index) => languageCode === params.languageCodes[index]) + ) { + return; + } + if (params.languageCodes.length === 0) { + await params.read.store.delete(params.botId); + return; + } + // Record locale intent before publishing so partial writes remain cleanup-visible. + await params.read.store.register(params.botId, { + version: TELEGRAM_MENU_LOCALE_LEDGER_VERSION, + languageCodes: params.languageCodes, + }); +} diff --git a/extensions/telegram/src/bot-native-command-menu-sync.test.ts b/extensions/telegram/src/bot-native-command-menu-sync.test.ts new file mode 100644 index 000000000000..77a9a2167467 --- /dev/null +++ b/extensions/telegram/src/bot-native-command-menu-sync.test.ts @@ -0,0 +1,815 @@ +// Telegram tests cover native command menu remote synchronization. +import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { syncTelegramMenuCommands } from "./bot-native-command-menu.js"; +import { setTelegramRuntime } from "./runtime.js"; +import { clearTelegramRuntimeForTest } from "./runtime.test-support.js"; +import type { TelegramRuntime } from "./runtime.types.js"; + +function waitForTelegramMenu(assertion: () => void) { + return vi.waitFor(assertion, { interval: 1 }); +} + +function createDeferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const ledgerRows = new Map(); +let nextBotId = 1_000_000; +const testBotIds = new Map(); + +function createLedgerStore(): PluginStateKeyedStore { + return { + register: async (key, value) => { + ledgerRows.set(key, value); + }, + registerIfAbsent: async (key, value) => { + if (ledgerRows.has(key)) { + return false; + } + ledgerRows.set(key, value); + return true; + }, + lookup: async (key) => ledgerRows.get(key) as T | undefined, + consume: async (key) => { + const value = ledgerRows.get(key) as T | undefined; + ledgerRows.delete(key); + return value; + }, + delete: async (key) => ledgerRows.delete(key), + entries: async () => [], + clear: async () => ledgerRows.clear(), + }; +} + +type SyncMenuOptions = { + deleteMyCommands?: ReturnType; + setMyCommands: ReturnType; + commandsToRegister: Parameters[0]["commandsToRegister"]; + accountId: string; + botIdentity?: string; + botToken?: string; + botId?: number; + runtimeLog?: ReturnType; + runtimeError?: ReturnType; +}; + +function resolveTestBotToken(options: SyncMenuOptions): string { + if (options.botToken) { + return options.botToken; + } + const identity = `${options.accountId}:${options.botIdentity ?? "default"}`; + let botId = testBotIds.get(identity); + if (!botId) { + botId = nextBotId++; + testBotIds.set(identity, botId); + } + return `${botId}:test-token`; +} + +function syncMenuCommandsWithMocks(options: SyncMenuOptions): void { + const api = { + ...(options.deleteMyCommands ? { deleteMyCommands: options.deleteMyCommands } : {}), + setMyCommands: options.setMyCommands, + }; + syncTelegramMenuCommands({ + bot: { api } as unknown as Parameters[0]["bot"], + runtime: { + log: options.runtimeLog ?? vi.fn(), + error: options.runtimeError ?? vi.fn(), + exit: vi.fn(), + } as Parameters[0]["runtime"], + commandsToRegister: options.commandsToRegister, + accountId: options.accountId, + botId: options.botId, + botToken: resolveTestBotToken(options), + }); +} + +function setMyCommandsCall(setMyCommands: ReturnType, index: number): unknown[] { + const call = setMyCommands.mock.calls.at(index); + if (!call) { + throw new Error(`Expected setMyCommands call ${index}`); + } + return call; +} + +function setMyCommandsPayload( + setMyCommands: ReturnType, + index: number, +): Array { + const payload = setMyCommandsCall(setMyCommands, index).at(0); + if (!Array.isArray(payload)) { + throw new Error(`Expected setMyCommands call ${index} to include a command payload`); + } + return payload; +} + +beforeEach(() => { + ledgerRows.clear(); + const openKeyedStore = (() => + createLedgerStore()) as TelegramRuntime["state"]["openKeyedStore"]; + setTelegramRuntime({ state: { openKeyedStore }, channel: {} } as TelegramRuntime); +}); + +afterEach(() => { + clearTelegramRuntimeForTest(); +}); + +describe("bot-native-command-menu sync lifecycle", () => { + it("deletes stale commands before setting new menu", async () => { + const callOrder: string[] = []; + const deleteMyCommands = vi.fn(async (options?: { scope?: { type?: string } }) => { + callOrder.push(options?.scope?.type ? `delete:${options.scope.type}` : "delete:default"); + }); + const setMyCommands = vi.fn( + async (_commands: unknown, options?: { scope?: { type?: string } }) => { + callOrder.push(options?.scope?.type ? `set:${options.scope.type}` : "set:default"); + }, + ); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [{ command: "cmd", description: "Command" }], + accountId: `test-delete-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + + expect(callOrder).toEqual([ + "delete:default", + "delete:all_group_chats", + "set:default", + "set:all_group_chats", + ]); + }); + + it("registers the menu in default and group chat scopes", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const commands = [{ command: "cmd", description: "Command" }]; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId: `test-scopes-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + + expect(setMyCommands).toHaveBeenCalledWith(commands); + expect(setMyCommands).toHaveBeenCalledWith(commands, { + scope: { type: "all_group_chats" }, + }); + }); + + it("registers localized command descriptions per Telegram language scope", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const runtimeLog = vi.fn(); + const commands = [ + { + command: "cmd", + description: "Default", + descriptionLocalizations: { + ko: "한국어", + "en-GB": "British English is unsupported by Telegram", + }, + }, + ]; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeLog, + commandsToRegister: commands, + accountId: `test-localized-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + expect(deleteMyCommands).toHaveBeenCalledTimes(2); + expect(setMyCommandsPayload(setMyCommands, 0)).toEqual([ + { command: "cmd", description: "Default" }, + ]); + expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([ + { command: "cmd", description: "한국어" }, + ]); + expect(setMyCommandsCall(setMyCommands, 2).at(1)).toEqual({ language_code: "ko" }); + expect(setMyCommandsCall(setMyCommands, 3).at(1)).toEqual({ + scope: { type: "all_group_chats" }, + language_code: "ko", + }); + expect(runtimeLog).toHaveBeenCalledWith( + "Telegram command menu ignored unsupported description localization codes: en-GB.", + ); + }); + + it("caps localized command descriptions before registering Telegram variants", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [ + { + command: "long", + description: "Default", + descriptionLocalizations: { ko: "x".repeat(300) }, + }, + ], + accountId: `test-localized-cap-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + const localizedPayload = setMyCommandsPayload(setMyCommands, 2); + expect(localizedPayload[0]).toMatchObject({ command: "long" }); + expect((localizedPayload[0] as { description: string }).description).toHaveLength(256); + }); + + it("prioritizes configured, canonical, then alias commands under localization-only pressure", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const localizedDescription = "한".repeat(250); + const canonical = Array.from({ length: 22 }, (_, index) => ({ + command: `canonical_${index}`, + description: `Canonical ${index}`, + descriptionLocalizations: { ko: localizedDescription }, + })); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [ + { + command: "early_alias", + description: "Alias", + descriptionLocalizations: { ko: localizedDescription }, + isAlias: true, + }, + ...canonical, + { + command: "configured", + description: "Configured", + descriptionLocalizations: { ko: localizedDescription }, + isConfigured: true, + }, + ], + accountId: `test-localized-pressure-${Date.now()}`, + }); + + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + const localizedNames = setMyCommandsPayload(setMyCommands, 2).map( + (command) => (command as { command: string }).command, + ); + expect(localizedNames).toEqual([ + "configured", + ...canonical.map(({ command }) => command), + "early_alias", + ]); + expect(setMyCommandsPayload(setMyCommands, 3)).toEqual(setMyCommandsPayload(setMyCommands, 2)); + }); + + it("preserves ordinary localized order when localization creates no pressure", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [ + { + command: "early_alias", + description: "Alias", + descriptionLocalizations: { ko: "별칭" }, + isAlias: true, + }, + { + command: "canonical", + description: "Canonical", + descriptionLocalizations: { ko: "표준" }, + }, + { + command: "configured", + description: "Configured", + descriptionLocalizations: { ko: "설정" }, + isConfigured: true, + }, + ], + accountId: `test-localized-no-pressure-${Date.now()}`, + }); + + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([ + { command: "early_alias", description: "별칭" }, + { command: "canonical", description: "표준" }, + { command: "configured", description: "설정" }, + ]); + expect(setMyCommandsPayload(setMyCommands, 3)).toEqual(setMyCommandsPayload(setMyCommands, 2)); + }); + + it("resyncs when command order changes (#32017)", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const commands = [ + { command: "bravo", description: "B" }, + { command: "alpha", description: "A" }, + ]; + const accountId = `test-order-stable-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands.toReversed(), + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + expect(deleteMyCommands).toHaveBeenCalledTimes(4); + }); + + it("resyncs when a command description changes (#32017)", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-description-change-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [{ command: "alpha", description: "A" }], + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [{ command: "alpha", description: "Changed" }], + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + }); + + it("resyncs delimiter-like command lists without hash collisions", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-delimiter-collision-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [{ command: "a", description: "b\0c\0d" }], + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [ + { command: "a", description: "b" }, + { command: "c", description: "d" }, + ], + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + }); + + it("skips sync when command hash is unchanged (#32017)", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-skip-${Date.now()}`; + const commands = [{ command: "skip_test", description: "Skip test command" }]; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId, + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(deleteMyCommands).toHaveBeenCalledTimes(2); + expect(setMyCommands).toHaveBeenCalledTimes(2); + }); + + it("ignores internal priority metadata in the requested-state hash (#32017)", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-priority-hash-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [ + { + command: "skip_test", + description: "Skip test command", + isAlias: true, + isConfigured: true, + }, + ], + accountId, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [{ command: "skip_test", description: "Skip test command" }], + accountId, + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(deleteMyCommands).toHaveBeenCalledTimes(2); + expect(setMyCommands).toHaveBeenCalledTimes(2); + }); + + it("does not reuse cached hash across different bot identities", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-bot-identity-${Date.now()}`; + const commands = [{ command: "same", description: "Same" }]; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId, + botIdentity: "bot-a", + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: commands, + accountId, + botIdentity: "bot-b", + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + }); + + it("does not cache empty-menu hash when deleteMyCommands fails", async () => { + const deleteMyCommands = vi + .fn() + .mockRejectedValueOnce(new Error("transient failure")) + .mockResolvedValue(undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-empty-delete-fail-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [], + accountId, + }); + await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: [], + accountId, + }); + await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(4)); + }); + + it("retries with fewer commands on BOT_COMMANDS_TOO_MUCH", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi + .fn() + .mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH")) + .mockResolvedValue(undefined); + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeLog, + runtimeError, + commandsToRegister: Array.from({ length: 100 }, (_, i) => ({ + command: `cmd_${i}`, + description: `Command ${i}`, + })), + accountId: `test-retry-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3)); + + expect(setMyCommandsPayload(setMyCommands, 0)).toHaveLength(100); + expect(setMyCommandsPayload(setMyCommands, 1)).toHaveLength(80); + expect(setMyCommandsPayload(setMyCommands, 2)).toHaveLength(80); + expect(setMyCommandsCall(setMyCommands, 2).at(1)).toEqual({ + scope: { type: "all_group_chats" }, + }); + expect(runtimeLog).toHaveBeenCalledWith( + "Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.", + ); + expect(runtimeLog).toHaveBeenCalledWith( + "Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries.", + ); + expect(runtimeError).not.toHaveBeenCalled(); + }); + + it("registers localized variants from the accepted retry command set", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi + .fn() + .mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH")) + .mockResolvedValue(undefined); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + commandsToRegister: Array.from({ length: 100 }, (_, i) => ({ + command: `cmd_${i}`, + description: `Command ${i}`, + descriptionLocalizations: { ko: `명령 ${i}` }, + })), + accountId: `test-localized-retry-${Date.now()}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(5)); + + expect(setMyCommandsPayload(setMyCommands, 0)).toHaveLength(100); + expect(setMyCommandsPayload(setMyCommands, 1)).toHaveLength(80); + expect(setMyCommandsPayload(setMyCommands, 3)).toHaveLength(80); + expect(setMyCommandsCall(setMyCommands, 3).at(1)).toEqual({ language_code: "ko" }); + }); + + it.each([ + { label: "description envelope", error: { description: "BOT_COMMANDS_TOO_MUCH" } }, + { label: "message envelope", error: { message: "BOT_COMMANDS_TOO_MUCH" } }, + ])("retries when Telegram returns a plain-object $label error", async ({ error, label }) => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined); + const runtimeLog = vi.fn(); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeLog, + commandsToRegister: Array.from({ length: 10 }, (_, i) => ({ + command: `cmd_${i}`, + description: `Command ${i}`, + })), + accountId: `test-envelope-${Date.now()}-${label}`, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3)); + expect(runtimeLog).toHaveBeenCalledWith( + "Telegram rejected 10 commands (BOT_COMMANDS_TOO_MUCH); retrying with 8.", + ); + }); + + it("clears removed localized scope pairs in one strictly serialized generation", async () => { + const events: string[] = []; + let active = 0; + let maxActive = 0; + const record = async ( + kind: string, + options?: { scope?: { type?: string }; language_code?: string }, + ) => { + active += 1; + maxActive = Math.max(maxActive, active); + events.push( + `${kind}:${options?.language_code ?? "neutral"}:${options?.scope?.type ?? "default"}`, + ); + await Promise.resolve(); + active -= 1; + }; + const deleteMyCommands = vi.fn(async (options) => record("delete", options)); + const setMyCommands = vi.fn(async (_commands, options) => record("set", options)); + const accountId = `test-locale-removal-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + commandsToRegister: [ + { + command: "cmd", + description: "Default", + descriptionLocalizations: { fr: "Français", ko: "한국어" }, + }, + ], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(6)); + events.length = 0; + deleteMyCommands.mockClear(); + setMyCommands.mockClear(); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + commandsToRegister: [ + { + command: "cmd", + description: "Default", + descriptionLocalizations: { ko: "한국어" }, + }, + ], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + expect(events).toEqual([ + "delete:neutral:default", + "delete:neutral:all_group_chats", + "delete:fr:default", + "delete:fr:all_group_chats", + "delete:ko:default", + "delete:ko:all_group_chats", + "set:neutral:default", + "set:neutral:all_group_chats", + "set:ko:default", + "set:ko:all_group_chats", + ]); + expect(maxActive).toBe(1); + }); + + it("queues a later generation until the current remote-owner lane completes", async () => { + const gate = createDeferred(); + const events: string[] = []; + let active = 0; + let maxActive = 0; + const deleteMyCommands = vi.fn(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + events.push("delete"); + active -= 1; + }); + const setMyCommands = vi.fn(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + events.push("set"); + if (setMyCommands.mock.calls.length === 1) { + await gate.promise; + } + active -= 1; + }); + const accountId = `test-generation-queue-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + commandsToRegister: [{ command: "first", description: "First" }], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(1)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + commandsToRegister: [{ command: "second", description: "Second" }], + }); + await Promise.resolve(); + + expect(deleteMyCommands).toHaveBeenCalledTimes(2); + expect(setMyCommands).toHaveBeenCalledTimes(1); + expect(active).toBe(1); + gate.resolve(); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + expect(events).toEqual(["delete", "delete", "set", "set", "delete", "delete", "set", "set"]); + expect(maxActive).toBe(1); + }); + + it("retries failed localized cleanup instead of hash-caching it", async () => { + let failFrenchGroupClear = false; + const deleteMyCommands = vi.fn( + async (options?: { scope?: { type?: string }; language_code?: string }) => { + if ( + failFrenchGroupClear && + options?.language_code === "fr" && + options.scope?.type === "all_group_chats" + ) { + failFrenchGroupClear = false; + throw new Error("localized cleanup failed"); + } + }, + ); + const setMyCommands = vi.fn(async () => undefined); + const runtimeError = vi.fn(); + const accountId = `test-cleanup-retry-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeError, + accountId, + commandsToRegister: [ + { command: "cmd", description: "Default", descriptionLocalizations: { fr: "Français" } }, + ], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + deleteMyCommands.mockClear(); + setMyCommands.mockClear(); + failFrenchGroupClear = true; + + const neutralCommands = [{ command: "cmd", description: "Default" }]; + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeError, + accountId, + commandsToRegister: neutralCommands, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + runtimeError, + accountId, + commandsToRegister: neutralCommands, + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + + const frenchGroupClears = deleteMyCommands.mock.calls.filter( + ([options]) => options?.language_code === "fr" && options?.scope?.type === "all_group_chats", + ); + expect(frenchGroupClears).toHaveLength(2); + expect(runtimeError).toHaveBeenCalled(); + }); + + it("keys the durable locale ledger by stable bot ID across token rotation", async () => { + const deleteMyCommands = vi.fn(async () => undefined); + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-ledger-bot-id-${Date.now()}`; + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + botToken: "987654321:old-token", + commandsToRegister: [ + { command: "cmd", description: "Default", descriptionLocalizations: { fr: "Français" } }, + ], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); + expect([...ledgerRows.keys()]).toEqual(["987654321"]); + deleteMyCommands.mockClear(); + setMyCommands.mockClear(); + + syncMenuCommandsWithMocks({ + deleteMyCommands, + setMyCommands, + accountId, + botToken: "987654321:new-token", + commandsToRegister: [{ command: "cmd", description: "Default" }], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); + + expect(deleteMyCommands).toHaveBeenCalledWith({ language_code: "fr" }); + expect(deleteMyCommands).toHaveBeenCalledWith({ + scope: { type: "all_group_chats" }, + language_code: "fr", + }); + expect(ledgerRows.has("987654321")).toBe(false); + }); + + it("uses empty setMyCommands for each exact scope/language pair when delete is unavailable", async () => { + const setMyCommands = vi.fn(async () => undefined); + const accountId = `test-clear-fallback-${Date.now()}`; + + syncMenuCommandsWithMocks({ + setMyCommands, + accountId, + commandsToRegister: [ + { command: "cmd", description: "Default", descriptionLocalizations: { fr: "Français" } }, + ], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(6)); + setMyCommands.mockClear(); + + syncMenuCommandsWithMocks({ + setMyCommands, + accountId, + commandsToRegister: [{ command: "cmd", description: "Default" }], + }); + await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(6)); + + expect(setMyCommands).toHaveBeenCalledWith([], { language_code: "fr" }); + expect(setMyCommands).toHaveBeenCalledWith([], { + scope: { type: "all_group_chats" }, + language_code: "fr", + }); + }); +}); diff --git a/extensions/telegram/src/bot-native-command-menu.test.ts b/extensions/telegram/src/bot-native-command-menu.test.ts index 74afdfed44ce..c5a285507e50 100644 --- a/extensions/telegram/src/bot-native-command-menu.test.ts +++ b/extensions/telegram/src/bot-native-command-menu.test.ts @@ -17,7 +17,7 @@ type SyncMenuOptions = { setMyCommands: ReturnType; commandsToRegister: Parameters[0]["commandsToRegister"]; accountId: string; - botIdentity: string; + botToken: string; runtimeLog?: ReturnType; runtimeError?: ReturnType; }; @@ -34,7 +34,7 @@ function syncMenuCommandsWithMocks(options: SyncMenuOptions): void { } as Parameters[0]["runtime"], commandsToRegister: options.commandsToRegister, accountId: options.accountId, - botIdentity: options.botIdentity, + botToken: options.botToken, }); } @@ -131,7 +131,7 @@ describe("bot-native-command-menu", () => { setMyCommands, commandsToRegister: result.commandsToRegister, accountId: `test-pressure-${Date.now()}`, - botIdentity: "bot-a", + botToken: "bot-a", }); await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3)); const retryPayload = setMyCommandsPayload(setMyCommands, 1); @@ -240,456 +240,4 @@ describe("bot-native-command-menu", () => { 'Plugin command "/" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).', ); }); - - it("deletes stale commands before setting new menu", async () => { - const callOrder: string[] = []; - const deleteMyCommands = vi.fn(async (options?: { scope?: { type?: string } }) => { - callOrder.push(options?.scope?.type ? `delete:${options.scope.type}` : "delete:default"); - }); - const setMyCommands = vi.fn( - async (_commands: unknown, options?: { scope?: { type?: string } }) => { - callOrder.push(options?.scope?.type ? `set:${options.scope.type}` : "set:default"); - }, - ); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: [{ command: "cmd", description: "Command" }], - accountId: `test-delete-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalled(); - }); - - expect(callOrder).toEqual([ - "delete:default", - "delete:all_group_chats", - "set:default", - "set:all_group_chats", - ]); - }); - - it("registers the menu in default and group chat scopes", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const commands = [{ command: "cmd", description: "Command" }]; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: commands, - accountId: `test-scopes-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(2); - }); - - expect(setMyCommands).toHaveBeenCalledWith(commands); - expect(setMyCommands).toHaveBeenCalledWith(commands, { - scope: { type: "all_group_chats" }, - }); - }); - - it("registers localized command descriptions per Telegram language scope", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - const commands = [ - { - command: "cmd", - description: "Default", - descriptionLocalizations: { - ko: "한국어", - "en-GB": "British English is unsupported by Telegram", - }, - }, - ]; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands, - accountId: `test-localized-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(4); - }); - - expect(setMyCommandsPayload(setMyCommands, 0)).toEqual([ - { command: "cmd", description: "Default" }, - ]); - expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([ - { command: "cmd", description: "한국어" }, - ]); - expect(setMyCommandsCall(setMyCommands, 2).at(1)).toEqual({ language_code: "ko" }); - expect(setMyCommandsCall(setMyCommands, 3).at(1)).toEqual({ - scope: { type: "all_group_chats" }, - language_code: "ko", - }); - expect(runtimeLog).toHaveBeenCalledWith( - "Telegram command menu ignored unsupported description localization codes: en-GB.", - ); - }); - - it("caps localized command descriptions before registering Telegram variants", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: [ - { - command: "long", - description: "Default", - descriptionLocalizations: { ko: "x".repeat(300) }, - }, - ], - accountId: `test-localized-cap-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(4); - }); - - const localizedPayload = setMyCommandsPayload(setMyCommands, 2); - expect(localizedPayload[0]).toMatchObject({ command: "long" }); - expect((localizedPayload[0] as { description: string }).description).toHaveLength(256); - }); - - it("prioritizes configured, canonical, then alias commands under localization-only pressure", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const localizedDescription = "한".repeat(250); - const canonical = Array.from({ length: 22 }, (_, index) => ({ - command: `canonical_${index}`, - description: `Canonical ${index}`, - descriptionLocalizations: { ko: localizedDescription }, - })); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: [ - { - command: "early_alias", - description: "Alias", - descriptionLocalizations: { ko: localizedDescription }, - isAlias: true, - }, - ...canonical, - { - command: "configured", - description: "Configured", - descriptionLocalizations: { ko: localizedDescription }, - isConfigured: true, - }, - ], - accountId: `test-localized-pressure-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - const localizedNames = setMyCommandsPayload(setMyCommands, 2).map( - (command) => (command as { command: string }).command, - ); - expect(localizedNames).toEqual([ - "configured", - ...canonical.map(({ command }) => command), - "early_alias", - ]); - expect(setMyCommandsPayload(setMyCommands, 3)).toEqual(setMyCommandsPayload(setMyCommands, 2)); - }); - - it("preserves ordinary localized order when localization creates no pressure", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const commands = [ - { - command: "early_alias", - description: "Alias", - descriptionLocalizations: { ko: "별칭" }, - isAlias: true, - }, - { - command: "canonical", - description: "Canonical", - descriptionLocalizations: { ko: "표준" }, - }, - { - command: "configured", - description: "Configured", - descriptionLocalizations: { ko: "설정" }, - isConfigured: true, - }, - ]; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: commands, - accountId: `test-localized-no-pressure-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([ - { command: "early_alias", description: "별칭" }, - { command: "canonical", description: "표준" }, - { command: "configured", description: "설정" }, - ]); - expect(setMyCommandsPayload(setMyCommands, 3)).toEqual(setMyCommandsPayload(setMyCommands, 2)); - }); - - it("resyncs when command order changes (#32017)", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - const commands = [ - { command: "bravo", description: "B" }, - { command: "alpha", description: "A" }, - ]; - const accountId = `test-order-stable-${Date.now()}`; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands, - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands.toReversed(), - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - expect(deleteMyCommands).toHaveBeenCalledTimes(4); - }); - - it("resyncs when a command description changes (#32017)", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - const accountId = `test-description-change-${Date.now()}`; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [{ command: "alpha", description: "A" }], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [{ command: "alpha", description: "Changed" }], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - }); - - it("resyncs delimiter-like command lists without hash collisions", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - const accountId = `test-delimiter-collision-${Date.now()}`; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [{ command: "a", description: "b\0c\0d" }], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [ - { command: "a", description: "b" }, - { command: "c", description: "d" }, - ], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - }); - - it("ignores internal priority metadata in the requested-state hash (#32017)", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - - const accountId = `test-skip-${Date.now()}`; - const commands = [{ command: "skip_test", description: "Skip test command" }]; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [ - { - command: "skip_test", - description: "Skip test command", - isAlias: true, - isConfigured: true, - }, - ], - accountId, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(2); - }); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands, - accountId, - botIdentity: "bot-a", - }); - - expect(setMyCommands).toHaveBeenCalledTimes(2); - }); - - 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" }]; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands, - accountId, - botIdentity: "token-bot-a", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2)); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: commands, - accountId, - botIdentity: "token-bot-b", - }); - await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4)); - }); - - it("does not cache empty-menu hash when deleteMyCommands fails", async () => { - const deleteMyCommands = vi - .fn() - .mockRejectedValueOnce(new Error("transient failure")) - .mockResolvedValue(undefined); - const setMyCommands = vi.fn(async () => undefined); - const runtimeLog = vi.fn(); - const accountId = `test-empty-delete-fail-${Date.now()}`; - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2)); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: [], - accountId, - botIdentity: "bot-a", - }); - await waitForTelegramMenu(() => expect(deleteMyCommands).toHaveBeenCalledTimes(4)); - }); - - it("registers localized variants from the accepted retry command set", async () => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi - .fn() - .mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH")) - .mockResolvedValue(undefined); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - commandsToRegister: Array.from({ length: 100 }, (_, i) => ({ - command: `cmd_${i}`, - description: `Command ${i}`, - descriptionLocalizations: { ko: `명령 ${i}` }, - })), - accountId: `test-localized-retry-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(5); - }); - expect(setMyCommandsPayload(setMyCommands, 0)).toHaveLength(100); - expect(setMyCommandsPayload(setMyCommands, 1)).toHaveLength(80); - expect(setMyCommandsPayload(setMyCommands, 3)).toHaveLength(80); - expect(setMyCommandsCall(setMyCommands, 3).at(1)).toEqual({ language_code: "ko" }); - }); - - it.each([ - { label: "description envelope", error: { description: "BOT_COMMANDS_TOO_MUCH" } }, - { label: "message envelope", error: { message: "BOT_COMMANDS_TOO_MUCH" } }, - ])("retries when Telegram returns a plain-object $label error", async ({ error }) => { - const deleteMyCommands = vi.fn(async () => undefined); - const setMyCommands = vi.fn().mockRejectedValueOnce(error).mockResolvedValue(undefined); - const runtimeLog = vi.fn(); - - syncMenuCommandsWithMocks({ - deleteMyCommands, - setMyCommands, - runtimeLog, - commandsToRegister: Array.from({ length: 10 }, (_, i) => ({ - command: `cmd_${i}`, - description: `Command ${i}`, - })), - accountId: `test-envelope-${Date.now()}`, - botIdentity: "bot-a", - }); - - await waitForTelegramMenu(() => { - expect(setMyCommands).toHaveBeenCalledTimes(3); - }); - expect(runtimeLog).toHaveBeenCalledWith( - "Telegram rejected 10 commands (BOT_COMMANDS_TOO_MUCH); retrying with 8.", - ); - }); }); diff --git a/extensions/telegram/src/bot-native-command-menu.ts b/extensions/telegram/src/bot-native-command-menu.ts index 49e74f92ad7d..0c1296948518 100644 --- a/extensions/telegram/src/bot-native-command-menu.ts +++ b/extensions/telegram/src/bot-native-command-menu.ts @@ -9,6 +9,16 @@ import { readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { withTelegramApiErrorLogging } from "./api-logging.js"; +import { + enqueueTelegramMenuSync, + getProcessKnownTelegramMenuLocales, + normalizeTelegramMenuLanguageCode, + persistTelegramMenuLocaleLedger, + readTelegramMenuCommandHash, + readTelegramMenuLocaleLedger, + resolveTelegramMenuRemoteOwner, + writeTelegramMenuCommandHash, +} from "./bot-native-command-menu-state.js"; import { normalizeTelegramCommandName, TELEGRAM_COMMAND_NAME_PATTERN } from "./command-config.js"; const TELEGRAM_MAX_COMMANDS = 100; @@ -360,32 +370,6 @@ function hashCommandList(commands: TelegramMenuCommand[]): string { return createHash("sha256").update(JSON.stringify(requestedCommands)).digest("hex").slice(0, 16); } -// Keep the sync cache process-local so restarts always re-register commands. -const syncedCommandHashes = new Map(); - -function getCommandHashKey(accountId?: string, botIdentity?: string): string { - return `${accountId ?? "default"}:${botIdentity ?? ""}`; -} - -function readCachedCommandHash(accountId?: string, botIdentity?: string): string | null { - const key = getCommandHashKey(accountId, botIdentity); - return syncedCommandHashes.get(key) ?? null; -} - -function writeCachedCommandHash( - accountId: string | undefined, - botIdentity: string | undefined, - hash: string, -): void { - const key = getCommandHashKey(accountId, botIdentity); - syncedCommandHashes.set(key, hash); -} - -function normalizeTelegramLanguageCode(languageCode: string): string | null { - const normalized = languageCode.trim().toLowerCase(); - return /^[a-z]{2}$/.test(normalized) ? normalized : null; -} - function readLocalizedDescription( command: TelegramMenuCommand, languageCode: string, @@ -393,7 +377,7 @@ function readLocalizedDescription( for (const [rawLanguageCode, rawDescription] of Object.entries( command.descriptionLocalizations ?? {}, )) { - if (normalizeTelegramLanguageCode(rawLanguageCode) !== languageCode) { + if (normalizeTelegramMenuLanguageCode(rawLanguageCode) !== languageCode) { continue; } const description = normalizeOptionalString(rawDescription); @@ -420,15 +404,13 @@ function buildLocalizedCommandVariants(commands: TelegramMenuCommand[]): { } { const locales = new Set(); const unsupportedLanguageCodes = new Set(); - for (const cmd of commands) { - if (cmd.descriptionLocalizations) { - for (const lang of Object.keys(cmd.descriptionLocalizations)) { - const normalized = normalizeTelegramLanguageCode(lang); - if (normalized) { - locales.add(normalized); - } else { - unsupportedLanguageCodes.add(lang); - } + for (const command of commands) { + for (const rawLanguageCode of Object.keys(command.descriptionLocalizations ?? {})) { + const normalized = normalizeTelegramMenuLanguageCode(rawLanguageCode); + if (normalized) { + locales.add(normalized); + } else { + unsupportedLanguageCodes.add(rawLanguageCode); } } } @@ -459,28 +441,45 @@ function formatTelegramCommandScopeOperation( return languageCode ? `${base}(${languageCode})` : base; } -async function deleteTelegramMenuCommandsForScopes(params: { +function buildTelegramCommandScopeOptions( + scope: TelegramCommandMenuScope, + languageCode?: string, +): { scope?: { type: "all_group_chats" }; language_code?: LanguageCode } | undefined { + return scope.options || languageCode + ? { + ...scope.options, + ...(languageCode ? { language_code: languageCode as LanguageCode } : {}), + } + : undefined; +} + +async function clearTelegramMenuCommandsForScopes(params: { bot: Bot; runtime: RuntimeEnv; + languageCode?: string; }): Promise { - const { bot, runtime } = params; - if (typeof bot.api.deleteMyCommands !== "function") { - return true; - } + const { bot, runtime, languageCode } = params; - let allDeleted = true; + let allCleared = true; for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) { - const deleted = await withTelegramApiErrorLogging({ - operation: formatTelegramCommandScopeOperation("deleteMyCommands", scope), + const options = buildTelegramCommandScopeOptions(scope, languageCode); + const operation = + typeof bot.api.deleteMyCommands === "function" ? "deleteMyCommands" : "setMyCommands"; + const cleared = await withTelegramApiErrorLogging({ + operation: formatTelegramCommandScopeOperation(operation, scope, languageCode), runtime, - fn: () => - scope.options ? bot.api.deleteMyCommands(scope.options) : bot.api.deleteMyCommands(), + fn: () => { + if (typeof bot.api.deleteMyCommands === "function") { + return options ? bot.api.deleteMyCommands(options) : bot.api.deleteMyCommands(); + } + return options ? bot.api.setMyCommands([], options) : bot.api.setMyCommands([]); + }, }) .then(() => true) .catch(() => false); - allDeleted &&= deleted; + allCleared &&= cleared; } - return allDeleted; + return allCleared; } async function setTelegramMenuCommandsForScopes(params: { @@ -491,20 +490,15 @@ async function setTelegramMenuCommandsForScopes(params: { shouldLog?: (err: unknown) => boolean; }): Promise { const { bot, runtime, commands, languageCode, shouldLog } = params; + const botCommands = toTelegramBotCommands(commands); for (const scope of TELEGRAM_COMMAND_MENU_SCOPES) { await withTelegramApiErrorLogging({ operation: formatTelegramCommandScopeOperation("setMyCommands", scope, languageCode), runtime, shouldLog, fn: () => { - const botCommands = toTelegramBotCommands(commands); - const opts = { - ...scope.options, - ...(languageCode ? { language_code: languageCode as LanguageCode } : undefined), - }; - return Object.keys(opts).length > 0 - ? bot.api.setMyCommands(botCommands, opts) - : bot.api.setMyCommands(botCommands); + const opts = buildTelegramCommandScopeOptions(scope, languageCode); + return opts ? bot.api.setMyCommands(botCommands, opts) : bot.api.setMyCommands(botCommands); }, }); } @@ -515,33 +509,86 @@ export function syncTelegramMenuCommands(params: { runtime: RuntimeEnv; commandsToRegister: TelegramMenuCommand[]; accountId?: string; - botIdentity?: string; + botId?: number; + botToken?: string; }): void { - const { bot, runtime, commandsToRegister, accountId, botIdentity } = params; + const { bot, runtime, commandsToRegister } = params; + const owner = resolveTelegramMenuRemoteOwner(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 = readCachedCommandHash(accountId, botIdentity); + const cachedHash = readTelegramMenuCommandHash(owner.hashKey); if (cachedHash === currentHash) { logVerbose("telegram: command menu unchanged; skipping sync"); return; } - // Keep delete -> set ordering to avoid stale deletions racing after fresh registrations. - const deleteSucceeded = await deleteTelegramMenuCommandsForScopes({ bot, runtime }); + const processLocales = getProcessKnownTelegramMenuLocales(owner.queueKey); + const ledgerRead = owner.botId + ? await readTelegramMenuLocaleLedger({ botId: owner.botId, runtime }) + : null; + const trackedLocales = new Set([ + ...processLocales, + ...(ledgerRead?.value?.languageCodes ?? []), + ]); + + // Keep every exact scope/language clear ahead of publication. + const neutralCleared = await clearTelegramMenuCommandsForScopes({ bot, runtime }); + const unclearedLocales = new Set(); + for (const languageCode of [...trackedLocales].toSorted()) { + const cleared = await clearTelegramMenuCommandsForScopes({ + bot, + runtime, + languageCode, + }); + if (!cleared) { + unclearedLocales.add(languageCode); + } + } + processLocales.clear(); + for (const languageCode of unclearedLocales) { + processLocales.add(languageCode); + } + + const persistLocales = async (desiredLocales: string[]): Promise => { + const conservativeLocales = [...new Set([...unclearedLocales, ...desiredLocales])].toSorted(); + processLocales.clear(); + for (const languageCode of conservativeLocales) { + processLocales.add(languageCode); + } + if (!owner.botId) { + return true; + } + if (!ledgerRead) { + return false; + } + try { + await persistTelegramMenuLocaleLedger({ + botId: owner.botId, + read: ledgerRead, + languageCodes: conservativeLocales, + }); + return true; + } catch (error) { + runtime.error?.( + `Telegram command menu locale ledger write failed for bot ${owner.botId}: ${String(error)}`, + ); + return false; + } + }; if (commandsToRegister.length === 0) { - if (!deleteSucceeded) { - runtime.log?.("telegram: deleteMyCommands failed; skipping empty-menu hash cache write"); - return; + const ledgerComplete = await persistLocales([]); + if (neutralCleared && unclearedLocales.size === 0 && ledgerComplete) { + writeTelegramMenuCommandHash(owner.hashKey, currentHash); + } else { + runtime.log?.( + "telegram: command menu cleanup incomplete; skipping success hash cache write", + ); } - if (typeof bot.api.deleteMyCommands !== "function") { - await setTelegramMenuCommandsForScopes({ bot, runtime, commands: [] }); - } - writeCachedCommandHash(accountId, botIdentity, currentHash); return; } @@ -597,6 +644,15 @@ export function syncTelegramMenuCommands(params: { ); } + const desiredLocales = variants.map((variant) => variant.languageCode); + const ledgerComplete = await persistLocales(desiredLocales); + if (!ledgerComplete) { + runtime.log?.( + "telegram: localized command menu skipped because locale intent was not durably recorded", + ); + return; + } + for (const variant of variants) { await setTelegramMenuCommandsForScopes({ bot, @@ -605,10 +661,18 @@ export function syncTelegramMenuCommands(params: { languageCode: variant.languageCode, }); } - writeCachedCommandHash(accountId, botIdentity, currentHash); + if (neutralCleared && unclearedLocales.size === 0) { + writeTelegramMenuCommandHash(owner.hashKey, currentHash); + } else { + runtime.log?.("telegram: command menu cleanup incomplete; skipping success hash cache write"); + } }; - void sync().catch((err: unknown) => { - runtime.error?.(`Telegram command sync failed: ${String(err)}`); + enqueueTelegramMenuSync({ + ownerKey: owner.queueKey, + sync, + onError: (error) => { + runtime.error?.(`Telegram command sync failed: ${String(error)}`); + }, }); } diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index e6bb24a701a2..ba1f52d63415 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -681,7 +681,7 @@ type RegisterTelegramNativeCommandsParams = { telegramDeps?: TelegramNativeCommandDeps; opts: Pick< TelegramBotOptions, - "token" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" + "token" | "botInfo" | "allowFrom" | "groupAllowFrom" | "replyToMode" | "accountAbortSignal" >; }; @@ -1085,7 +1085,8 @@ export const registerTelegramNativeCommands = ({ runtime, commandsToRegister, accountId, - botIdentity: opts.token, + botId: opts.botInfo?.id, + botToken: opts.token, }); const resolveCommandRuntimeContext = async (params: { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 74bdef91246a..0218fe5c2141 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -222,7 +222,7 @@ const menuSyncHoisted = vi.hoisted(() => ({ await bot.api.setMyCommands(commandsToRegister); }), })); -const syncTelegramMenuCommands = menuSyncHoisted.syncTelegramMenuCommands; +export const syncTelegramMenuCommands = menuSyncHoisted.syncTelegramMenuCommands; function parseModelRef(raw: string): { provider?: string; model: string } { const trimmed = raw.trim(); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 3e1aa5256952..04de050ce9d5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -75,6 +75,7 @@ const { setSessionStoreEntriesForTest, setMessageReactionSpy, setMyCommandsSpy, + syncTelegramMenuCommands, telegramBotDepsForTest, throttlerSpy, useSpy, @@ -4199,7 +4200,11 @@ describe("createTelegramBot", () => { createTelegramBot({ token: "tok" }); - expect(setMyCommandsSpy).toHaveBeenCalledTimes(1); + expect(syncTelegramMenuCommands).toHaveBeenCalledOnce(); + expect(syncTelegramMenuCommands).toHaveBeenCalledWith( + expect.objectContaining({ commandsToRegister: [] }), + ); + expect(setMyCommandsSpy).toHaveBeenCalledOnce(); expect(setMyCommandsSpy).toHaveBeenCalledWith([]); }); it("handles requireMention when mentions do and do not resolve", async () => {