mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-11 09:11:13 +00:00
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
|
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
|
|
import type { ResolvedDiscordAccount } from "./accounts.js";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
let discordPlugin: typeof import("./channel.js").discordPlugin;
|
|
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
|
|
|
|
const probeDiscordMock = vi.hoisted(() => vi.fn());
|
|
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
|
|
const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn());
|
|
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
|
|
|
|
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
|
return {
|
|
...actual,
|
|
sleepWithAbort: sleepWithAbortMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./probe.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./probe.js")>();
|
|
return {
|
|
...actual,
|
|
probeDiscord: probeDiscordMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./monitor/provider.runtime.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./monitor/provider.runtime.js")>();
|
|
return {
|
|
...actual,
|
|
monitorDiscordProvider: monitorDiscordProviderMock,
|
|
};
|
|
});
|
|
|
|
vi.mock("./audit.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("./audit.js")>();
|
|
return {
|
|
...actual,
|
|
auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock,
|
|
};
|
|
});
|
|
|
|
function createCfg(): OpenClawConfig {
|
|
return {
|
|
channels: {
|
|
discord: {
|
|
enabled: true,
|
|
token: "discord-token",
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
}
|
|
|
|
function resolveAccount(cfg: OpenClawConfig, accountId = "default"): ResolvedDiscordAccount {
|
|
return discordPlugin.config.resolveAccount(cfg, accountId) as ResolvedDiscordAccount;
|
|
}
|
|
|
|
function startDiscordAccount(cfg: OpenClawConfig, accountId = "default") {
|
|
return discordPlugin.gateway!.startAccount!(
|
|
createStartAccountContext({
|
|
account: resolveAccount(cfg, accountId),
|
|
cfg,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function installDiscordRuntime(discord: Record<string, unknown>) {
|
|
setDiscordRuntime({
|
|
channel: {
|
|
discord,
|
|
},
|
|
logging: {
|
|
shouldLogVerbose: () => false,
|
|
},
|
|
} as unknown as PluginRuntime);
|
|
}
|
|
|
|
afterEach(() => {
|
|
probeDiscordMock.mockReset();
|
|
monitorDiscordProviderMock.mockReset();
|
|
auditDiscordChannelPermissionsMock.mockReset();
|
|
sleepWithAbortMock.mockReset();
|
|
sleepWithAbortMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.useRealTimers();
|
|
installDiscordRuntime({});
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
({ discordPlugin } = await import("./channel.js"));
|
|
({ setDiscordRuntime } = await import("./runtime.js"));
|
|
});
|
|
|
|
describe("discordPlugin outbound", () => {
|
|
it("honors per-account replyToMode overrides", () => {
|
|
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
|
|
if (!resolveReplyToMode) {
|
|
throw new Error("Expected discordPlugin.threading.resolveReplyToMode to be defined");
|
|
}
|
|
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
replyToMode: "all",
|
|
token: "discord-token",
|
|
accounts: {
|
|
work: {
|
|
token: "discord-token-work",
|
|
replyToMode: "first",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(resolveReplyToMode({ cfg, accountId: "work" })).toBe("first");
|
|
expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
|
|
});
|
|
|
|
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
|
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
|
|
|
const result = await discordPlugin.outbound!.sendMedia!({
|
|
cfg: {} as OpenClawConfig,
|
|
to: "channel:123",
|
|
text: "hi",
|
|
mediaUrl: "/tmp/image.png",
|
|
mediaLocalRoots: ["/tmp/agent-root"],
|
|
accountId: "work",
|
|
deps: {
|
|
discord: sendMessageDiscord,
|
|
},
|
|
});
|
|
|
|
expect(sendMessageDiscord).toHaveBeenCalledWith(
|
|
"channel:123",
|
|
"hi",
|
|
expect.objectContaining({
|
|
mediaUrl: "/tmp/image.png",
|
|
mediaLocalRoots: ["/tmp/agent-root"],
|
|
}),
|
|
);
|
|
expect(result).toMatchObject({ channel: "discord", messageId: "m1" });
|
|
});
|
|
|
|
it("uses direct Discord probe helpers for status probes", async () => {
|
|
const runtimeProbeDiscord = vi.fn(async () => {
|
|
throw new Error("runtime Discord probe should not be used");
|
|
});
|
|
installDiscordRuntime({
|
|
probeDiscord: runtimeProbeDiscord,
|
|
});
|
|
probeDiscordMock.mockResolvedValue({
|
|
ok: true,
|
|
bot: { username: "Bob" },
|
|
application: {
|
|
intents: {
|
|
messageContent: "limited",
|
|
guildMembers: "disabled",
|
|
presence: "disabled",
|
|
},
|
|
},
|
|
elapsedMs: 1,
|
|
});
|
|
|
|
const cfg = createCfg();
|
|
const account = resolveAccount(cfg);
|
|
|
|
await discordPlugin.status!.probeAccount!({
|
|
account,
|
|
timeoutMs: 5000,
|
|
cfg,
|
|
});
|
|
|
|
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, {
|
|
includeApplication: true,
|
|
});
|
|
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses direct Discord startup helpers before monitoring", async () => {
|
|
const runtimeProbeDiscord = vi.fn(async () => {
|
|
throw new Error("runtime Discord probe should not be used");
|
|
});
|
|
const runtimeMonitorDiscordProvider = vi.fn(async () => {
|
|
throw new Error("runtime Discord monitor should not be used");
|
|
});
|
|
installDiscordRuntime({
|
|
probeDiscord: runtimeProbeDiscord,
|
|
monitorDiscordProvider: runtimeMonitorDiscordProvider,
|
|
});
|
|
probeDiscordMock.mockResolvedValue({
|
|
ok: true,
|
|
bot: { username: "Bob" },
|
|
application: {
|
|
intents: {
|
|
messageContent: "limited",
|
|
guildMembers: "disabled",
|
|
presence: "disabled",
|
|
},
|
|
},
|
|
elapsedMs: 1,
|
|
});
|
|
monitorDiscordProviderMock.mockResolvedValue(undefined);
|
|
|
|
const cfg = createCfg();
|
|
await startDiscordAccount(cfg);
|
|
|
|
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
|
|
includeApplication: true,
|
|
});
|
|
expect(monitorDiscordProviderMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
token: "discord-token",
|
|
accountId: "default",
|
|
}),
|
|
);
|
|
expect(sleepWithAbortMock).not.toHaveBeenCalled();
|
|
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
|
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stagger starts later accounts in multi-bot setups", async () => {
|
|
probeDiscordMock.mockResolvedValue({
|
|
ok: true,
|
|
bot: { username: "Cherry" },
|
|
application: {
|
|
intents: {
|
|
messageContent: "limited",
|
|
guildMembers: "disabled",
|
|
presence: "disabled",
|
|
},
|
|
},
|
|
elapsedMs: 1,
|
|
});
|
|
monitorDiscordProviderMock.mockResolvedValue(undefined);
|
|
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
accounts: {
|
|
// "alpha" sorts before "zeta" so alpha is index 0, zeta is index 1
|
|
alpha: { token: "Bot alpha-token", enabled: true },
|
|
zeta: { token: "Bot zeta-token", enabled: true },
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
// First account (index 0) — no delay
|
|
await startDiscordAccount(cfg, "alpha");
|
|
expect(sleepWithAbortMock).not.toHaveBeenCalled();
|
|
|
|
// Second account (index 1) — 10s delay
|
|
await startDiscordAccount(cfg, "zeta");
|
|
expect(sleepWithAbortMock).toHaveBeenCalledWith(10_000, expect.any(Object));
|
|
});
|
|
});
|
|
|
|
describe("discordPlugin bindings", () => {
|
|
it("preserves user-prefixed current conversation ids for DM binds", () => {
|
|
const result = discordPlugin.bindings?.resolveCommandConversation?.({
|
|
accountId: "default",
|
|
originatingTo: "user:123456789012345678",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
conversationId: "user:123456789012345678",
|
|
});
|
|
});
|
|
|
|
it("preserves channel-prefixed current conversation ids for channel binds", () => {
|
|
const result = discordPlugin.bindings?.resolveCommandConversation?.({
|
|
accountId: "default",
|
|
originatingTo: "channel:987654321098765432",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
conversationId: "channel:987654321098765432",
|
|
});
|
|
});
|
|
|
|
it("preserves channel-prefixed parent ids for thread binds", () => {
|
|
const result = discordPlugin.bindings?.resolveCommandConversation?.({
|
|
accountId: "default",
|
|
originatingTo: "channel:thread-42",
|
|
threadId: "thread-42",
|
|
threadParentId: "parent-9",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
conversationId: "thread-42",
|
|
parentConversationId: "channel:parent-9",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("discordPlugin security", () => {
|
|
it("normalizes dm allowlist entries with trimmed prefixes and mentions", () => {
|
|
const resolveDmPolicy = discordPlugin.security?.resolveDmPolicy;
|
|
if (!resolveDmPolicy) {
|
|
throw new Error("resolveDmPolicy unavailable");
|
|
}
|
|
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
token: "discord-token",
|
|
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] },
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const result = resolveDmPolicy({
|
|
cfg,
|
|
account: discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount,
|
|
});
|
|
if (!result) {
|
|
throw new Error("discord resolveDmPolicy returned null");
|
|
}
|
|
|
|
expect(result.policy).toBe("allowlist");
|
|
expect(result.allowFrom).toEqual([" discord:<@!123456789> "]);
|
|
expect(result.normalizeEntry?.(" discord:<@!123456789> ")).toBe("123456789");
|
|
expect(result.normalizeEntry?.(" user:987654321 ")).toBe("987654321");
|
|
});
|
|
});
|
|
|
|
describe("discordPlugin groups", () => {
|
|
it("uses plugin-owned group policy resolvers", () => {
|
|
const cfg = {
|
|
channels: {
|
|
discord: {
|
|
token: "discord-test",
|
|
guilds: {
|
|
guild1: {
|
|
requireMention: false,
|
|
tools: { allow: ["message.guild"] },
|
|
channels: {
|
|
"123": {
|
|
requireMention: true,
|
|
tools: { allow: ["message.channel"] },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
expect(
|
|
discordPlugin.groups?.resolveRequireMention?.({
|
|
cfg,
|
|
groupSpace: "guild1",
|
|
groupId: "123",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
discordPlugin.groups?.resolveToolPolicy?.({
|
|
cfg,
|
|
groupSpace: "guild1",
|
|
groupId: "123",
|
|
}),
|
|
).toEqual({ allow: ["message.channel"] });
|
|
});
|
|
});
|