Files
openclaw/extensions/telegram/src/bot-native-command-menu-sync.test.ts
Peter Steinberger cfb19c7054 fix(telegram): stabilize command menu ordering (#121239)
Preserve custom, native, plugin, and alias precedence while keeping the documented /skill fallback across local and Telegram-driven menu pressure.
2026-08-09 16:08:10 -07:00

1101 lines
38 KiB
TypeScript

// 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 waitForTelegramMenuTurn() {
return new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
}
function createDeferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
const ledgerRows = new Map<string, unknown>();
const ledgerRegisterCalls: Array<{ key: string; value: unknown }> = [];
const ledgerDeleteCalls: string[] = [];
let nextLedgerRegisterError: Error | undefined;
let nextBotId = 1_000_000;
const testBotIds = new Map<string, number>();
function createLedgerStore<T>(): PluginStateKeyedStore<T> {
return {
register: async (key, value) => {
ledgerRegisterCalls.push({ key, value });
if (nextLedgerRegisterError) {
const error = nextLedgerRegisterError;
nextLedgerRegisterError = undefined;
throw error;
}
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) => {
ledgerDeleteCalls.push(key);
return 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;
}
function readLanguageCodeFromApiCall(call: readonly unknown[]): unknown {
const options = call.at(-1);
return options && typeof options === "object" && "language_code" in options
? options.language_code
: undefined;
}
beforeEach(() => {
ledgerRows.clear();
ledgerRegisterCalls.length = 0;
ledgerDeleteCalls.length = 0;
nextLedgerRegisterError = undefined;
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",
zz: "Unassigned language code",
},
},
];
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, zz.",
);
expect(
[...deleteMyCommands.mock.calls, ...setMyCommands.mock.calls].every((call) => {
const languageCode = readLanguageCodeFromApiCall(call);
return languageCode !== "zz" && languageCode !== "en-GB";
}),
).toBe(true);
});
it("treats blank supported localizations as absent for variants, ledger, and hashing", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const accountId = `test-blank-localization-${Date.now()}`;
const botId = "876543213";
const sync = (description: string) =>
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
accountId,
botToken: `${botId}:test-token`,
commandsToRegister: [
{
command: "cmd",
description: "Default",
descriptionLocalizations: { fr: description },
},
],
});
sync(" ");
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
expect(ledgerRows.has(botId)).toBe(false);
expect(setMyCommands.mock.calls.map(readLanguageCodeFromApiCall)).not.toContain("fr");
sync("\n");
await waitForTelegramMenuTurn();
expect(deleteMyCommands.mock.calls.length + setMyCommands.mock.calls.length).toBe(4);
});
it("uses neutral descriptions only for blank commands in a sibling-provided locale", async () => {
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
accountId: `test-sibling-localization-${Date.now()}`,
botToken: "876543214:test-token",
commandsToRegister: [
{
command: "blank",
description: "Neutral blank",
descriptionLocalizations: { fr: " " },
},
{
command: "localized",
description: "Neutral localized",
descriptionLocalizations: { " FR ": " Français ", fr: "Duplicate" },
},
],
});
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([
{ command: "blank", description: "Neutral blank" },
{ command: "localized", description: "Français" },
]);
});
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("preserves canonical source order 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: "configured",
description: "Configured",
descriptionLocalizations: { ko: localizedDescription },
},
...canonical,
{
command: "late_alias",
description: "Alias",
descriptionLocalizations: { ko: localizedDescription },
isAlias: 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),
"late_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: "configured",
description: "Configured",
descriptionLocalizations: { ko: "설정" },
},
{
command: "canonical",
description: "Canonical",
descriptionLocalizations: { ko: "표준" },
},
{
command: "late_alias",
description: "Alias",
descriptionLocalizations: { ko: "별칭" },
isAlias: true,
},
],
accountId: `test-localized-no-pressure-${Date.now()}`,
});
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
expect(setMyCommandsPayload(setMyCommands, 2)).toEqual([
{ command: "configured", description: "설정" },
{ command: "canonical", description: "표준" },
{ command: "late_alias", 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 waitForTelegramMenuTurn();
expect(deleteMyCommands).toHaveBeenCalledTimes(2);
expect(setMyCommands).toHaveBeenCalledTimes(2);
});
it("ignores isAlias and isSkill 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,
isSkill: true,
},
],
accountId,
});
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
commandsToRegister: [{ command: "skip_test", description: "Skip test command" }],
accountId,
});
await waitForTelegramMenuTurn();
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("repairs a non-canonical ledger once while preserving valid remote locale cleanup", async () => {
const botId = "876543210";
ledgerRows.set(botId, {
version: 1,
languageCodes: [" FR ", "fr", "FR", "zz", "en-GB", 42, null],
});
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeError = vi.fn();
const accountId = `test-repair-ledger-${Date.now()}`;
const commandsToRegister = [
{
command: "cmd",
description: "Default",
descriptionLocalizations: { " KO ": "한국어" },
},
];
const sync = () =>
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeError,
accountId,
botToken: `${botId}:test-token`,
commandsToRegister,
});
sync();
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
expect(deleteMyCommands).toHaveBeenCalledWith({ language_code: "fr" });
expect(deleteMyCommands).toHaveBeenCalledWith({
scope: { type: "all_group_chats" },
language_code: "fr",
});
expect(setMyCommands).toHaveBeenCalledWith([{ command: "cmd", description: "한국어" }], {
language_code: "ko",
});
expect(ledgerRegisterCalls).toEqual([
{ key: botId, value: { version: 1, languageCodes: ["fr"] } },
{ key: botId, value: { version: 1, languageCodes: ["ko"] } },
]);
expect(ledgerRows.get(botId)).toEqual({ version: 1, languageCodes: ["ko"] });
expect(runtimeError).toHaveBeenCalledWith(
`Telegram command menu locale ledger for bot ${botId} was repaired; the unshipped ledger contained non-canonical data (discarded unsupported language codes: en-GB, zz; discarded 2 malformed ledger field(s) or entry(ies)).`,
);
const apiCalls = [...deleteMyCommands.mock.calls, ...setMyCommands.mock.calls];
expect(
apiCalls.every((call) => {
const languageCode = readLanguageCodeFromApiCall(call);
return languageCode !== "zz" && languageCode !== "en-GB";
}),
).toBe(true);
await waitForTelegramMenuTurn();
sync();
await waitForTelegramMenuTurn();
expect(deleteMyCommands).toHaveBeenCalledTimes(4);
expect(setMyCommands).toHaveBeenCalledTimes(4);
expect(ledgerRegisterCalls).toHaveLength(2);
expect(runtimeError).toHaveBeenCalledTimes(1);
});
it("resets an unsalvageable malformed current ledger once and still completes reconciliation", async () => {
const botId = "876543211";
ledgerRows.set(botId, {
version: 1,
languageCodes: ["zz", "en-GB", 42],
unexpected: true,
});
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeError = vi.fn();
const accountId = `test-reset-ledger-${Date.now()}`;
const commandsToRegister = [{ command: "cmd", description: "Default" }];
const sync = () =>
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeError,
accountId,
botToken: `${botId}:test-token`,
commandsToRegister,
});
sync();
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
expect(ledgerRows.has(botId)).toBe(false);
expect(ledgerDeleteCalls).toEqual([botId]);
expect(runtimeError).toHaveBeenCalledWith(
`Telegram command menu locale ledger for bot ${botId} was reset; the unshipped ledger contained non-canonical data (discarded unsupported language codes: en-GB, zz; discarded 2 malformed ledger field(s) or entry(ies)).`,
);
await waitForTelegramMenuTurn();
sync();
await waitForTelegramMenuTurn();
expect(deleteMyCommands).toHaveBeenCalledTimes(2);
expect(setMyCommands).toHaveBeenCalledTimes(2);
expect(ledgerDeleteCalls).toEqual([botId]);
expect(runtimeError).toHaveBeenCalledTimes(1);
});
it("preserves future locale-ledger versions and fails closed before Telegram API calls", async () => {
const botId = "876543215";
const futureLedger = { version: 2, languageCodes: [" FR ", "zz", 42], unexpected: true };
const originalShape = structuredClone(futureLedger);
ledgerRows.set(botId, futureLedger);
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeError = vi.fn();
const sync = () =>
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeError,
accountId: `test-future-ledger-${Date.now()}`,
botToken: `${botId}:test-token`,
commandsToRegister: [{ command: "cmd", description: "Default" }],
});
sync();
await waitForTelegramMenu(() => expect(runtimeError).toHaveBeenCalledTimes(1));
sync();
await waitForTelegramMenu(() => expect(runtimeError).toHaveBeenCalledTimes(2));
expect(ledgerRows.get(botId)).toBe(futureLedger);
expect(ledgerRows.get(botId)).toEqual(originalShape);
expect(ledgerRegisterCalls).toHaveLength(0);
expect(ledgerDeleteCalls).toHaveLength(0);
expect(deleteMyCommands).not.toHaveBeenCalled();
expect(setMyCommands).not.toHaveBeenCalled();
for (const [message] of runtimeError.mock.calls) {
expect(message).toContain("unsupported future version 2");
expect(message.length).toBeLessThanOrEqual(180);
}
});
it("retries after a locale-ledger repair write fails", async () => {
const botId = "876543212";
const rawLedger = { version: 1, languageCodes: ["fr", "zz"] };
ledgerRows.set(botId, rawLedger);
nextLedgerRegisterError = new Error("injected repair failure");
const deleteMyCommands = vi.fn(async () => undefined);
const setMyCommands = vi.fn(async () => undefined);
const runtimeError = vi.fn();
const accountId = `test-retry-ledger-repair-${Date.now()}`;
const commandsToRegister = [
{
command: "cmd",
description: "Default",
descriptionLocalizations: { ko: "한국어" },
},
];
const sync = () =>
syncMenuCommandsWithMocks({
deleteMyCommands,
setMyCommands,
runtimeError,
accountId,
botToken: `${botId}:test-token`,
commandsToRegister,
});
sync();
await waitForTelegramMenu(() => expect(runtimeError).toHaveBeenCalledTimes(1));
expect(ledgerRows.get(botId)).toBe(rawLedger);
expect(deleteMyCommands).not.toHaveBeenCalled();
expect(setMyCommands).not.toHaveBeenCalled();
expect(runtimeError).toHaveBeenCalledWith(
`Telegram command menu locale ledger repair failed for bot ${botId}: Error: injected repair failure`,
);
sync();
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
expect(deleteMyCommands).toHaveBeenCalledWith({ language_code: "fr" });
expect(setMyCommands).toHaveBeenCalledWith([{ command: "cmd", description: "한국어" }], {
language_code: "ko",
});
expect(ledgerRows.get(botId)).toEqual({ version: 1, languageCodes: ["ko"] });
expect(runtimeError).toHaveBeenCalledWith(
`Telegram command menu locale ledger for bot ${botId} was repaired; the unshipped ledger contained non-canonical data (discarded unsupported language codes: zz).`,
);
await waitForTelegramMenuTurn();
sync();
await waitForTelegramMenuTurn();
expect(deleteMyCommands).toHaveBeenCalledTimes(4);
expect(setMyCommands).toHaveBeenCalledTimes(4);
expect(ledgerRegisterCalls).toHaveLength(3);
expect(runtimeError).toHaveBeenCalledTimes(2);
});
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",
});
});
});