mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
cfb19c7054
Preserve custom, native, plugin, and alias precedence while keeping the documented /skill fallback across local and Telegram-driven menu pressure.
1101 lines
38 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|