mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
284 lines
9.4 KiB
TypeScript
284 lines
9.4 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildCappedTelegramMenuCommands,
|
|
buildPluginTelegramMenuCommands,
|
|
hashCommandList,
|
|
syncTelegramMenuCommands,
|
|
} from "./bot-native-command-menu.js";
|
|
|
|
type SyncMenuOptions = {
|
|
deleteMyCommands: ReturnType<typeof vi.fn>;
|
|
setMyCommands: ReturnType<typeof vi.fn>;
|
|
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
|
|
accountId: string;
|
|
botIdentity: string;
|
|
runtimeLog?: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function syncMenuCommandsWithMocks(options: SyncMenuOptions): void {
|
|
syncTelegramMenuCommands({
|
|
bot: {
|
|
api: { deleteMyCommands: options.deleteMyCommands, setMyCommands: options.setMyCommands },
|
|
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
|
|
runtime: {
|
|
log: options.runtimeLog ?? vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
|
|
commandsToRegister: options.commandsToRegister,
|
|
accountId: options.accountId,
|
|
botIdentity: options.botIdentity,
|
|
});
|
|
}
|
|
|
|
describe("bot-native-command-menu", () => {
|
|
it("caps menu entries to Telegram limit", () => {
|
|
const allCommands = Array.from({ length: 105 }, (_, i) => ({
|
|
command: `cmd_${i}`,
|
|
description: `Command ${i}`,
|
|
}));
|
|
|
|
const result = buildCappedTelegramMenuCommands({ allCommands });
|
|
|
|
expect(result.commandsToRegister).toHaveLength(100);
|
|
expect(result.totalCommands).toBe(105);
|
|
expect(result.maxCommands).toBe(100);
|
|
expect(result.overflowCount).toBe(5);
|
|
expect(result.commandsToRegister[0]).toEqual({ command: "cmd_0", description: "Command 0" });
|
|
expect(result.commandsToRegister[99]).toEqual({
|
|
command: "cmd_99",
|
|
description: "Command 99",
|
|
});
|
|
});
|
|
|
|
it("validates plugin command specs and reports conflicts", () => {
|
|
const existingCommands = new Set(["native"]);
|
|
|
|
const result = buildPluginTelegramMenuCommands({
|
|
specs: [
|
|
{ name: "valid", description: " Works " },
|
|
{ name: "bad-name!", description: "Bad" },
|
|
{ name: "native", description: "Conflicts with native" },
|
|
{ name: "valid", description: "Duplicate plugin name" },
|
|
{ name: "empty", description: " " },
|
|
],
|
|
existingCommands,
|
|
});
|
|
|
|
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
|
|
expect(result.issues).toContain(
|
|
'Plugin command "/bad-name!" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
|
|
);
|
|
expect(result.issues).toContain(
|
|
'Plugin command "/native" conflicts with an existing Telegram command.',
|
|
);
|
|
expect(result.issues).toContain('Plugin command "/valid" is duplicated.');
|
|
expect(result.issues).toContain('Plugin command "/empty" is missing a description.');
|
|
});
|
|
|
|
it("normalizes hyphenated plugin command names", () => {
|
|
const result = buildPluginTelegramMenuCommands({
|
|
specs: [{ name: "agent-run", description: "Run agent" }],
|
|
existingCommands: new Set<string>(),
|
|
});
|
|
|
|
expect(result.commands).toEqual([{ command: "agent_run", description: "Run agent" }]);
|
|
expect(result.issues).toEqual([]);
|
|
});
|
|
|
|
it("ignores malformed plugin specs without crashing", () => {
|
|
const malformedSpecs = [
|
|
{ name: "valid", description: " Works " },
|
|
{ name: "missing-description", description: undefined },
|
|
{ name: undefined, description: "Missing name" },
|
|
] as unknown as Parameters<typeof buildPluginTelegramMenuCommands>[0]["specs"];
|
|
|
|
const result = buildPluginTelegramMenuCommands({
|
|
specs: malformedSpecs,
|
|
existingCommands: new Set<string>(),
|
|
});
|
|
|
|
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
|
|
expect(result.issues).toContain(
|
|
'Plugin command "/missing_description" is missing a description.',
|
|
);
|
|
expect(result.issues).toContain(
|
|
'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 () => {
|
|
callOrder.push("delete");
|
|
});
|
|
const setMyCommands = vi.fn(async () => {
|
|
callOrder.push("set");
|
|
});
|
|
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
commandsToRegister: [{ command: "cmd", description: "Command" }],
|
|
accountId: `test-delete-${Date.now()}`,
|
|
botIdentity: "bot-a",
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(setMyCommands).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(callOrder).toEqual(["delete", "set"]);
|
|
});
|
|
|
|
it("produces a stable hash regardless of command order (#32017)", () => {
|
|
const commands = [
|
|
{ command: "bravo", description: "B" },
|
|
{ command: "alpha", description: "A" },
|
|
];
|
|
const reversed = [...commands].toReversed();
|
|
expect(hashCommandList(commands)).toBe(hashCommandList(reversed));
|
|
});
|
|
|
|
it("produces different hashes for different command lists (#32017)", () => {
|
|
const a = [{ command: "alpha", description: "A" }];
|
|
const b = [{ command: "alpha", description: "Changed" }];
|
|
expect(hashCommandList(a)).not.toBe(hashCommandList(b));
|
|
});
|
|
|
|
it("skips sync when command hash is unchanged (#32017)", async () => {
|
|
const deleteMyCommands = vi.fn(async () => undefined);
|
|
const setMyCommands = vi.fn(async () => undefined);
|
|
const runtimeLog = vi.fn();
|
|
|
|
// Use a unique accountId so cached hashes from other tests don't interfere.
|
|
const accountId = `test-skip-${Date.now()}`;
|
|
const commands = [{ command: "skip_test", description: "Skip test command" }];
|
|
|
|
// First sync — no cached hash, should call setMyCommands.
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: commands,
|
|
accountId,
|
|
botIdentity: "bot-a",
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(setMyCommands).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Second sync with the same commands — hash is cached, should skip.
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: commands,
|
|
accountId,
|
|
botIdentity: "bot-a",
|
|
});
|
|
|
|
// setMyCommands should NOT have been called a second time.
|
|
expect(setMyCommands).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not reuse cached hash across different bot identities", async () => {
|
|
const deleteMyCommands = vi.fn(async () => undefined);
|
|
const setMyCommands = vi.fn(async () => undefined);
|
|
const runtimeLog = vi.fn();
|
|
const accountId = `test-bot-identity-${Date.now()}`;
|
|
const commands = [{ command: "same", description: "Same" }];
|
|
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: commands,
|
|
accountId,
|
|
botIdentity: "token-bot-a",
|
|
});
|
|
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(1));
|
|
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: commands,
|
|
accountId,
|
|
botIdentity: "token-bot-b",
|
|
});
|
|
await vi.waitFor(() => expect(setMyCommands).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
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 vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(1));
|
|
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: [],
|
|
accountId,
|
|
botIdentity: "bot-a",
|
|
});
|
|
await vi.waitFor(() => expect(deleteMyCommands).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
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();
|
|
|
|
syncTelegramMenuCommands({
|
|
bot: {
|
|
api: {
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
},
|
|
} as unknown as Parameters<typeof syncTelegramMenuCommands>[0]["bot"],
|
|
runtime: {
|
|
log: runtimeLog,
|
|
error: vi.fn(),
|
|
exit: vi.fn(),
|
|
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
|
|
commandsToRegister: Array.from({ length: 100 }, (_, i) => ({
|
|
command: `cmd_${i}`,
|
|
description: `Command ${i}`,
|
|
})),
|
|
accountId: `test-retry-${Date.now()}`,
|
|
botIdentity: "bot-a",
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(setMyCommands).toHaveBeenCalledTimes(2);
|
|
});
|
|
const firstPayload = setMyCommands.mock.calls[0]?.[0] as Array<unknown>;
|
|
const secondPayload = setMyCommands.mock.calls[1]?.[0] as Array<unknown>;
|
|
expect(firstPayload).toHaveLength(100);
|
|
expect(secondPayload).toHaveLength(80);
|
|
expect(runtimeLog).toHaveBeenCalledWith(
|
|
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
|
|
);
|
|
});
|
|
});
|