mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
1313 lines
40 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
});
|
|
});
|