Files
openclaw/extensions/discord/src/monitor/native-command.options.test.ts
2026-03-26 15:59:51 -05:00

234 lines
7.1 KiB
TypeScript

import { ChannelType } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
const { logVerboseMock } = vi.hoisted(() => ({
logVerboseMock: vi.fn(),
}));
const { loggerWarnMock } = vi.hoisted(() => ({
loggerWarnMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
"openclaw/plugin-sdk/runtime-env",
);
return {
...actual,
createSubsystemLogger: () => ({
child: vi.fn(),
info: vi.fn(),
error: vi.fn(),
warn: loggerWarnMock,
debug: vi.fn(),
}),
logVerbose: logVerboseMock,
};
});
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
function createNativeCommand(
name: string,
opts?: {
cfg?: ReturnType<typeof loadConfig>;
discordConfig?: NonNullable<OpenClawConfig["channels"]>["discord"];
},
): ReturnType<typeof import("./native-command.js").createDiscordNativeCommand> {
const command = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === name,
);
if (!command) {
throw new Error(`missing native command: ${name}`);
}
const cfg = (opts?.cfg ?? {}) as ReturnType<typeof loadConfig>;
const discordConfig = (opts?.discordConfig ?? {}) as NonNullable<
OpenClawConfig["channels"]
>["discord"];
return createDiscordNativeCommand({
command,
cfg,
discordConfig,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
}
type CommandOption = NonNullable<
ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>["options"]
>[number];
function findOption(
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption | undefined {
return command.options?.find((entry) => entry.name === name);
}
function requireOption(
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption {
const option = findOption(command, name);
if (!option) {
throw new Error(`missing command option: ${name}`);
}
return option;
}
function readAutocomplete(option: CommandOption | undefined): unknown {
if (!option || typeof option !== "object") {
return undefined;
}
return (option as { autocomplete?: unknown }).autocomplete;
}
function readChoices(option: CommandOption | undefined): unknown[] | undefined {
if (!option || typeof option !== "object") {
return undefined;
}
const value = (option as { choices?: unknown }).choices;
return Array.isArray(value) ? value : undefined;
}
describe("createDiscordNativeCommand option wiring", () => {
beforeAll(async () => {
({ listNativeCommandSpecs } = await import("../../../../src/auto-reply/commands-registry.js"));
({ createDiscordNativeCommand } = await import("./native-command.js"));
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
});
beforeEach(() => {
logVerboseMock.mockReset();
loggerWarnMock.mockReset();
});
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
const command = createNativeCommand("acp");
const action = requireOption(command, "action");
const autocomplete = readAutocomplete(action);
if (typeof autocomplete !== "function") {
throw new Error("acp action option did not wire autocomplete");
}
const respond = vi.fn(async (_choices: unknown[]) => undefined);
expect(readChoices(action)).toBeUndefined();
await autocomplete({
user: {
id: "owner",
username: "tester",
globalName: "Tester",
},
channel: {
type: ChannelType.DM,
id: "dm-1",
},
guild: undefined,
rawData: {},
options: {
getFocused: () => ({ value: "st" }),
},
respond,
client: {},
} as never);
expect(respond).toHaveBeenCalledWith([
{ name: "steer", value: "steer" },
{ name: "status", value: "status" },
{ name: "install", value: "install" },
]);
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("voice");
const action = requireOption(command, "action");
const choices = readChoices(action);
expect(readAutocomplete(action)).toBeUndefined();
expect(choices).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: expect.any(String), value: expect.any(String) }),
]),
);
});
it("returns no autocomplete choices for unauthorized users", async () => {
const command = createNativeCommand("think", {
cfg: {
commands: {
allowFrom: {
discord: ["user:allowed-user"],
},
},
} as ReturnType<typeof loadConfig>,
});
const level = requireOption(command, "level");
const autocomplete = readAutocomplete(level);
if (typeof autocomplete !== "function") {
throw new Error("think level option did not wire autocomplete");
}
const respond = vi.fn(async (_choices: unknown[]) => undefined);
await autocomplete({
user: {
id: "blocked-user",
username: "blocked",
globalName: "Blocked",
},
channel: {
type: ChannelType.GuildText,
id: "channel-1",
name: "general",
},
guild: {
id: "guild-1",
},
rawData: {
member: { roles: [] },
},
options: {
getFocused: () => ({ value: "xh" }),
},
respond,
client: {},
} as never);
expect(respond).toHaveBeenCalledWith([]);
});
it("truncates Discord command and option descriptions to Discord's limit", () => {
const longDescription = "x".repeat(140);
const cfg = {} as ReturnType<typeof loadConfig>;
const discordConfig = {} as NonNullable<OpenClawConfig["channels"]>["discord"];
const command = createDiscordNativeCommand({
command: {
name: "longdesc",
description: longDescription,
acceptsArgs: true,
args: [
{
name: "input",
description: longDescription,
type: "string",
required: false,
},
],
},
cfg,
discordConfig,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
expect(command.description).toHaveLength(100);
expect(command.description).toBe("x".repeat(100));
expect(requireOption(command, "input").description).toHaveLength(100);
expect(requireOption(command, "input").description).toBe("x".repeat(100));
});
});