Files
openclaw/extensions/discord/src/actions/runtime.test.ts

1292 lines
36 KiB
TypeScript

import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearPresences, setPresence } from "../monitor/presence-cache.js";
import { DiscordThreadInitialMessageError } from "../send.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordAction } from "./runtime.js";
import {
discordMessagingActionRuntime,
handleDiscordMessagingAction,
} from "./runtime.messaging.js";
import {
discordModerationActionRuntime,
handleDiscordModerationAction,
} from "./runtime.moderation.js";
const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime };
const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime };
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
const discordSendMocks = {
banMemberDiscord: vi.fn(async () => ({})),
createChannelDiscord: vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
})),
createThreadDiscord: vi.fn(async () => ({})),
deleteChannelDiscord: vi.fn(async () => ({ ok: true, channelId: "C1" })),
deleteMessageDiscord: vi.fn(async () => ({})),
editChannelDiscord: vi.fn(async () => ({
id: "C1",
name: "edited",
})),
editMessageDiscord: vi.fn(async () => ({})),
fetchChannelInfoDiscord: vi.fn(async () => ({ id: "C1", type: 0 })),
fetchChannelPermissionsDiscord: vi.fn(async () => ({})),
fetchMessageDiscord: vi.fn(async () => ({})),
fetchReactionsDiscord: vi.fn(async () => ({})),
kickMemberDiscord: vi.fn(async () => ({})),
listGuildChannelsDiscord: vi.fn(async () => []),
listPinsDiscord: vi.fn(async () => ({})),
listThreadsDiscord: vi.fn(async () => ({})),
moveChannelDiscord: vi.fn(async () => ({ ok: true })),
pinMessageDiscord: vi.fn(async () => ({})),
reactMessageDiscord: vi.fn(async () => ({})),
readMessagesDiscord: vi.fn(async () => []),
removeChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
removeOwnReactionsDiscord: vi.fn(async () => ({ removed: ["👍"] })),
removeReactionDiscord: vi.fn(async () => ({})),
searchMessagesDiscord: vi.fn(async () => ({})),
sendDiscordComponentMessage: vi.fn(async () => ({})),
sendMessageDiscord: vi.fn(async () => ({})),
sendPollDiscord: vi.fn(async () => ({})),
sendStickerDiscord: vi.fn(async () => ({})),
sendVoiceMessageDiscord: vi.fn(async () => ({})),
setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
timeoutMemberDiscord: vi.fn(async () => ({})),
unpinMessageDiscord: vi.fn(async () => ({})),
};
const {
createChannelDiscord,
createThreadDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchReactionsDiscord,
fetchMessageDiscord,
kickMemberDiscord,
listGuildChannelsDiscord,
listPinsDiscord,
moveChannelDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeChannelPermissionDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendDiscordComponentMessage,
sendMessageDiscord,
sendPollDiscord,
sendVoiceMessageDiscord,
setChannelPermissionDiscord,
timeoutMemberDiscord,
} = discordSendMocks;
const enableAllActions = () => true;
const DISCORD_TEST_CFG = EMPTY_DISCORD_TEST_CONFIG;
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
const call = source.mock.calls[callIndex];
if (!call) {
throw new Error(`expected ${label} call ${callIndex}`);
}
return call;
}
function mockObjectArg(
source: MockCallSource,
label: string,
callIndex: number,
argIndex: number,
): Record<string, unknown> {
const value = mockCall(source, label, callIndex)[argIndex];
if (!value || typeof value !== "object") {
throw new Error(`expected ${label} call ${callIndex} argument ${argIndex} to be an object`);
}
return value as Record<string, unknown>;
}
function handleMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
cfg: OpenClawConfig = DISCORD_TEST_CFG,
options?: {
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
workspaceDir?: string;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
) {
return handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options);
}
function handleGuildAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
cfg: OpenClawConfig = DISCORD_TEST_CFG,
options?: { mediaLocalRoots?: readonly string[] },
) {
return handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
}
function handleModerationAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean,
cfg: OpenClawConfig = DISCORD_TEST_CFG,
) {
return handleDiscordModerationAction(action, params, isActionEnabled, cfg);
}
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
beforeEach(() => {
vi.clearAllMocks();
clearPresences();
Object.assign(
discordMessagingActionRuntime,
originalDiscordMessagingActionRuntime,
discordSendMocks,
);
Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks);
Object.assign(
discordModerationActionRuntime,
originalDiscordModerationActionRuntime,
discordSendMocks,
);
});
describe("handleDiscordMessagingAction", () => {
it.each([
{
name: "without account",
params: {
channelId: "C1",
messageId: "M1",
emoji: "✅",
},
expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "default" },
},
{
name: "with accountId",
params: {
channelId: "C1",
messageId: "M1",
emoji: "✅",
accountId: "ops",
},
expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "ops" },
},
])("adds reactions $name", async ({ params, expectedOptions }) => {
await handleMessagingAction("react", params, enableAllActions);
if (expectedOptions) {
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions);
return;
}
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
cfg: DISCORD_TEST_CFG,
});
});
it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => {
const cfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: { token: "token-work" },
},
},
},
} as OpenClawConfig;
await handleMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "✅",
},
enableAllActions,
cfg,
);
expect(reactMessageDiscord).toHaveBeenCalledTimes(1);
expect(mockCall(reactMessageDiscord, "reactMessageDiscord")).toEqual([
"C1",
"M1",
"✅",
{ cfg, accountId: "work" },
]);
});
it("resolves Discord DM targets for reaction adds", async () => {
const resolveReactionTarget = vi.fn(async () => "DM1");
discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;
await handleMessagingAction(
"react",
{
to: "user:U1",
messageId: "M1",
emoji: "✅",
},
enableAllActions,
);
expect(resolveReactionTarget).toHaveBeenCalledWith({
target: "user:U1",
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
expect(reactMessageDiscord).toHaveBeenCalledWith("DM1", "M1", "✅", {
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
});
it("resolves Discord DM targets for reaction listing", async () => {
const resolveReactionTarget = vi.fn(async () => "DM1");
discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;
await handleMessagingAction(
"reactions",
{
to: "user:U1",
messageId: "M1",
},
enableAllActions,
);
expect(resolveReactionTarget).toHaveBeenCalledWith({
target: "user:U1",
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
expect(fetchReactionsDiscord).toHaveBeenCalledWith("DM1", "M1", {
cfg: DISCORD_TEST_CFG,
accountId: "default",
limit: undefined,
});
});
it("removes reactions on empty emoji", async () => {
await handleMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "",
},
enableAllActions,
);
expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
});
it("removes reactions when remove flag set", async () => {
await handleMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "✅",
remove: true,
},
enableAllActions,
);
expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
cfg: DISCORD_TEST_CFG,
accountId: "default",
});
});
it("rejects removes without emoji", async () => {
await expect(
handleMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "",
remove: true,
},
enableAllActions,
),
).rejects.toThrow(/Emoji is required/);
});
it("respects reaction gating", async () => {
await expect(
handleMessagingAction(
"react",
{
channelId: "C1",
messageId: "M1",
emoji: "✅",
},
disabledActions,
),
).rejects.toThrow(/Discord reactions are disabled/);
});
it("parses string booleans for poll options", async () => {
await handleMessagingAction(
"poll",
{
to: "channel:123",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
allowMultiselect: "true",
durationHours: "24",
},
enableAllActions,
);
expect(sendPollDiscord).toHaveBeenCalledWith(
"channel:123",
{
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationHours: 24,
},
{ cfg: DISCORD_TEST_CFG, content: undefined },
);
});
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([
{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
] as never);
const result = await handleMessagingAction(
"readMessages",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("threads provided cfg into readMessages calls", async () => {
const cfg = {
channels: {
discord: {
token: "token",
},
},
} as OpenClawConfig;
await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, cfg);
expect(readMessagesDiscord).toHaveBeenCalledWith(
"C1",
{ limit: undefined, before: undefined, after: undefined, around: undefined },
{ cfg },
);
});
it("adds normalized timestamps to fetchMessage payloads", async () => {
fetchMessageDiscord.mockResolvedValueOnce({
id: "1",
timestamp: "2026-01-15T11:00:00.000Z",
});
const result = await handleMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
);
const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };
const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
expect(payload.message?.timestampMs).toBe(expectedMs);
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("threads provided cfg into fetchMessage calls", async () => {
const cfg = {
channels: {
discord: {
token: "token",
},
},
} as OpenClawConfig;
await handleMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
cfg,
);
expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
});
it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
const result = await handleMessagingAction("listPins", { channelId: "C1" }, enableAllActions);
const payload = result.details as {
pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
expect(payload.pins[0].timestampMs).toBe(expectedMs);
expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to searchMessages payloads", async () => {
searchMessagesDiscord.mockResolvedValueOnce({
total_results: 1,
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
});
const result = await handleMessagingAction(
"searchMessages",
{ guildId: "G1", content: "hi" },
enableAllActions,
);
const payload = result.details as {
results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
};
const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
new Date(expectedMs).toISOString(),
);
});
it("sends voice messages from a local file path", async () => {
sendVoiceMessageDiscord.mockClear();
sendMessageDiscord.mockClear();
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
path: "/tmp/voice.mp3",
asVoice: true,
silent: true,
},
enableAllActions,
);
expect(sendVoiceMessageDiscord).toHaveBeenCalledWith("channel:123", "/tmp/voice.mp3", {
cfg: DISCORD_TEST_CFG,
replyTo: undefined,
silent: true,
});
expect(sendMessageDiscord).not.toHaveBeenCalled();
});
it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => {
sendMessageDiscord.mockClear();
const mediaReadFile = vi.fn(async () => Buffer.from("image"));
const mediaAccess = { localRoots: ["/tmp/agent-root"], readFile: mediaReadFile };
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
content: "hello",
mediaUrl: "/tmp/image.png",
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], mediaReadFile },
);
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
expect(call[0]).toBe("channel:123");
expect(call[1]).toBe("hello");
expect(sendOptions.mediaAccess).toBe(mediaAccess);
expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
expect(sendOptions.mediaReadFile).toBe(mediaReadFile);
});
it("allows media-only message sends", async () => {
sendMessageDiscord.mockClear();
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
mediaUrl: "/tmp/image.png",
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
);
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
expect(call[0]).toBe("channel:123");
const content = call[1];
expect(content).toBe("");
expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
});
it("ignores empty components objects for regular media sends", async () => {
sendMessageDiscord.mockClear();
sendDiscordComponentMessage.mockClear();
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
content: "hello",
mediaUrl: "/tmp/image.png",
components: {},
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
);
expect(sendDiscordComponentMessage).not.toHaveBeenCalled();
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
expect(call[0]).toBe("channel:123");
const content = call[1];
expect(content).toBe("hello");
expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
});
it("forwards the optional filename into sendMessageDiscord", async () => {
sendMessageDiscord.mockClear();
await handleMessagingAction(
"sendMessage",
{
to: "channel:123",
content: "hello",
mediaUrl: "/tmp/generated-image",
filename: "image.png",
},
enableAllActions,
);
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
expect(call[0]).toBe("channel:123");
const content = call[1];
expect(content).toBe("hello");
expect(sendOptions.mediaUrl).toBe("/tmp/generated-image");
expect(sendOptions.filename).toBe("image.png");
});
it("renames an existing thread when threadName is provided on sendMessage", async () => {
sendMessageDiscord.mockResolvedValueOnce({
messageId: "M1",
channelId: "T1",
});
fetchChannelInfoDiscord.mockResolvedValueOnce({
id: "T1",
type: 11,
});
editChannelDiscord.mockResolvedValueOnce({
id: "T1",
name: "new-thread",
});
const result = await handleMessagingAction(
"sendMessage",
{
to: "channel:T1",
content: "hello",
threadName: "new-thread",
},
enableAllActions,
);
expect(sendMessageDiscord).toHaveBeenCalledWith("channel:T1", "hello", {
cfg: DISCORD_TEST_CFG,
accountId: undefined,
mediaAccess: undefined,
mediaUrl: undefined,
filename: undefined,
mediaLocalRoots: undefined,
mediaReadFile: undefined,
replyTo: undefined,
components: undefined,
embeds: undefined,
silent: false,
});
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("T1", { cfg: DISCORD_TEST_CFG });
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "T1",
name: "new-thread",
},
{ cfg: DISCORD_TEST_CFG },
);
expect(result.details).toEqual({
ok: true,
result: {
messageId: "M1",
channelId: "T1",
},
threadRename: {
ok: true,
channelId: "T1",
name: "new-thread",
},
});
});
it("warns instead of renaming when threadName is provided but channel management is disabled", async () => {
sendMessageDiscord.mockResolvedValueOnce({
messageId: "M1",
channelId: "T1",
});
const messagesOnly = (key: keyof DiscordActionConfig) => key === "messages";
const result = await handleMessagingAction(
"sendMessage",
{
to: "channel:T1",
content: "hello",
threadName: "new-thread",
},
messagesOnly,
);
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
expect(fetchChannelInfoDiscord).not.toHaveBeenCalled();
expect(editChannelDiscord).not.toHaveBeenCalled();
expect(result.details).toEqual({
ok: true,
result: {
messageId: "M1",
channelId: "T1",
},
warning: "Discord threadName was ignored because Discord channel management is disabled.",
});
});
it("warns instead of renaming when threadName is provided for a non-thread send target", async () => {
sendMessageDiscord.mockResolvedValueOnce({
messageId: "M1",
channelId: "C1",
});
fetchChannelInfoDiscord.mockResolvedValueOnce({
id: "C1",
type: 0,
});
const result = await handleMessagingAction(
"sendMessage",
{
to: "channel:C1",
content: "hello",
threadName: "new-thread",
},
enableAllActions,
);
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG });
expect(editChannelDiscord).not.toHaveBeenCalled();
expect(result.details).toEqual({
ok: true,
result: {
messageId: "M1",
channelId: "C1",
},
warning: "Discord threadName was ignored because the send target is not a thread.",
});
});
it("preserves message delivery and warns when thread rename fails", async () => {
sendMessageDiscord.mockResolvedValueOnce({
messageId: "M1",
channelId: "T1",
});
fetchChannelInfoDiscord.mockResolvedValueOnce({
id: "T1",
type: 11,
});
editChannelDiscord.mockRejectedValueOnce(new Error("missing permissions"));
const result = await handleMessagingAction(
"sendMessage",
{
to: "channel:T1",
content: "hello",
threadName: "new-thread",
},
enableAllActions,
);
expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "T1",
name: "new-thread",
},
{ cfg: DISCORD_TEST_CFG },
);
expect(result.details).toEqual({
ok: true,
result: {
messageId: "M1",
channelId: "T1",
},
warning: "Discord message was sent, but thread rename failed: missing permissions",
});
});
it("rejects voice messages that include content", async () => {
await expect(
handleMessagingAction(
"sendMessage",
{
to: "channel:123",
mediaUrl: "/tmp/voice.mp3",
asVoice: true,
content: "hello",
},
enableAllActions,
),
).rejects.toThrow(/Voice messages cannot include text content/);
});
it("forwards optional thread content", async () => {
createThreadDiscord.mockClear();
await handleMessagingAction(
"threadCreate",
{
channelId: "C1",
name: "Forum thread",
content: "Initial forum post body",
},
enableAllActions,
);
expect(createThreadDiscord).toHaveBeenCalledWith(
"C1",
{
name: "Forum thread",
messageId: undefined,
autoArchiveMinutes: undefined,
content: "Initial forum post body",
appliedTags: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("returns partial success when Discord creates the thread but initial message send fails", async () => {
const thread = { id: "T1", name: "thread", type: 11 };
createThreadDiscord.mockRejectedValueOnce(
new DiscordThreadInitialMessageError(
thread as ConstructorParameters<typeof DiscordThreadInitialMessageError>[0],
new Error("missing access"),
),
);
const result = await handleMessagingAction(
"threadCreate",
{
channelId: "C1",
name: "thread",
content: "Initial post",
},
enableAllActions,
);
expect(result.details).toEqual({
ok: true,
partial: true,
thread,
warning: "Discord thread was created, but sending the initial message failed.",
initialMessageError: "missing access",
});
});
});
describe("handleDiscordGuildAction", () => {
it("uses configured defaultAccount for omitted memberInfo presence lookup", async () => {
setPresence("work", "U1", {
user: { id: "U1" },
guild_id: "G1",
status: "online",
activities: [],
client_status: {},
} as never);
discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({
user: { id: "U1" },
})) as never;
const cfg = {
channels: {
discord: {
defaultAccount: "work",
accounts: {
work: { token: "token-work" },
},
},
},
} as OpenClawConfig;
const result = await handleGuildAction(
"memberInfo",
{
guildId: "G1",
userId: "U1",
},
enableAllActions,
cfg,
);
expect(discordGuildActionRuntime.fetchMemberInfoDiscord).toHaveBeenCalledWith("G1", "U1", {
cfg,
accountId: "work",
});
const details = result.details as Record<string, unknown>;
expect(details.ok).toBe(true);
expect(details.status).toBe("online");
expect(details.activities).toEqual([]);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;
describe("handleDiscordGuildAction - channel management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a channel", async () => {
const result = await handleGuildAction(
"channelCreate",
{
guildId: "G1",
name: "test-channel",
type: 0,
topic: "Test topic",
},
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith(
{
guildId: "G1",
name: "test-channel",
type: 0,
parentId: undefined,
topic: "Test topic",
position: undefined,
nsfw: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
expect(result.details).toEqual({
ok: true,
channel: {
id: "new-channel",
name: "test",
type: 0,
},
});
});
it("respects channel gating for channelCreate", async () => {
await expect(
handleGuildAction("channelCreate", { guildId: "G1", name: "test" }, channelsDisabled),
).rejects.toThrow(/Discord channel management is disabled/);
});
it("forwards accountId for channelList", async () => {
await handleGuildAction("channelList", { guildId: "G1", accountId: "ops" }, channelInfoEnabled);
expect(listGuildChannelsDiscord).toHaveBeenCalledWith("G1", {
cfg: DISCORD_TEST_CFG,
accountId: "ops",
});
});
it("edits a channel", async () => {
await handleGuildAction(
"channelEdit",
{
channelId: "C1",
name: "new-name",
topic: "new topic",
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "C1",
name: "new-name",
topic: "new topic",
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
archived: undefined,
locked: undefined,
autoArchiveDuration: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("forwards thread edit fields", async () => {
await handleGuildAction(
"channelEdit",
{
channelId: "C1",
archived: true,
locked: false,
autoArchiveDuration: 1440,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
archived: true,
locked: false,
autoArchiveDuration: 1440,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it.each([
["parentId is null", { parentId: null }],
["clearParent is true", { clearParent: true }],
])("clears the channel parent when %s", async (_label, payload) => {
await handleGuildAction(
"channelEdit",
{
channelId: "C1",
...payload,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
archived: undefined,
locked: undefined,
autoArchiveDuration: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("deletes a channel", async () => {
await handleGuildAction("channelDelete", { channelId: "C1" }, channelsEnabled);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG });
});
it("moves a channel", async () => {
await handleGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith(
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it.each([
["parentId is null", { parentId: null }],
["clearParent is true", { clearParent: true }],
])("clears the channel parent on move when %s", async (_label, payload) => {
await handleGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
...payload,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith(
{
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("creates a category with type=4", async () => {
await handleGuildAction(
"categoryCreate",
{ guildId: "G1", name: "My Category" },
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith(
{
guildId: "G1",
name: "My Category",
type: 4,
position: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("edits a category", async () => {
await handleGuildAction(
"categoryEdit",
{ categoryId: "CAT1", name: "Renamed Category" },
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith(
{
channelId: "CAT1",
name: "Renamed Category",
position: undefined,
},
{ cfg: DISCORD_TEST_CFG },
);
});
it("deletes a category", async () => {
await handleGuildAction("categoryDelete", { categoryId: "CAT1" }, channelsEnabled);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1", { cfg: DISCORD_TEST_CFG });
});
it.each([
{
name: "role",
params: {
channelId: "C1",
targetId: "R1",
targetType: "role" as const,
allow: "1024",
deny: "2048",
},
expected: {
channelId: "C1",
targetId: "R1",
targetType: 0,
allow: "1024",
deny: "2048",
},
},
{
name: "member",
params: {
channelId: "C1",
targetId: "U1",
targetType: "member" as const,
allow: "1024",
},
expected: {
channelId: "C1",
targetId: "U1",
targetType: 1,
allow: "1024",
deny: undefined,
},
},
])("sets channel permissions for $name", async ({ params, expected }) => {
await handleGuildAction("channelPermissionSet", params, channelsEnabled);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith(expected, {
cfg: DISCORD_TEST_CFG,
});
});
it("removes channel permissions", async () => {
await handleGuildAction(
"channelPermissionRemove",
{ channelId: "C1", targetId: "R1" },
channelsEnabled,
);
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1", {
cfg: DISCORD_TEST_CFG,
});
});
});
describe("handleDiscordModerationAction", () => {
it("forwards accountId for timeout", async () => {
await handleModerationAction(
"timeout",
{
guildId: "G1",
userId: "U1",
durationMinutes: 5,
accountId: "ops",
},
moderationEnabled,
);
expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
expect(params.guildId).toBe("G1");
expect(params.userId).toBe("U1");
expect(params.durationMinutes).toBe(5);
expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({
cfg: DISCORD_TEST_CFG,
accountId: "ops",
});
});
});
describe("handleDiscordAction per-account gating", () => {
it("allows moderation when account config enables it", async () => {
const cfg = {
channels: {
discord: {
accounts: {
ops: { token: "tok-ops", actions: { moderation: true } },
},
},
},
} as OpenClawConfig;
await handleDiscordAction(
{ action: "timeout", guildId: "G1", userId: "U1", durationMinutes: 5, accountId: "ops" },
cfg,
);
expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
expect(params.guildId).toBe("G1");
expect(params.userId).toBe("U1");
expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({
cfg,
accountId: "ops",
});
});
it("blocks moderation when account omits it", async () => {
const cfg = {
channels: {
discord: {
accounts: {
chat: { token: "tok-chat" },
},
},
},
} as OpenClawConfig;
await expect(
handleDiscordAction(
{ action: "timeout", guildId: "G1", userId: "U1", durationMinutes: 5, accountId: "chat" },
cfg,
),
).rejects.toThrow(/Discord moderation is disabled/);
});
it("uses account-merged config, not top-level config", async () => {
// Top-level has no moderation, but the account does
const cfg = {
channels: {
discord: {
token: "tok-base",
accounts: {
ops: { token: "tok-ops", actions: { moderation: true } },
},
},
},
} as OpenClawConfig;
await handleDiscordAction(
{ action: "kick", guildId: "G1", userId: "U1", accountId: "ops" },
cfg,
);
expect(kickMemberDiscord).toHaveBeenCalled();
});
it("inherits top-level channel gate when account overrides moderation only", async () => {
const cfg = {
channels: {
discord: {
actions: { channels: false },
accounts: {
ops: { token: "tok-ops", actions: { moderation: true } },
},
},
},
} as OpenClawConfig;
await expect(
handleDiscordAction(
{ action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
cfg,
),
).rejects.toThrow(/channel management is disabled/i);
});
it("allows account to explicitly re-enable top-level disabled channel gate", async () => {
const cfg = {
channels: {
discord: {
actions: { channels: false },
accounts: {
ops: {
token: "tok-ops",
actions: { moderation: true, channels: true },
},
},
},
},
} as OpenClawConfig;
await handleDiscordAction(
{ action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
cfg,
);
expect(createChannelDiscord).toHaveBeenCalledTimes(1);
const params = mockObjectArg(createChannelDiscord, "createChannelDiscord", 0, 0);
expect(params.guildId).toBe("G1");
expect(params.name).toBe("alerts");
expect(mockCall(createChannelDiscord, "createChannelDiscord")[1]).toEqual({
cfg,
accountId: "ops",
});
});
});