diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index feb4239c5..578a94c6c 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -170,7 +170,7 @@ struct DiscordGuildForm: Identifiable { key: String = "", slug: String = "", requireMention: Bool = false, - reactionNotifications: String = "allowlist", + reactionNotifications: String = "own", users: String = "", channels: [DiscordGuildChannelForm] = [] ) { @@ -497,7 +497,7 @@ final class ConnectionsStore { let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? "" let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw) ? reactionModeRaw - : "allowlist" + : "own" let users = entry["users"]?.arrayValue? .compactMap { item -> String? in if let str = item.stringValue { return str } diff --git a/docs/configuration.md b/docs/configuration.md index fd8eff783..65a0d3c6a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -266,7 +266,7 @@ Configure the Discord bot by setting the bot token and optional gating: "123456789012345678": { // guild id (preferred) or slug slug: "friends-of-clawd", requireMention: false, // per-guild default - reactionNotifications: "allowlist", // off | own | all | allowlist + reactionNotifications: "own", // off | own | all | allowlist users: ["987654321098765432"], // optional per-guild user allowlist channels: { general: { allow: true }, @@ -283,9 +283,9 @@ Clawdis starts Discord only when a `discord` config section exists. The token is Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity. Reaction notification modes: - `off`: no reaction events. -- `own`: all reactions on the bot's own messages. +- `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. -- `allowlist`: reactions from `guilds..users` on all messages. +- `allowlist`: reactions from `guilds..users` on all messages (empty list disables). ### `imessage` (imsg CLI) diff --git a/docs/discord.md b/docs/discord.md index 30c0336e0..edfa1792a 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -86,7 +86,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea "123456789012345678": { slug: "friends-of-clawd", requireMention: false, - reactionNotifications: "allowlist", + reactionNotifications: "own", users: ["987654321098765432", "steipete"], channels: { general: { allow: true }, @@ -121,9 +121,9 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea Reaction notifications use `guilds..reactionNotifications`: - `off`: no reaction events. -- `own`: all reactions on the bot's own messages. +- `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. -- `allowlist`: reactions from `guilds..users` on all messages. +- `allowlist`: reactions from `guilds..users` on all messages (empty list disables). ### Tool action defaults diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 385e970a5..b9f6bb6ac 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -8,6 +8,7 @@ import { resolveDiscordGuildEntry, resolveDiscordReplyTarget, resolveGroupDmAllow, + shouldEmitDiscordReactionNotification, } from "./monitor.js"; const fakeGuild = (id: string, name: string) => @@ -21,6 +22,7 @@ const makeEntries = ( out[key] = { slug: value.slug, requireMention: value.requireMention, + reactionNotifications: value.reactionNotifications, users: value.users, channels: value.channels, }; @@ -207,3 +209,87 @@ describe("discord reply target selection", () => { ).toBe("123"); }); }); + +describe("discord reaction notification gating", () => { + it("defaults to own when mode is unset", () => { + expect( + shouldEmitDiscordReactionNotification({ + mode: undefined, + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-1", + }), + ).toBe(true); + expect( + shouldEmitDiscordReactionNotification({ + mode: undefined, + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + }), + ).toBe(false); + }); + + it("skips when mode is off", () => { + expect( + shouldEmitDiscordReactionNotification({ + mode: "off", + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-1", + }), + ).toBe(false); + }); + + it("allows all reactions when mode is all", () => { + expect( + shouldEmitDiscordReactionNotification({ + mode: "all", + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + }), + ).toBe(true); + }); + + it("requires bot ownership when mode is own", () => { + expect( + shouldEmitDiscordReactionNotification({ + mode: "own", + botId: "bot-1", + messageAuthorId: "bot-1", + userId: "user-2", + }), + ).toBe(true); + expect( + shouldEmitDiscordReactionNotification({ + mode: "own", + botId: "bot-1", + messageAuthorId: "user-2", + userId: "user-3", + }), + ).toBe(false); + }); + + it("requires allowlist matches when mode is allowlist", () => { + expect( + shouldEmitDiscordReactionNotification({ + mode: "allowlist", + botId: "bot-1", + messageAuthorId: "user-1", + userId: "user-2", + allowlist: [], + }), + ).toBe(false); + expect( + shouldEmitDiscordReactionNotification({ + mode: "allowlist", + botId: "bot-1", + messageAuthorId: "user-1", + userId: "123", + userName: "steipete", + allowlist: ["123", "other"], + }), + ).toBe(true); + }); +}); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 8ae637219..b511cbd15 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -559,28 +559,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const botId = client.user?.id; if (botId && user.id === botId) return; - const reactionMode = guildInfo?.reactionNotifications ?? "allowlist"; - if (reactionMode === "off") return; - if (reactionMode === "own") { - const authorId = message.author?.id; - if (!botId || authorId !== botId) return; - } - if (reactionMode === "allowlist") { - const userAllow = guildInfo?.users; - if (!Array.isArray(userAllow) || userAllow.length === 0) return; - const users = normalizeDiscordAllowList(userAllow, [ - "discord:", - "user:", - ]); - const userOk = - !!users && - allowListMatches(users, { - id: user.id, - name: user.username, - tag: user.tag, - }); - if (!userOk) return; - } + const reactionMode = guildInfo?.reactionNotifications ?? "own"; + const shouldNotify = shouldEmitDiscordReactionNotification({ + mode: reactionMode, + botId, + messageAuthorId: message.author?.id, + userId: user.id, + userName: user.username, + userTag: user.tag, + allowlist: guildInfo?.users, + }); + if (!shouldNotify) return; const emojiLabel = formatDiscordReactionEmoji(resolvedReaction); const actorLabel = user.tag ?? user.username ?? user.id; @@ -985,6 +974,43 @@ export function allowListMatches( return false; } +export function shouldEmitDiscordReactionNotification(params: { + mode: "off" | "own" | "all" | "allowlist" | undefined; + botId?: string | null; + messageAuthorId?: string | null; + userId: string; + userName?: string | null; + userTag?: string | null; + allowlist?: Array | null; +}) { + const { + mode, + botId, + messageAuthorId, + userId, + userName, + userTag, + allowlist, + } = params; + const effectiveMode = mode ?? "own"; + if (effectiveMode === "off") return false; + if (effectiveMode === "own") { + if (!botId || !messageAuthorId) return false; + return messageAuthorId === botId; + } + if (effectiveMode === "allowlist") { + if (!Array.isArray(allowlist) || allowlist.length === 0) return false; + const users = normalizeDiscordAllowList(allowlist, ["discord:", "user:"]); + if (!users) return false; + return allowListMatches(users, { + id: userId, + name: userName ?? undefined, + tag: userTag ?? undefined, + }); + } + return true; +} + export function resolveDiscordGuildEntry(params: { guild: Guild | null; guildEntries: Record | undefined; diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 4cf9c2589..0581eb155 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -203,7 +203,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot entry.reactionNotifications === "own" || entry.reactionNotifications === "allowlist" ? entry.reactionNotifications - : "allowlist", + : "own", users: toList(entry.users), channels, }; diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 2f23d795d..ab9eca9d9 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -654,7 +654,7 @@ function renderProvider( next[guildIndex] = { ...next[guildIndex], reactionNotifications: (e.target as HTMLSelectElement) - .value as "off" | "own" | "all", + .value as "off" | "own" | "all" | "allowlist", }; props.onDiscordChange({ guilds: next }); }} @@ -832,7 +832,7 @@ function renderProvider( key: "", slug: "", requireMention: false, - reactionNotifications: "allowlist", + reactionNotifications: "own", users: "", channels: [], },