merge main into shadow/discord-id

This commit is contained in:
Peter Steinberger
2026-01-02 11:27:24 +01:00
63 changed files with 1434 additions and 262 deletions

149
src/discord/monitor.test.ts Normal file
View File

@@ -0,0 +1,149 @@
import {
allowListMatches,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfig,
resolveDiscordGuildEntry,
resolveGroupDmAllow,
type DiscordGuildEntryResolved,
} from "./monitor.js";
const fakeGuild = (id: string, name: string) =>
({ id, name } as unknown as import("discord.js").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,
users: value.users,
channels: value.channels,
};
}
return out;
};
describe("discord allowlist helpers", () => {
it("normalizes slugs", () => {
expect(normalizeDiscordSlug("Friends of Clawd"))
.toBe("friends-of-clawd");
expect(normalizeDiscordSlug("#General"))
.toBe("general");
expect(normalizeDiscordSlug("Dev__Chat"))
.toBe("dev-chat");
});
it("matches ids or names", () => {
const allow = normalizeDiscordAllowList(
["123", "steipete", "Friends of Clawd"],
["discord:", "user:", "guild:", "channel:"],
);
expect(allow).not.toBeNull();
expect(allowListMatches(allow!, { id: "123" })).toBe(true);
expect(allowListMatches(allow!, { name: "steipete" })).toBe(true);
expect(allowListMatches(allow!, { name: "friends-of-clawd" })).toBe(true);
expect(allowListMatches(allow!, { name: "other" })).toBe(false);
});
});
describe("discord guild/channel resolution", () => {
it("resolves guild entry by id", () => {
const guildEntries = makeEntries({
"123": { slug: "friends-of-clawd" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of Clawd"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-clawd");
});
it("resolves guild entry by slug key", () => {
const guildEntries = makeEntries({
"friends-of-clawd": { slug: "friends-of-clawd" },
});
const resolved = resolveDiscordGuildEntry({
guild: fakeGuild("123", "Friends of Clawd"),
guildEntries,
});
expect(resolved?.id).toBe("123");
expect(resolved?.slug).toBe("friends-of-clawd");
});
it("resolves channel config by slug", () => {
const guildInfo: DiscordGuildEntryResolved = {
channels: {
general: { allow: true },
help: { allow: true, requireMention: 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);
});
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);
});
});
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: ["clawd-dm"],
channelId: "1",
channelName: "Clawd DM",
channelSlug: "clawd-dm",
}),
).toBe(true);
expect(
resolveGroupDmAllow({
channels: ["clawd-dm"],
channelId: "1",
channelName: "Other",
channelSlug: "other",
}),
).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import {
ChannelType,
Client,
Events,
GatewayIntentBits,
@@ -24,12 +25,6 @@ export type MonitorDiscordOpts = {
token?: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
allowFrom?: Array<string | number>;
guildAllowFrom?: {
guilds?: Array<string | number>;
users?: Array<string | number>;
};
requireMention?: boolean;
mediaMaxMb?: number;
historyLimit?: number;
};
@@ -47,6 +42,25 @@ type DiscordHistoryEntry = {
messageId?: string;
};
export type DiscordAllowList = {
allowAll: boolean;
ids: Set<string>;
names: Set<string>;
};
export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
requireMention?: boolean;
users?: Array<string | number>;
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
};
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
};
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig();
const token = normalizeDiscordToken(
@@ -69,16 +83,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
};
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom;
const requireMention =
opts.requireMention ?? cfg.discord?.requireMention ?? true;
const dmConfig = cfg.discord?.dm;
const guildEntries = cfg.discord?.guilds;
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
);
const dmEnabled = dmConfig?.enabled ?? true;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const client = new Client({
intents: [
@@ -106,7 +122,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (message.author?.bot) return;
if (!message.author) return;
const isDirectMessage = !message.guild;
const channelType = message.channel.type;
const isGroupDm = channelType === ChannelType.GroupDM;
const isDirectMessage = channelType === ChannelType.DM;
const isGuildMessage = Boolean(message.guild);
if (isGroupDm && !groupDmEnabled) return;
if (isDirectMessage && !dmEnabled) return;
const botId = client.user?.id;
const wasMentioned =
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
@@ -117,7 +138,59 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
message.embeds[0]?.description ||
"";
if (!isDirectMessage && historyLimit > 0 && baseText) {
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: message.guild,
guildEntries,
})
: null;
if (
isGuildMessage &&
guildEntries &&
Object.keys(guildEntries).length > 0 &&
!guildInfo
) {
logVerbose(
`Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`,
);
return;
}
const channelName =
(isGuildMessage || isGroupDm) && "name" in message.channel
? message.channel.name
: undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const guildSlug =
guildInfo?.slug ||
(message.guild?.name ? normalizeDiscordSlug(message.guild.name) : "");
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
channelId: message.channelId,
channelName,
channelSlug,
})
: null;
const groupDmAllowed =
isGroupDm &&
resolveGroupDmAllow({
channels: groupDmChannels,
channelId: message.channelId,
channelName,
channelSlug,
});
if (isGroupDm && !groupDmAllowed) return;
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
);
return;
}
if (isGuildMessage && historyLimit > 0 && baseText) {
const history = guildHistories.get(message.channelId) ?? [];
history.push({
sender: message.member?.displayName ?? message.author.tag,
@@ -129,7 +202,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
guildHistories.set(message.channelId, history);
}
if (!isDirectMessage && requireMention) {
const resolvedRequireMention =
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
if (isGuildMessage && resolvedRequireMention) {
if (botId && !wasMentioned) {
logger.info(
{
@@ -142,46 +217,45 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
}
if (!isDirectMessage && guildAllowFrom) {
const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [
"guild:",
]);
const users = normalizeDiscordAllowList(guildAllowFrom.users, [
"discord:",
"user:",
]);
if (guilds || users) {
const guildId = message.guild?.id ?? "";
const userId = message.author.id;
const guildOk =
!guilds || guilds.allowAll || (guildId && guilds.ids.has(guildId));
const userOk = !users || users.allowAll || users.ids.has(userId);
if (!guildOk || !userOk) {
if (isGuildMessage) {
const userAllow = guildInfo?.users;
if (Array.isArray(userAllow) && userAllow.length > 0) {
const users = normalizeDiscordAllowList(userAllow, [
"discord:",
"user:",
]);
const userOk =
!users ||
allowListMatches(users, {
id: message.author.id,
name: message.author.username,
tag: message.author.tag,
});
if (!userOk) {
logVerbose(
`Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`,
`Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`,
);
return;
}
}
}
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
const allowed = allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean);
const candidate = message.author.id;
const normalized = new Set(
allowed
.filter((entry) => entry !== "*")
.map((entry) => entry.replace(/^discord:/i, "")),
);
const allowList = normalizeDiscordAllowList(allowFrom, [
"discord:",
"user:",
]);
const permitted =
allowed.includes("*") ||
normalized.has(candidate) ||
allowed.includes(candidate);
allowList &&
allowListMatches(allowList, {
id: message.author.id,
name: message.author.username,
tag: message.author.tag,
});
if (!permitted) {
logVerbose(
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
);
return;
}
@@ -198,6 +272,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const fromLabel = isDirectMessage
? buildDirectLabel(message)
: buildGuildLabel(message);
const groupRoom =
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
let combinedBody = formatAgentEnvelope({
surface: "Discord",
@@ -240,10 +317,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
: `channel:${message.channelId}`,
ChatType: isDirectMessage ? "direct" : "group",
SenderName: message.member?.displayName ?? message.author.tag,
GroupSubject:
!isDirectMessage && "name" in message.channel
? message.channel.name
: undefined,
GroupSubject: groupSubject,
GroupRoom: groupRoom,
GroupSpace: isGuildMessage ? guildSlug || undefined : undefined,
Surface: "discord" as const,
WasMentioned: wasMentioned,
MessageSid: message.id,
@@ -292,7 +368,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
token,
runtime,
});
if (!isDirectMessage && shouldClearHistory && historyLimit > 0) {
if (isGuildMessage && shouldClearHistory && historyLimit > 0) {
guildHistories.set(message.channelId, []);
}
} catch (err) {
@@ -366,25 +442,174 @@ function buildGuildLabel(message: import("discord.js").Message) {
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
function normalizeDiscordAllowList(
export function normalizeDiscordAllowList(
raw: Array<string | number> | undefined,
prefixes: string[],
): { allowAll: boolean; ids: Set<string> } | null {
): DiscordAllowList | null {
if (!raw || raw.length === 0) return null;
const cleaned = raw
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
for (const prefix of prefixes) {
if (entry.toLowerCase().startsWith(prefix)) {
return entry.slice(prefix.length);
}
const ids = new Set<string>();
const names = new Set<string>();
let allowAll = false;
for (const rawEntry of raw) {
let entry = String(rawEntry).trim();
if (!entry) continue;
if (entry === "*") {
allowAll = true;
continue;
}
for (const prefix of prefixes) {
if (entry.toLowerCase().startsWith(prefix)) {
entry = entry.slice(prefix.length);
break;
}
return entry;
});
const allowAll = cleaned.includes("*");
const ids = new Set(cleaned.filter((entry) => entry !== "*"));
return { allowAll, ids };
}
const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/);
if (mentionMatch?.[1]) {
ids.add(mentionMatch[1]);
continue;
}
entry = entry.trim();
if (entry.startsWith("@") || entry.startsWith("#")) {
entry = entry.slice(1);
}
if (/^\d+$/.test(entry)) {
ids.add(entry);
continue;
}
const normalized = normalizeDiscordName(entry);
if (normalized) names.add(normalized);
const slugged = normalizeDiscordSlug(entry);
if (slugged) names.add(slugged);
}
if (!allowAll && ids.size === 0 && names.size === 0) return null;
return { allowAll, ids, names };
}
function normalizeDiscordName(value?: string | null) {
if (!value) return "";
return value.trim().toLowerCase();
}
export function normalizeDiscordSlug(value?: string | null) {
if (!value) return "";
let text = value.trim().toLowerCase();
if (!text) return "";
text = text.replace(/^[@#]+/, "");
text = text.replace(/[\s_]+/g, "-");
text = text.replace(/[^a-z0-9-]+/g, "-");
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
return text;
}
export function allowListMatches(
allowList: DiscordAllowList,
candidates: {
id?: string;
name?: string | null;
tag?: string | null;
},
) {
if (allowList.allowAll) return true;
const { id, name, tag } = candidates;
if (id && allowList.ids.has(id)) return true;
const normalizedName = normalizeDiscordName(name);
if (normalizedName && allowList.names.has(normalizedName)) return true;
const normalizedTag = normalizeDiscordName(tag);
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
const slugName = normalizeDiscordSlug(name);
if (slugName && allowList.names.has(slugName)) return true;
const slugTag = normalizeDiscordSlug(tag);
if (slugTag && allowList.names.has(slugTag)) return true;
return false;
}
export function resolveDiscordGuildEntry(params: {
guild: import("discord.js").Guild | null;
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
}): DiscordGuildEntryResolved | null {
const { guild, guildEntries } = params;
if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) {
return null;
}
const guildId = guild.id;
const guildSlug = normalizeDiscordSlug(guild.name);
const direct = guildEntries[guildId];
if (direct) {
return {
id: guildId,
slug: direct.slug ?? guildSlug,
requireMention: direct.requireMention,
users: direct.users,
channels: direct.channels,
};
}
if (guildSlug && guildEntries[guildSlug]) {
const entry = guildEntries[guildSlug];
return {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
users: entry.users,
channels: entry.channels,
};
}
const matchBySlug = Object.entries(guildEntries).find(([, entry]) => {
const entrySlug = normalizeDiscordSlug(entry.slug);
return entrySlug && entrySlug === guildSlug;
});
if (matchBySlug) {
const entry = matchBySlug[1];
return {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
users: entry.users,
channels: entry.channels,
};
}
return null;
}
export function resolveDiscordChannelConfig(params: {
guildInfo: DiscordGuildEntryResolved | null;
channelId: string;
channelName?: string;
channelSlug?: string;
}): DiscordChannelConfigResolved | null {
const { guildInfo, channelId, channelName, channelSlug } = params;
const channelEntries = guildInfo?.channels;
if (channelEntries && Object.keys(channelEntries).length > 0) {
const entry =
channelEntries[channelId] ??
(channelSlug
? channelEntries[channelSlug] ??
channelEntries[`#${channelSlug}`]
: undefined) ??
(channelName
? channelEntries[normalizeDiscordSlug(channelName)]
: undefined);
if (!entry) return { allowed: false };
return { allowed: entry.allow !== false, requireMention: entry.requireMention };
}
return { allowed: true };
}
export function resolveGroupDmAllow(params: {
channels: Array<string | number> | undefined;
channelId: string;
channelName?: string;
channelSlug?: string;
}) {
const { channels, channelId, channelName, channelSlug } = params;
if (!channels || channels.length === 0) return true;
const allowList = normalizeDiscordAllowList(channels, ["channel:"]);
if (!allowList) return true;
return allowListMatches(allowList, {
id: channelId,
name: channelSlug || channelName,
});
}
async function sendTyping(message: Message) {