mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 15:43:57 -06:00
cfb19c7054
Preserve custom, native, plugin, and alias precedence while keeping the documented /skill fallback across local and Telegram-driven menu pressure.
492 lines
19 KiB
TypeScript
492 lines
19 KiB
TypeScript
// Telegram tests cover bot native command menu plugin behavior.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildCappedTelegramMenuCommands,
|
|
buildPluginTelegramMenuCommands,
|
|
syncTelegramMenuCommands,
|
|
} from "./bot-native-command-menu.js";
|
|
|
|
const TELEGRAM_COMMAND_TEXT_LIMIT = 5700;
|
|
|
|
function waitForTelegramMenu(assertion: () => void) {
|
|
return vi.waitFor(assertion, { interval: 1 });
|
|
}
|
|
|
|
function waitForTelegramMenuTurn() {
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
type SyncMenuOptions = {
|
|
deleteMyCommands: ReturnType<typeof vi.fn>;
|
|
setMyCommands: ReturnType<typeof vi.fn>;
|
|
commandsToRegister: Parameters<typeof syncTelegramMenuCommands>[0]["commandsToRegister"];
|
|
accountId: string;
|
|
botToken: string;
|
|
runtimeLog?: ReturnType<typeof vi.fn>;
|
|
runtimeError?: 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: options.runtimeError ?? vi.fn(),
|
|
exit: vi.fn(),
|
|
} as Parameters<typeof syncTelegramMenuCommands>[0]["runtime"],
|
|
commandsToRegister: options.commandsToRegister,
|
|
accountId: options.accountId,
|
|
botToken: options.botToken,
|
|
});
|
|
}
|
|
|
|
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 buildSkillRetryCommands(params: {
|
|
nativeCount: number;
|
|
skillCount: number;
|
|
pluginCount: number;
|
|
}): SyncMenuOptions["commandsToRegister"] {
|
|
return [
|
|
{ command: "custom_one", description: "Custom one" },
|
|
{ command: "custom_two", description: "Custom two" },
|
|
...Array.from({ length: params.nativeCount }, (_, index) => ({
|
|
command: `native_${index}`,
|
|
description: `Native ${index}`,
|
|
})),
|
|
{ command: "skill", description: "Run a skill" },
|
|
...Array.from({ length: params.skillCount }, (_, index) => ({
|
|
command: `skill_${index}`,
|
|
description: `Skill ${index}`,
|
|
isSkill: true,
|
|
})),
|
|
...Array.from({ length: params.pluginCount }, (_, index) => ({
|
|
command: `plugin_${index}`,
|
|
description: `Plugin ${index}`,
|
|
})),
|
|
];
|
|
}
|
|
|
|
describe("bot-native-command-menu", () => {
|
|
const canonicalCommands = Array.from({ length: 100 }, (_, index) => ({
|
|
command: `canonical_${index}`,
|
|
description: `Canonical ${index}`,
|
|
}));
|
|
it.each([
|
|
{
|
|
label: ">100 count cap",
|
|
allCommands: [
|
|
{ command: "configured", description: "Configured" },
|
|
...canonicalCommands,
|
|
{ command: "late_alias", description: "Alias", isAlias: true },
|
|
],
|
|
maxTotalChars: TELEGRAM_COMMAND_TEXT_LIMIT,
|
|
retry: false,
|
|
expected: ["configured", ...canonicalCommands.slice(0, 99).map(({ command }) => command)],
|
|
},
|
|
{
|
|
label: "sub-100 text omission",
|
|
allCommands: [
|
|
{ command: "custom_last", description: "Configured" },
|
|
{ command: "canonical_later", description: "Canonical" },
|
|
{ command: "late_alias", description: "Alias", isAlias: true },
|
|
],
|
|
maxTotalChars: 28,
|
|
retry: false,
|
|
expected: ["custom_last", "canonical_later"],
|
|
},
|
|
{
|
|
label: "BOT_COMMANDS_TOO_MUCH retry",
|
|
allCommands: [
|
|
{ command: "configured", description: "Configured" },
|
|
{ command: "canonical_a", description: "Canonical A" },
|
|
{ command: "canonical_b", description: "Canonical B" },
|
|
{ command: "canonical_c", description: "Canonical C" },
|
|
{ command: "late_alias", description: "Alias", isAlias: true },
|
|
],
|
|
maxTotalChars: TELEGRAM_COMMAND_TEXT_LIMIT,
|
|
retry: true,
|
|
expected: ["configured", "canonical_a", "canonical_b", "canonical_c"],
|
|
},
|
|
{
|
|
label: "no pressure",
|
|
allCommands: [
|
|
{ command: "configured", description: "Configured" },
|
|
{ command: "canonical", description: "Canonical" },
|
|
{ command: "plugin", description: "Plugin" },
|
|
{ command: "late_alias", description: "🦞".repeat(250), isAlias: true },
|
|
],
|
|
maxTotalChars: TELEGRAM_COMMAND_TEXT_LIMIT,
|
|
retry: false,
|
|
expected: ["configured", "canonical", "plugin", "late_alias"],
|
|
},
|
|
])("preserves canonical source order for $label", async (testCase) => {
|
|
const result = buildCappedTelegramMenuCommands({
|
|
allCommands: testCase.allCommands,
|
|
maxTotalChars: testCase.maxTotalChars,
|
|
});
|
|
if (!testCase.retry) {
|
|
expect(result.commandsToRegister.map(({ command }) => command)).toEqual(testCase.expected);
|
|
if (testCase.label === "no pressure") {
|
|
expect(result.commandsToRegister).toEqual(testCase.allCommands);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const setMyCommands = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
|
.mockResolvedValue(undefined);
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands: vi.fn(async () => undefined),
|
|
setMyCommands,
|
|
commandsToRegister: result.commandsToRegister,
|
|
accountId: `test-pressure-${Date.now()}`,
|
|
botToken: "bot-a",
|
|
});
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3));
|
|
const retryPayload = setMyCommandsPayload(setMyCommands, 1);
|
|
expect(retryPayload.map((command) => (command as { command: string }).command)).toEqual(
|
|
testCase.expected,
|
|
);
|
|
expect(setMyCommandsPayload(setMyCommands, 2)).toEqual(retryPayload);
|
|
expect(retryPayload.every((command) => Object.keys(command as object).length === 2)).toBe(true);
|
|
});
|
|
|
|
it("promotes /skill when local fitting omits every direct skill", () => {
|
|
const configured = Array.from({ length: 100 }, (_, index) => ({
|
|
command: `configured_${index}`,
|
|
description: `Configured ${index}`,
|
|
}));
|
|
const result = buildCappedTelegramMenuCommands({
|
|
allCommands: [
|
|
...configured,
|
|
{ command: "skill", description: "Run a skill" },
|
|
{ command: "direct_one", description: "Direct one", isSkill: true },
|
|
{ command: "direct_two", description: "Direct two", isSkill: true },
|
|
],
|
|
});
|
|
|
|
expect(result.skillCommandsOmitted).toBe(true);
|
|
expect(result.commandsToRegister.map((command) => command.command)).toEqual([
|
|
"skill",
|
|
...configured.slice(0, 99).map((command) => command.command),
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "partial direct-skill prefix",
|
|
nativeCount: 67,
|
|
source: buildSkillRetryCommands({ nativeCount: 67, skillCount: 20, pluginCount: 10 }),
|
|
expectedPluginTail: Array.from({ length: 10 }, (_, index) => `plugin_${index}`),
|
|
},
|
|
{
|
|
label: "zero direct skills retained",
|
|
nativeCount: 77,
|
|
source: buildSkillRetryCommands({ nativeCount: 77, skillCount: 10, pluginCount: 10 }),
|
|
expectedPluginTail: [] as string[],
|
|
},
|
|
])("collapses $label on BOT_COMMANDS_TOO_MUCH", async (testCase) => {
|
|
const setMyCommands = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
|
.mockResolvedValue(undefined);
|
|
const runtimeLog = vi.fn();
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands: vi.fn(async () => undefined),
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: testCase.source,
|
|
accountId: `test-skill-retry-${testCase.label}-${Date.now()}`,
|
|
botToken: "bot-skill-retry",
|
|
});
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3));
|
|
|
|
const retryPayload = setMyCommandsPayload(setMyCommands, 1);
|
|
const retryNames = retryPayload.map((command) => (command as { command: string }).command);
|
|
expect(retryNames).toEqual([
|
|
"skill",
|
|
"custom_one",
|
|
"custom_two",
|
|
...Array.from({ length: testCase.nativeCount }, (_, index) => `native_${index}`),
|
|
...testCase.expectedPluginTail,
|
|
]);
|
|
expect(retryNames.some((command) => command.startsWith("skill_"))).toBe(false);
|
|
expect(setMyCommandsCall(setMyCommands, 1).at(1)).toBeUndefined();
|
|
expect(setMyCommandsPayload(setMyCommands, 2)).toEqual(retryPayload);
|
|
expect(setMyCommandsCall(setMyCommands, 2).at(1)).toEqual({
|
|
scope: { type: "all_group_chats" },
|
|
});
|
|
expect(runtimeLog.mock.calls.map(([message]) => message)).toEqual([
|
|
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
|
|
"Telegram accepted 80 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 20). Reduce plugin/skill/custom commands to expose more menu entries.",
|
|
]);
|
|
});
|
|
|
|
it("logs and progresses from the actual underfilled retry length", async () => {
|
|
const setMyCommands = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
|
.mockResolvedValue(undefined);
|
|
const runtimeLog = vi.fn();
|
|
const source = buildSkillRetryCommands({ nativeCount: 37, skillCount: 60, pluginCount: 0 });
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands: vi.fn(async () => undefined),
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: source,
|
|
accountId: `test-underfilled-skill-retry-${Date.now()}`,
|
|
botToken: "bot-underfilled-skill-retry",
|
|
});
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(3));
|
|
|
|
const retryPayload = setMyCommandsPayload(setMyCommands, 1);
|
|
const retryNames = retryPayload.map((command) => (command as { command: string }).command);
|
|
expect(retryNames).toEqual([
|
|
"skill",
|
|
"custom_one",
|
|
"custom_two",
|
|
...Array.from({ length: 37 }, (_, index) => `native_${index}`),
|
|
]);
|
|
expect(retryNames.some((command) => command.startsWith("skill_"))).toBe(false);
|
|
expect(setMyCommandsCall(setMyCommands, 1).at(1)).toBeUndefined();
|
|
expect(setMyCommandsPayload(setMyCommands, 2)).toEqual(retryPayload);
|
|
expect(setMyCommandsCall(setMyCommands, 2).at(1)).toEqual({
|
|
scope: { type: "all_group_chats" },
|
|
});
|
|
expect(runtimeLog.mock.calls.map(([message]) => message)).toEqual([
|
|
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 40.",
|
|
"Telegram accepted 40 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 60). Reduce plugin/skill/custom commands to expose more menu entries.",
|
|
]);
|
|
});
|
|
|
|
it("refills a second BOT_COMMANDS_TOO_MUCH reduction from the original catalog", async () => {
|
|
const setMyCommands = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: BOT_COMMANDS_TOO_MUCH"))
|
|
.mockResolvedValue(undefined);
|
|
const runtimeLog = vi.fn();
|
|
const source = buildSkillRetryCommands({ nativeCount: 57, skillCount: 20, pluginCount: 20 });
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands: vi.fn(async () => undefined),
|
|
setMyCommands,
|
|
runtimeLog,
|
|
commandsToRegister: source,
|
|
accountId: `test-second-skill-retry-${Date.now()}`,
|
|
botToken: "bot-second-skill-retry",
|
|
});
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(4));
|
|
|
|
const firstRetryNames = setMyCommandsPayload(setMyCommands, 1).map(
|
|
(command) => (command as { command: string }).command,
|
|
);
|
|
expect(firstRetryNames).toHaveLength(80);
|
|
expect(firstRetryNames.filter((command) => command.startsWith("skill_"))).toHaveLength(20);
|
|
expect(firstRetryNames.some((command) => command.startsWith("plugin_"))).toBe(false);
|
|
const secondRetryPayload = setMyCommandsPayload(setMyCommands, 2);
|
|
const secondRetryNames = secondRetryPayload.map(
|
|
(command) => (command as { command: string }).command,
|
|
);
|
|
expect(secondRetryNames).toHaveLength(64);
|
|
expect(secondRetryNames.slice(0, 3)).toEqual(["skill", "custom_one", "custom_two"]);
|
|
expect(secondRetryNames.some((command) => command.startsWith("skill_"))).toBe(false);
|
|
expect(secondRetryNames.slice(-4)).toEqual(
|
|
Array.from({ length: 4 }, (_, index) => `plugin_${index}`),
|
|
);
|
|
expect(setMyCommandsCall(setMyCommands, 2).at(1)).toBeUndefined();
|
|
expect(setMyCommandsPayload(setMyCommands, 3)).toEqual(secondRetryPayload);
|
|
expect(setMyCommandsCall(setMyCommands, 3).at(1)).toEqual({
|
|
scope: { type: "all_group_chats" },
|
|
});
|
|
expect(runtimeLog.mock.calls.map(([message]) => message)).toEqual([
|
|
"Telegram rejected 100 commands (BOT_COMMANDS_TOO_MUCH); retrying with 80.",
|
|
"Telegram rejected 80 commands (BOT_COMMANDS_TOO_MUCH); retrying with 64.",
|
|
"Telegram accepted 64 commands after BOT_COMMANDS_TOO_MUCH (started with 100; omitted 36). Reduce plugin/skill/custom commands to expose more menu entries.",
|
|
]);
|
|
});
|
|
|
|
it("hashes effective localizations independently of insertion order", async () => {
|
|
const deleteMyCommands = vi.fn(async () => undefined);
|
|
const setMyCommands = vi.fn(async () => undefined);
|
|
const accountId = `test-localization-hash-${Date.now()}`;
|
|
const sync = (descriptionLocalizations: Record<string, string>) =>
|
|
syncMenuCommandsWithMocks({
|
|
deleteMyCommands,
|
|
setMyCommands,
|
|
accountId,
|
|
botToken: "bot-localization-hash",
|
|
commandsToRegister: [
|
|
{
|
|
command: "localized",
|
|
description: "Default|value\0",
|
|
descriptionLocalizations,
|
|
},
|
|
],
|
|
});
|
|
|
|
sync({ fr: "Français|value\0", ko: "한국어" });
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(6));
|
|
sync({ ko: "한국어", " FR ": " Français|value\0 " });
|
|
await waitForTelegramMenuTurn();
|
|
expect(setMyCommands).toHaveBeenCalledTimes(6);
|
|
sync({ ko: "변경됨", fr: "Français|value\0" });
|
|
await waitForTelegramMenu(() => expect(setMyCommands).toHaveBeenCalledTimes(12));
|
|
});
|
|
|
|
it("does not reuse cached capped results for delimiter-like descriptions", () => {
|
|
const first = buildCappedTelegramMenuCommands({
|
|
allCommands: [{ command: "a", description: "b\0c\0d" }],
|
|
});
|
|
const second = buildCappedTelegramMenuCommands({
|
|
allCommands: [
|
|
{ command: "a", description: "b" },
|
|
{ command: "c", description: "d" },
|
|
],
|
|
});
|
|
|
|
expect(first.commandsToRegister).toEqual([{ command: "a", description: "b\0c\0d" }]);
|
|
expect(second.commandsToRegister).toEqual([
|
|
{ command: "a", description: "b" },
|
|
{ command: "c", description: "d" },
|
|
]);
|
|
});
|
|
|
|
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("preserves plugin command description localizations for Telegram menu sync", () => {
|
|
const result = buildPluginTelegramMenuCommands({
|
|
specs: [
|
|
{
|
|
name: "valid",
|
|
description: "Works",
|
|
descriptionLocalizations: { ko: "작동함" },
|
|
},
|
|
],
|
|
existingCommands: new Set<string>(),
|
|
});
|
|
|
|
expect(result.commands).toEqual([
|
|
{
|
|
command: "valid",
|
|
description: "Works",
|
|
descriptionLocalizations: { ko: "작동함" },
|
|
},
|
|
]);
|
|
expect(result.issues).toStrictEqual([]);
|
|
});
|
|
|
|
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).toStrictEqual([]);
|
|
});
|
|
|
|
it("sorts plugin commands deterministically and prefers exact normalized identities", () => {
|
|
const specs = [
|
|
{ name: "zeta", description: "Zeta" },
|
|
{
|
|
name: "foo-bar",
|
|
description: "Transformed alias",
|
|
descriptionLocalizations: { ko: "변환됨" },
|
|
},
|
|
{
|
|
name: "foo_bar",
|
|
description: "Exact owner",
|
|
descriptionLocalizations: { ko: "정확함" },
|
|
},
|
|
{ name: "alpha", description: "Alpha" },
|
|
];
|
|
const build = (orderedSpecs: typeof specs) =>
|
|
buildPluginTelegramMenuCommands({
|
|
specs: orderedSpecs,
|
|
existingCommands: new Set<string>(),
|
|
});
|
|
|
|
const forward = build(specs);
|
|
const reverse = build(specs.toReversed());
|
|
|
|
expect(forward).toEqual(reverse);
|
|
expect(forward.commands).toEqual([
|
|
{ command: "alpha", description: "Alpha" },
|
|
{
|
|
command: "foo_bar",
|
|
description: "Exact owner",
|
|
descriptionLocalizations: { ko: "정확함" },
|
|
},
|
|
{ command: "zeta", description: "Zeta" },
|
|
]);
|
|
expect(forward.issues).toContain('Plugin command "/foo_bar" is duplicated.');
|
|
});
|
|
|
|
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).',
|
|
);
|
|
});
|
|
});
|