Files
openclaw/src/discord/monitor.test.ts
Robin Waslander 487a3ba8ce fix(discord): enforce users/roles allowlist in reaction ingress
References GHSA-9vvh-2768-c8vp.
2026-03-12 03:13:46 +01:00

1313 lines
40 KiB
TypeScript

import { ChannelType, type Guild } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { typedCases } from "../test-utils/typed-cases.js";
import {
allowListMatches,
buildDiscordMediaPayload,
type DiscordGuildEntryResolved,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
registerDiscordListener,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordReplyTarget,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
sanitizeDiscordThreadName,
shouldEmitDiscordReactionNotification,
} from "./monitor.js";
import { DiscordMessageListener, DiscordReactionListener } from "./monitor/listeners.js";
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
}));
const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild;
const makeEntries = (
entries: Record<string, Partial<DiscordGuildEntryResolved>>,
): Record<string, DiscordGuildEntryResolved> => {
const out: Record<string, DiscordGuildEntryResolved> = {};
for (const [key, value] of Object.entries(entries)) {
out[key] = {
slug: value.slug,
requireMention: value.requireMention,
reactionNotifications: value.reactionNotifications,
users: value.users,
roles: value.roles,
channels: value.channels,
};
}
return out;
};
function createAutoThreadMentionContext() {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
return { guildInfo, channelConfig };
}
describe("registerDiscordListener", () => {
class FakeListener {}
it("dedupes listeners by constructor", () => {
const listeners: object[] = [];
expect(registerDiscordListener(listeners, new FakeListener())).toBe(true);
expect(registerDiscordListener(listeners, new FakeListener())).toBe(false);
expect(listeners).toHaveLength(1);
});
});
describe("DiscordMessageListener", () => {
function createDeferred() {
let resolve: (() => void) | null = null;
const promise = new Promise<void>((done) => {
resolve = done;
});
return {
promise,
resolve: () => {
if (typeof resolve === "function") {
(resolve as () => void)();
}
},
};
}
it("returns immediately while handler continues in background", async () => {
let handlerResolved = false;
const deferred = createDeferred();
const handler = vi.fn(async () => {
await deferred.promise;
handlerResolved = true;
});
const listener = new DiscordMessageListener(handler);
const handlePromise = listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
);
// handle() returns immediately while the background queue starts on the next tick.
await expect(handlePromise).resolves.toBeUndefined();
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledOnce();
});
expect(handlerResolved).toBe(false);
// Release and let background handler finish.
deferred.resolve();
await Promise.resolve();
expect(handlerResolved).toBe(true);
});
it("dispatches subsequent events concurrently without blocking on prior handler", async () => {
const first = createDeferred();
const second = createDeferred();
let runCount = 0;
const handler = vi.fn(async () => {
runCount += 1;
if (runCount === 1) {
await first.promise;
return;
}
await second.promise;
});
const listener = new DiscordMessageListener(handler);
await expect(
listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
),
).resolves.toBeUndefined();
await expect(
listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
),
).resolves.toBeUndefined();
// Both handlers are dispatched concurrently (fire-and-forget).
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(2);
});
first.resolve();
second.resolve();
await Promise.resolve();
});
it("logs handler failures", async () => {
const logger = {
warn: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
const handler = vi.fn(async () => {
throw new Error("boom");
});
const listener = new DiscordMessageListener(handler, logger);
await listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
);
await vi.waitFor(() => {
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed"));
});
});
it("does not apply its own slow-listener logging (owned by inbound worker)", async () => {
const deferred = createDeferred();
const handler = vi.fn(() => deferred.promise);
const logger = {
warn: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
const listener = new DiscordMessageListener(handler, logger);
const handlePromise = listener.handle(
{} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
{} as unknown as import("@buape/carbon").Client,
);
await expect(handlePromise).resolves.toBeUndefined();
deferred.resolve();
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledOnce();
});
// The listener no longer wraps handlers with slow-listener logging;
// that responsibility moved to the inbound worker.
expect(logger.warn).not.toHaveBeenCalled();
});
});
describe("discord allowlist helpers", () => {
it("normalizes slugs", () => {
expect(normalizeDiscordSlug("Friends of OpenClaw")).toBe("friends-of-openclaw");
expect(normalizeDiscordSlug("#General")).toBe("general");
expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
});
it("matches ids by default and names only when enabled", () => {
const allow = normalizeDiscordAllowList(
["123", "steipete", "Friends of OpenClaw"],
["discord:", "user:", "guild:", "channel:"],
);
expect(allow).not.toBeNull();
if (!allow) {
throw new Error("Expected allow list to be normalized");
}
expect(allowListMatches(allow, { id: "123" })).toBe(true);
expect(allowListMatches(allow, { name: "steipete" })).toBe(false);
expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false);
expect(allowListMatches(allow, { name: "steipete" }, { allowNameMatching: true })).toBe(true);
expect(
allowListMatches(allow, { name: "friends-of-openclaw" }, { allowNameMatching: true }),
).toBe(true);
expect(allowListMatches(allow, { name: "other" })).toBe(false);
});
it("matches pk-prefixed allowlist entries", () => {
const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]);
expect(allow).not.toBeNull();
if (!allow) {
throw new Error("Expected allow list to be normalized");
}
expect(allowListMatches(allow, { id: "member-123" })).toBe(true);
expect(allowListMatches(allow, { id: "member-999" })).toBe(false);
});
});
describe("discord guild/channel resolution", () => {
it("resolves guild entry by id", () => {
const guildEntries = makeEntries({
"123": { slug: "friends-of-openclaw" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of OpenClaw"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-openclaw");
});
it("resolves guild entry by slug key", () => {
const guildEntries = makeEntries({
"friends-of-openclaw": { slug: "friends-of-openclaw" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of OpenClaw"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-openclaw");
});
it("falls back to wildcard guild entry", () => {
const guildEntries = makeEntries({
"*": { requireMention: false },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of OpenClaw"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.requireMention).toBe(false);
});
it("resolves channel config by slug", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
help: {
allow: true,
requireMention: true,
skills: ["search"],
enabled: false,
users: ["123"],
systemPrompt: "Use short answers.",
autoThread: true,
},
},
};
const channel = resolveDiscordChannelConfig({
guildInfo,
channelId: "456",
channelName: "General",
channelSlug: "general",
});
expect(channel?.allowed).toBe(true);
expect(channel?.requireMention).toBeUndefined();
const help = resolveDiscordChannelConfig({
guildInfo,
channelId: "789",
channelName: "Help",
channelSlug: "help",
});
expect(help?.allowed).toBe(true);
expect(help?.requireMention).toBe(true);
expect(help?.skills).toEqual(["search"]);
expect(help?.enabled).toBe(false);
expect(help?.users).toEqual(["123"]);
expect(help?.systemPrompt).toBe("Use short answers.");
expect(help?.autoThread).toBe(true);
});
it("denies channel when config present but no match", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
},
};
const channel = resolveDiscordChannelConfig({
guildInfo,
channelId: "999",
channelName: "random",
channelSlug: "random",
});
expect(channel?.allowed).toBe(false);
});
it("treats empty channel config map as no channel allowlist", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {},
};
const channel = resolveDiscordChannelConfig({
guildInfo,
channelId: "999",
channelName: "random",
channelSlug: "random",
});
expect(channel).toBeNull();
});
it("inherits parent config for thread channels", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
random: { allow: false },
},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-123",
channelName: "topic",
channelSlug: "topic",
parentId: "999",
parentName: "random",
parentSlug: "random",
scope: "thread",
});
expect(thread?.allowed).toBe(false);
});
it("does not match thread name/slug when resolving allowlists", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
random: { allow: false },
},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-999",
channelName: "general",
channelSlug: "general",
parentId: "999",
parentName: "random",
parentSlug: "random",
scope: "thread",
});
expect(thread?.allowed).toBe(false);
});
it("applies wildcard channel config when no specific match", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true, requireMention: false },
"*": { allow: true, autoThread: true, requireMention: true },
},
};
// Specific channel should NOT use wildcard
const general = resolveDiscordChannelConfig({
guildInfo,
channelId: "123",
channelName: "general",
channelSlug: "general",
});
expect(general?.allowed).toBe(true);
expect(general?.requireMention).toBe(false);
expect(general?.autoThread).toBeUndefined();
expect(general?.matchSource).toBe("direct");
// Unknown channel should use wildcard
const random = resolveDiscordChannelConfig({
guildInfo,
channelId: "999",
channelName: "random",
channelSlug: "random",
});
expect(random?.allowed).toBe(true);
expect(random?.autoThread).toBe(true);
expect(random?.requireMention).toBe(true);
expect(random?.matchSource).toBe("wildcard");
});
it("falls back to wildcard when thread channel and parent are missing", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
"*": { allow: true, requireMention: false },
},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-123",
channelName: "topic",
channelSlug: "topic",
parentId: "parent-999",
parentName: "general",
parentSlug: "general",
scope: "thread",
});
expect(thread?.allowed).toBe(true);
expect(thread?.matchKey).toBe("*");
expect(thread?.matchSource).toBe("wildcard");
});
it("treats empty channel config map as no thread allowlist", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {},
};
const thread = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-123",
channelName: "topic",
channelSlug: "topic",
parentId: "parent-999",
parentName: "general",
parentSlug: "general",
scope: "thread",
});
expect(thread).toBeNull();
});
});
describe("discord mention gating", () => {
it("requires mention by default", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: false,
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("applies autoThread mention rules based on thread ownership", () => {
const cases = [
{ name: "bot-owned thread", threadOwnerId: "bot123", expected: false },
{ name: "user-owned thread", threadOwnerId: "user456", expected: true },
{ name: "unknown thread owner", threadOwnerId: undefined, expected: true },
] as const;
for (const testCase of cases) {
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: testCase.threadOwnerId,
channelConfig,
guildInfo,
}),
testCase.name,
).toBe(testCase.expected);
}
});
it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
"parent-1": { allow: true, requireMention: false },
},
};
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: "thread-1",
channelName: "topic",
channelSlug: "topic",
parentId: "parent-1",
parentName: "Parent",
parentSlug: "parent",
scope: "thread",
});
expect(channelConfig?.matchSource).toBe("parent");
expect(channelConfig?.matchKey).toBe("parent-1");
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
channelConfig,
guildInfo,
}),
).toBe(false);
});
});
describe("discord groupPolicy gating", () => {
it("applies open/disabled/allowlist policy rules", () => {
const cases = [
{
name: "open policy always allows",
input: {
groupPolicy: "open" as const,
guildAllowlisted: false,
channelAllowlistConfigured: false,
channelAllowed: false,
},
expected: true,
},
{
name: "disabled policy always blocks",
input: {
groupPolicy: "disabled" as const,
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: true,
},
expected: false,
},
{
name: "allowlist blocks when guild not allowlisted",
input: {
groupPolicy: "allowlist" as const,
guildAllowlisted: false,
channelAllowlistConfigured: false,
channelAllowed: true,
},
expected: false,
},
{
name: "allowlist allows when guild allowlisted and no channel allowlist",
input: {
groupPolicy: "allowlist" as const,
guildAllowlisted: true,
channelAllowlistConfigured: false,
channelAllowed: true,
},
expected: true,
},
{
name: "allowlist allows when channel is allowed",
input: {
groupPolicy: "allowlist" as const,
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: true,
},
expected: true,
},
{
name: "allowlist blocks when channel is not allowed",
input: {
groupPolicy: "allowlist" as const,
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: false,
},
expected: false,
},
] as const;
for (const testCase of cases) {
expect(isDiscordGroupAllowedByPolicy(testCase.input), testCase.name).toBe(testCase.expected);
}
});
});
describe("discord group DM gating", () => {
it("allows all when no allowlist", () => {
expect(
resolveGroupDmAllow({
channels: undefined,
channelId: "1",
channelName: "dm",
channelSlug: "dm",
}),
).toBe(true);
});
it("matches group DM allowlist", () => {
expect(
resolveGroupDmAllow({
channels: ["openclaw-dm"],
channelId: "1",
channelName: "OpenClaw DM",
channelSlug: "openclaw-dm",
}),
).toBe(true);
expect(
resolveGroupDmAllow({
channels: ["openclaw-dm"],
channelId: "1",
channelName: "Other",
channelSlug: "other",
}),
).toBe(false);
});
});
describe("discord reply target selection", () => {
it("handles off/first/all reply modes", () => {
const cases = [
{ name: "off mode", replyToMode: "off" as const, hasReplied: false, expected: undefined },
{
name: "first mode before reply",
replyToMode: "first" as const,
hasReplied: false,
expected: "123",
},
{
name: "first mode after reply",
replyToMode: "first" as const,
hasReplied: true,
expected: undefined,
},
{
name: "all mode before reply",
replyToMode: "all" as const,
hasReplied: false,
expected: "123",
},
{
name: "all mode after reply",
replyToMode: "all" as const,
hasReplied: true,
expected: "123",
},
] as const;
for (const testCase of cases) {
expect(
resolveDiscordReplyTarget({
replyToMode: testCase.replyToMode,
replyToId: "123",
hasReplied: testCase.hasReplied,
}),
testCase.name,
).toBe(testCase.expected);
}
});
});
describe("discord autoThread name sanitization", () => {
it("strips mentions and collapses whitespace", () => {
const name = sanitizeDiscordThreadName(" <@123> <@&456> <#789> Help here ", "msg-1");
expect(name).toBe("Help here");
});
it("falls back to thread + id when empty after cleaning", () => {
const name = sanitizeDiscordThreadName(" <@123>", "abc");
expect(name).toBe("Thread abc");
});
});
describe("discord reaction notification gating", () => {
it("applies mode-specific reaction notification rules", () => {
const cases = typedCases<{
name: string;
input: Parameters<typeof shouldEmitDiscordReactionNotification>[0];
expected: boolean;
}>([
{
name: "unset defaults to own (author is bot)",
input: {
mode: undefined,
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-1",
},
expected: true,
},
{
name: "unset defaults to own (author is not bot)",
input: {
mode: undefined,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
},
expected: false,
},
{
name: "off mode",
input: {
mode: "off" as const,
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-1",
},
expected: false,
},
{
name: "all mode",
input: {
mode: "all" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
},
expected: true,
},
{
name: "all mode blocks non-allowlisted guild member",
input: {
mode: "all" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
guildInfo: { users: ["trusted-user"] },
},
expected: false,
},
{
name: "own mode with bot-authored message",
input: {
mode: "own" as const,
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-2",
},
expected: true,
},
{
name: "own mode with non-bot-authored message",
input: {
mode: "own" as const,
botId: "bot-1",
messageAuthorId: "user-2",
userId: "user-3",
},
expected: false,
},
{
name: "own mode still blocks member outside users allowlist",
input: {
mode: "own" as const,
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-3",
guildInfo: { users: ["trusted-user"] },
},
expected: false,
},
{
name: "allowlist mode without match",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
allowlist: [] as string[],
},
expected: false,
},
{
name: "allowlist mode with id match",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "123",
userName: "steipete",
guildInfo: { users: ["123", "other"] },
},
expected: true,
},
{
name: "allowlist mode does not match usernames by default",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "999",
userName: "trusted-user",
guildInfo: { users: ["trusted-user"] },
},
expected: false,
},
{
name: "allowlist mode matches usernames when explicitly enabled",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "999",
userName: "trusted-user",
guildInfo: { users: ["trusted-user"] },
allowNameMatching: true,
},
expected: true,
},
{
name: "allowlist mode matches allowed role",
input: {
mode: "allowlist" as const,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "999",
guildInfo: { roles: ["role:trusted-role"] },
memberRoleIds: ["trusted-role"],
},
expected: true,
},
]);
for (const testCase of cases) {
expect(
shouldEmitDiscordReactionNotification({
...testCase.input,
}),
testCase.name,
).toBe(testCase.expected);
}
});
});
describe("discord media payload", () => {
it("preserves attachment order for MediaPaths/MediaUrls", () => {
const payload = buildDiscordMediaPayload([
{ path: "/tmp/a.png", contentType: "image/png" },
{ path: "/tmp/b.png", contentType: "image/png" },
{ path: "/tmp/c.png", contentType: "image/png" },
]);
expect(payload.MediaPath).toBe("/tmp/a.png");
expect(payload.MediaUrl).toBe("/tmp/a.png");
expect(payload.MediaType).toBe("image/png");
expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"]);
expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"]);
});
});
// --- DM reaction integration tests ---
// These test that handleDiscordReactionEvent (via DiscordReactionListener)
// properly handles DM reactions instead of silently dropping them.
const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
enqueueSystemEventSpy: vi.fn(),
resolveAgentRouteMock: vi.fn((params: unknown) => ({
agentId: "default",
channel: "discord",
accountId: "acc-1",
sessionKey: "discord:acc-1:dm:user-1",
...(typeof params === "object" && params !== null ? { _params: params } : {}),
})),
}));
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventSpy,
}));
vi.mock("../routing/resolve-route.js", () => ({
resolveAgentRoute: resolveAgentRouteMock,
}));
function makeReactionEvent(overrides?: {
guildId?: string;
channelId?: string;
userId?: string;
messageId?: string;
emojiName?: string;
botAsAuthor?: boolean;
messageAuthorId?: string;
messageFetch?: ReturnType<typeof vi.fn>;
guild?: { name?: string; id?: string };
memberRoleIds?: string[];
}) {
const userId = overrides?.userId ?? "user-1";
const messageId = overrides?.messageId ?? "msg-1";
const channelId = overrides?.channelId ?? "channel-1";
const messageFetch =
overrides?.messageFetch ??
vi.fn(async () => ({
author: {
id: overrides?.messageAuthorId ?? (overrides?.botAsAuthor ? "bot-1" : "other-user"),
username: overrides?.botAsAuthor ? "bot" : "otheruser",
discriminator: "0",
},
}));
return {
guild_id: overrides?.guildId,
channel_id: channelId,
message_id: messageId,
emoji: { name: overrides?.emojiName ?? "👍", id: null },
guild: overrides?.guild,
rawMember: overrides?.memberRoleIds ? { roles: overrides.memberRoleIds } : undefined,
user: {
id: userId,
bot: false,
username: "testuser",
discriminator: "0",
},
message: {
fetch: messageFetch,
},
} as unknown as Parameters<DiscordReactionListener["handle"]>[0];
}
function makeReactionClient(options?: {
channelType?: ChannelType;
channelName?: string;
parentId?: string;
parentName?: string;
}) {
const channelType = options?.channelType ?? ChannelType.DM;
const channelName =
options?.channelName ?? (channelType === ChannelType.DM ? undefined : "test-channel");
const parentId = options?.parentId;
const parentName = options?.parentName ?? "parent-channel";
return {
fetchChannel: vi.fn(async (channelId: string) => {
if (parentId && channelId === parentId) {
return { type: ChannelType.GuildText, name: parentName, parentId: undefined };
}
return { type: channelType, name: channelName, parentId };
}),
} as unknown as Parameters<DiscordReactionListener["handle"]>[1];
}
function makeReactionListenerParams(overrides?: {
botUserId?: string;
dmEnabled?: boolean;
groupDmEnabled?: boolean;
groupDmChannels?: string[];
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
allowFrom?: string[];
groupPolicy?: "open" | "allowlist" | "disabled";
allowNameMatching?: boolean;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
}) {
return {
cfg: {} as ReturnType<typeof import("../config/config.js").loadConfig>,
accountId: "acc-1",
runtime: {} as import("../runtime.js").RuntimeEnv,
botUserId: overrides?.botUserId ?? "bot-1",
dmEnabled: overrides?.dmEnabled ?? true,
groupDmEnabled: overrides?.groupDmEnabled ?? true,
groupDmChannels: overrides?.groupDmChannels ?? [],
dmPolicy: overrides?.dmPolicy ?? "open",
allowFrom: overrides?.allowFrom ?? [],
groupPolicy: overrides?.groupPolicy ?? "open",
allowNameMatching: overrides?.allowNameMatching ?? false,
guildEntries: overrides?.guildEntries,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>,
};
}
describe("discord DM reaction handling", () => {
beforeEach(() => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
});
it("processes DM reactions with or without guild allowlists", async () => {
const cases = [
{ name: "no guild allowlist", guildEntries: undefined },
{
name: "guild allowlist configured",
guildEntries: makeEntries({
"guild-123": { slug: "guild-123" },
}),
},
] as const;
for (const testCase of cases) {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(
makeReactionListenerParams({ guildEntries: testCase.guildEntries }),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledOnce();
const [text, opts] = enqueueSystemEventSpy.mock.calls[0];
expect(text, testCase.name).toContain("Discord reaction added");
expect(text, testCase.name).toContain("👍");
expect(text, testCase.name).toContain("dm");
expect(text, testCase.name).not.toContain("undefined");
expect(opts.sessionKey, testCase.name).toBe("discord:acc-1:dm:user-1");
}
});
it("blocks DM reactions when dmPolicy is disabled", async () => {
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(
makeReactionListenerParams({ dmPolicy: "disabled" }),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("blocks DM reactions for unauthorized sender in allowlist mode", async () => {
const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(
makeReactionListenerParams({
dmPolicy: "allowlist",
allowFrom: ["user:user-2"],
}),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("allows DM reactions for authorized sender in allowlist mode", async () => {
const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(
makeReactionListenerParams({
dmPolicy: "allowlist",
allowFrom: ["user:user-1"],
}),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
});
it("blocks group DM reactions when group DMs are disabled", async () => {
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient({ channelType: ChannelType.GroupDM });
const listener = new DiscordReactionListener(
makeReactionListenerParams({ groupDmEnabled: false }),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("blocks guild reactions when groupPolicy is disabled", async () => {
const data = makeReactionEvent({
guildId: "guild-123",
botAsAuthor: true,
guild: { id: "guild-123", name: "Guild" },
});
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const listener = new DiscordReactionListener(
makeReactionListenerParams({ groupPolicy: "disabled" }),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
});
it("blocks guild reactions for sender outside users allowlist", async () => {
const data = makeReactionEvent({
guildId: "guild-123",
userId: "attacker-user",
botAsAuthor: true,
guild: { id: "guild-123", name: "Test Guild" },
});
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const listener = new DiscordReactionListener(
makeReactionListenerParams({
guildEntries: makeEntries({
"guild-123": {
users: ["user:trusted-user"],
},
}),
}),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
expect(resolveAgentRouteMock).not.toHaveBeenCalled();
});
it("allows guild reactions for sender in channel role allowlist override", async () => {
resolveAgentRouteMock.mockReturnValueOnce({
agentId: "default",
channel: "discord",
accountId: "acc-1",
sessionKey: "discord:acc-1:guild-123:channel-1",
});
const data = makeReactionEvent({
guildId: "guild-123",
userId: "member-user",
botAsAuthor: true,
guild: { id: "guild-123", name: "Test Guild" },
memberRoleIds: ["trusted-role"],
});
const client = makeReactionClient({ channelType: ChannelType.GuildText });
const listener = new DiscordReactionListener(
makeReactionListenerParams({
guildEntries: makeEntries({
"guild-123": {
roles: ["role:blocked-role"],
channels: {
"channel-1": {
allow: true,
roles: ["role:trusted-role"],
},
},
},
}),
}),
);
await listener.handle(data, client);
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
const [text] = enqueueSystemEventSpy.mock.calls[0];
expect(text).toContain("Discord reaction added");
});
it("routes DM reactions with peer kind 'direct' and user id", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ userId: "user-42", botAsAuthor: true });
const client = makeReactionClient({ channelType: ChannelType.DM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
const routeArgs = (resolveAgentRouteMock.mock.calls[0]?.[0] ?? {}) as {
peer?: unknown;
};
if (!routeArgs) {
throw new Error("expected route arguments");
}
expect(routeArgs.peer).toEqual({ kind: "direct", id: "user-42" });
});
it("routes group DM reactions with peer kind 'group'", async () => {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const data = makeReactionEvent({ botAsAuthor: true });
const client = makeReactionClient({ channelType: ChannelType.GroupDM });
const listener = new DiscordReactionListener(makeReactionListenerParams());
await listener.handle(data, client);
expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
const routeArgs = (resolveAgentRouteMock.mock.calls[0]?.[0] ?? {}) as {
peer?: unknown;
};
if (!routeArgs) {
throw new Error("expected route arguments");
}
expect(routeArgs.peer).toEqual({ kind: "group", id: "channel-1" });
});
});
describe("discord reaction notification modes", () => {
const guildId = "guild-900";
const guild = fakeGuild(guildId, "Mode Guild");
it("applies message-fetch behavior across notification modes and channel types", async () => {
const cases = typedCases<{
name: string;
reactionNotifications: "off" | "all" | "allowlist" | "own";
users: string[] | undefined;
userId: string | undefined;
channelType: ChannelType;
channelId: string | undefined;
parentId: string | undefined;
messageAuthorId: string;
expectedMessageFetchCalls: number;
expectedEnqueueCalls: number;
}>([
{
name: "off mode",
reactionNotifications: "off" as const,
users: undefined,
userId: undefined,
channelType: ChannelType.GuildText,
channelId: undefined,
parentId: undefined,
messageAuthorId: "other-user",
expectedMessageFetchCalls: 0,
expectedEnqueueCalls: 0,
},
{
name: "all mode",
reactionNotifications: "all" as const,
users: undefined,
userId: undefined,
channelType: ChannelType.GuildText,
channelId: undefined,
parentId: undefined,
messageAuthorId: "other-user",
expectedMessageFetchCalls: 0,
expectedEnqueueCalls: 1,
},
{
name: "allowlist mode",
reactionNotifications: "allowlist" as const,
users: ["123"] as string[],
userId: "123",
channelType: ChannelType.GuildText,
channelId: undefined,
parentId: undefined,
messageAuthorId: "other-user",
expectedMessageFetchCalls: 0,
expectedEnqueueCalls: 1,
},
{
name: "own mode",
reactionNotifications: "own" as const,
users: undefined,
userId: undefined,
channelType: ChannelType.GuildText,
channelId: undefined,
parentId: undefined,
messageAuthorId: "bot-1",
expectedMessageFetchCalls: 1,
expectedEnqueueCalls: 1,
},
{
name: "all mode thread channel",
reactionNotifications: "all" as const,
users: undefined,
userId: undefined,
channelType: ChannelType.PublicThread,
channelId: "thread-1",
parentId: "parent-1",
messageAuthorId: "other-user",
expectedMessageFetchCalls: 0,
expectedEnqueueCalls: 1,
},
]);
for (const testCase of cases) {
enqueueSystemEventSpy.mockClear();
resolveAgentRouteMock.mockClear();
const messageFetch = vi.fn(async () => ({
author: { id: testCase.messageAuthorId, username: "author", discriminator: "0" },
}));
const data = makeReactionEvent({
guildId,
guild,
userId: testCase.userId,
channelId: testCase.channelId,
messageFetch,
});
const client = makeReactionClient({
channelType: testCase.channelType,
parentId: testCase.parentId,
});
const guildEntries = makeEntries({
[guildId]: {
reactionNotifications: testCase.reactionNotifications,
users: testCase.users ? [...testCase.users] : undefined,
},
});
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
await listener.handle(data, client);
expect(messageFetch, testCase.name).toHaveBeenCalledTimes(testCase.expectedMessageFetchCalls);
expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledTimes(
testCase.expectedEnqueueCalls,
);
}
});
});