From 0f56dce748cbe56c8c899a01cd7b21be32cf92ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 10:32:21 +0100 Subject: [PATCH] feat: add discord dm/guild allowlists --- CHANGELOG.md | 1 + docs/configuration.md | 22 ++++-- docs/discord.md | 43 ++++++----- src/config/config.ts | 47 +++++++++++- src/discord/monitor.ts | 167 ++++++++++++++++++++++++++++++++--------- 5 files changed, 217 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e19a86900..17f4ea6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android). - Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward). - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). +- Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching. - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support. - Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI. diff --git a/docs/configuration.md b/docs/configuration.md index 421bf6a04..4c9f7f054 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -173,15 +173,21 @@ Configure the Discord bot by setting the bot token and optional gating: discord: { enabled: true, token: "your-bot-token", - allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids) - guildAllowFrom: { - guilds: ["123456789012345678"], // optional guild allowlist (ids) - users: ["987654321098765432"] // optional user allowlist (ids) - }, - requireMention: true, // require @bot mentions in guilds mediaMaxMb: 8, // clamp inbound media size - historyLimit: 20, // include last N guild messages as context - enableReactions: true // allow agent-triggered reactions + enableReactions: true, // allow agent-triggered reactions + dm: { + enabled: true, // disable all DMs when false + allowFrom: ["1234567890", "steipete"] // optional DM allowlist (ids or names) + }, + guild: { + channels: ["general", "help"], // optional channel allowlist (ids or names) + allowFrom: { + guilds: ["123456789012345678"], // optional guild allowlist (ids or names) + users: ["987654321098765432"] // optional user allowlist (ids or names) + }, + requireMention: true, // require @bot mentions in guilds + historyLimit: 20 // include last N guild messages as context + } } } ``` diff --git a/docs/discord.md b/docs/discord.md index fc56fc680..d914cab11 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -21,11 +21,12 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`). 4. Run the gateway; it auto-starts the Discord provider when the token is set (unless `discord.enabled = false`). 5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. -6. Guild channels: use `channel:` for delivery. Mentions are required by default; disable with `discord.requireMention = false`. -7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs. -8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers. -9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. +6. Guild channels: use `channel:` for delivery. Mentions are required by default; disable with `discord.guild.requireMention = false` (legacy: `discord.requireMention`). +7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Legacy: `discord.allowFrom`. +8. Optional guild allowlist: set `discord.guild.allowFrom` with `guilds` and/or `users` (ids or names) to gate who can invoke the bot in servers. Legacy: `discord.guildAllowFrom`. +9. Optional guild channel allowlist: set `discord.guild.channels` with channel ids or names to restrict where the bot listens. +10. Optional guild context history: set `discord.guild.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable (legacy: `discord.historyLimit`). +11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. @@ -42,24 +43,32 @@ Note: Discord does not provide a simple username → id lookup without extra gui discord: { enabled: true, token: "abc.123", - allowFrom: ["123456789012345678"], - guildAllowFrom: { - guilds: ["123456789012345678"], - users: ["987654321098765432"] - }, - requireMention: true, mediaMaxMb: 8, - historyLimit: 20, - enableReactions: true + enableReactions: true, + dm: { + enabled: true, + allowFrom: ["123456789012345678", "steipete"] + }, + guild: { + channels: ["general", "help"], + allowFrom: { + guilds: ["123456789012345678", "My Server"], + users: ["987654321098765432", "steipete"] + }, + requireMention: true, + historyLimit: 20 + } } } ``` -- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender. -- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match. -- `requireMention`: when `true`, messages in guild channels must mention the bot. +- `dm.enabled`: set `false` to ignore all DMs (default `true`). +- `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. +- `guild.allowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids or names). When both are set, both must match. +- `guild.channels`: Optional allowlist for channel ids or names. +- `guild.requireMention`: when `true`, messages in guild channels must mention the bot. - `mediaMaxMb`: clamp inbound media saved to disk. -- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). +- `guild.historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`). ## Reactions diff --git a/src/config/config.ts b/src/config/config.ts index 8aea48a51..7aa8b46d3 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -164,21 +164,47 @@ export type TelegramConfig = { webhookPath?: string; }; +export type DiscordDmConfig = { + /** If false, ignore all incoming Discord DMs. Default: true. */ + enabled?: boolean; + /** Allowlist for DM senders (ids or names). */ + allowFrom?: Array; +}; + +export type DiscordGuildConfig = { + /** Allowlist for guild messages (guilds/users by id or name). */ + allowFrom?: { + guilds?: Array; + users?: Array; + }; + /** Allowlist for guild channels (ids or names). */ + channels?: Array; + /** Require @bot mention to respond in guilds. Default: true. */ + requireMention?: boolean; + /** Number of recent guild messages to include for context. */ + historyLimit?: number; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; + /** Legacy DM allowlist (ids). Prefer discord.dm.allowFrom. */ allowFrom?: Array; + /** Legacy guild allowlist (ids). Prefer discord.guild.allowFrom. */ guildAllowFrom?: { guilds?: Array; users?: Array; }; + /** Legacy mention requirement. Prefer discord.guild.requireMention. */ requireMention?: boolean; mediaMaxMb?: number; - /** Number of recent guild messages to include for context (default: 20). */ + /** Legacy history limit. Prefer discord.guild.historyLimit. */ historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + dm?: DiscordDmConfig; + guild?: DiscordGuildConfig; }; export type SignalConfig = { @@ -919,6 +945,25 @@ const ClawdisSchema = z.object({ mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), + dm: z + .object({ + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + guild: z + .object({ + allowFrom: z + .object({ + guilds: z.array(z.union([z.string(), z.number()])).optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + channels: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + historyLimit: z.number().int().min(0).optional(), + }) + .optional(), }) .optional(), signal: z diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 135a4e52c..e6159c8d9 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -48,6 +48,12 @@ type DiscordHistoryEntry = { messageId?: string; }; +type DiscordAllowList = { + allowAll: boolean; + ids: Set; + names: Set; +}; + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = loadConfig(); const token = normalizeDiscordToken( @@ -70,16 +76,28 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }; - const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom; - const guildAllowFrom = opts.guildAllowFrom ?? cfg.discord?.guildAllowFrom; + const dmConfig = cfg.discord?.dm; + const guildConfig = cfg.discord?.guild; + const allowFrom = + opts.allowFrom ?? dmConfig?.allowFrom ?? cfg.discord?.allowFrom; + const guildAllowFrom = + opts.guildAllowFrom ?? guildConfig?.allowFrom ?? cfg.discord?.guildAllowFrom; + const guildChannels = guildConfig?.channels; const requireMention = - opts.requireMention ?? cfg.discord?.requireMention ?? true; + opts.requireMention ?? + guildConfig?.requireMention ?? + cfg.discord?.requireMention ?? + true; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( 0, - opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, + opts.historyLimit ?? + guildConfig?.historyLimit ?? + cfg.discord?.historyLimit ?? + 20, ); + const dmEnabled = dmConfig?.enabled ?? true; const client = new Client({ intents: [ @@ -111,6 +129,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const isGroupDm = channelType === ChannelType.GroupDM; const isDirectMessage = channelType === ChannelType.DM; const isGuildMessage = Boolean(message.guild); + if (isGroupDm) return; + if (isDirectMessage && !dmEnabled) return; const botId = client.user?.id; const wasMentioned = !isDirectMessage && Boolean(botId && message.mentions.has(botId)); @@ -121,7 +141,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { message.embeds[0]?.description || ""; - if (!isDirectMessage && historyLimit > 0 && baseText) { + if (isGuildMessage && historyLimit > 0 && baseText) { const history = guildHistories.get(message.channelId) ?? []; history.push({ sender: message.member?.displayName ?? message.author.tag, @@ -133,7 +153,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildHistories.set(message.channelId, history); } - if (!isDirectMessage && requireMention) { + if (isGuildMessage && requireMention) { if (botId && !wasMentioned) { logger.info( { @@ -146,7 +166,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } } - if (!isDirectMessage && isGuildMessage && guildAllowFrom) { + if (isGuildMessage) { + const channelAllow = normalizeDiscordAllowList(guildChannels, [ + "channel:", + ]); + if (channelAllow) { + const channelName = + "name" in message.channel ? message.channel.name : undefined; + const channelOk = allowListMatches(channelAllow, { + id: message.channelId, + name: channelName, + }); + if (!channelOk) { + logVerbose( + `Blocked discord channel ${message.channelId} not in guild.channels`, + ); + return; + } + } + } + + if (isGuildMessage && guildAllowFrom) { const guilds = normalizeDiscordAllowList(guildAllowFrom.guilds, [ "guild:", ]); @@ -158,8 +198,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { 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); + !guilds || + allowListMatches(guilds, { + id: guildId, + name: message.guild?.name, + }); + const userOk = + !users || + allowListMatches(users, { + id: userId, + name: message.author.username, + tag: message.author.tag, + }); if (!guildOk || !userOk) { logVerbose( `Blocked discord guild sender ${userId} (guild ${guildId || "unknown"}) not in guildAllowFrom`, @@ -170,22 +220,20 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } 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; } @@ -300,7 +348,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) { @@ -384,22 +432,67 @@ function buildGuildLabel(message: import("discord.js").Message) { function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], -): { allowAll: boolean; ids: Set } | 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(); + const names = new Set(); + 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); + } + + 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(); +} + +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; + return false; } async function sendTyping(message: Message) {