fix(telegram): clear stale localized command menus (#120885)

* fix(telegram): clear stale localized command menus

* test(telegram): satisfy command menu lint
This commit is contained in:
Peter Steinberger
2026-08-08 22:14:58 -07:00
committed by GitHub
parent 4b7454bb1c
commit ec866d8abc
7 changed files with 1141 additions and 530 deletions
@@ -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<TelegramMenuLocaleLedger>;
value?: TelegramMenuLocaleLedger;
};
const syncTails = new Map<string, Promise<void>>();
// Successful command hashes stay process-local so restarts always republish.
const syncedCommandHashes = new Map<string, string>();
const knownLanguageCodes = new Map<string, Set<string>>();
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<void>;
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<string> {
let locales = knownLanguageCodes.get(ownerKey);
if (!locales) {
locales = new Set<string>();
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<TelegramMenuLocaleLedgerHandle | null> {
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<TelegramMenuLocaleLedger>({
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<void> {
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,
});
}
@@ -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<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
const ledgerRows = new Map<string, unknown>();
let nextBotId = 1_000_000;
const testBotIds = new Map<string, number>();
function createLedgerStore<T>(): PluginStateKeyedStore<T> {
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<typeof vi.fn>;
setMyCommands: ReturnType<typeof vi.fn>;
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
accountId: string;
botIdentity?: string;
botToken?: string;
botId?: number;
runtimeLog?: ReturnType<typeof vi.fn>;
runtimeError?: ReturnType<typeof vi.fn>;
};
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<typeof syncTelegramMenuCommands>[0]["bot"],
runtime: {
log: options.runtimeLog ?? vi.fn(),
error: options.runtimeError ?? vi.fn(),
exit: vi.fn(),
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
commandsToRegister: options.commandsToRegister,
accountId: options.accountId,
botId: options.botId,
botToken: resolveTestBotToken(options),
});
}
function setMyCommandsCall(setMyCommands: ReturnType<typeof vi.fn>, 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<typeof vi.fn>,
index: number,
): Array<unknown> {
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 = (<T>() =>
createLedgerStore<T>()) 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<void>((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<void>((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",
});
});
});
@@ -17,7 +17,7 @@ type SyncMenuOptions = {
setMyCommands: ReturnType<typeof vi.fn>;
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
accountId: string;
botIdentity: string;
botToken: string;
runtimeLog?: ReturnType<typeof vi.fn>;
runtimeError?: ReturnType<typeof vi.fn>;
};
@@ -34,7 +34,7 @@ function syncMenuCommandsWithMocks(options: SyncMenuOptions): void {
} as Parameters<typeof syncTelegramMenuCommands>[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 "/<unknown>" 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.",
);
});
});
@@ -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<string, string>();
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<string>();
const unsupportedLanguageCodes = new Set<string>();
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<boolean> {
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<void> {
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<string>();
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<boolean> => {
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)}`);
},
});
}
@@ -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: {
@@ -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();
@@ -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 () => {