diff --git a/CHANGELOG.md b/CHANGELOG.md index bfae6b3df..cb5a9212e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. +- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) — thanks @thewilloftheshadow. ### Fixes - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index d94c471eb..5d6b5f95f 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -515,6 +515,17 @@ struct ConnectionsSettings: View { .labelsHidden() .toggleStyle(.checkbox) } + GridRow { + self.gridLabel("Reaction notifications") + Picker("", selection: $guild.reactionNotifications) { + Text("Off").tag("off") + Text("Own").tag("own") + Text("All").tag("all") + Text("Allowlist").tag("allowlist") + } + .labelsHidden() + .pickerStyle(.segmented) + } GridRow { self.gridLabel("Users allowlist") TextField("123456789, username#1234", text: $guild.users) diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index 27d66b82f..feb4239c5 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -162,6 +162,7 @@ struct DiscordGuildForm: Identifiable { var key: String var slug: String var requireMention: Bool + var reactionNotifications: String var users: String var channels: [DiscordGuildChannelForm] @@ -169,12 +170,14 @@ struct DiscordGuildForm: Identifiable { key: String = "", slug: String = "", requireMention: Bool = false, + reactionNotifications: String = "allowlist", users: String = "", channels: [DiscordGuildChannelForm] = [] ) { self.key = key self.slug = slug self.requireMention = requireMention + self.reactionNotifications = reactionNotifications self.users = users self.channels = channels } @@ -491,6 +494,10 @@ final class ConnectionsStore { let entry = value.dictionaryValue ?? [:] let slug = entry["slug"]?.stringValue ?? "" let requireMention = entry["requireMention"]?.boolValue ?? false + let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? "" + let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw) + ? reactionModeRaw + : "allowlist" let users = entry["users"]?.arrayValue? .compactMap { item -> String? in if let str = item.stringValue { return str } @@ -518,6 +525,7 @@ final class ConnectionsStore { key: key, slug: slug, requireMention: requireMention, + reactionNotifications: reactionNotifications, users: users, channels: channels) } @@ -794,6 +802,9 @@ final class ConnectionsStore { let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines) if !slug.isEmpty { payload["slug"] = slug } if entry.requireMention { payload["requireMention"] = true } + if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) { + payload["reactionNotifications"] = entry.reactionNotifications + } let users = entry.users .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/docs/configuration.md b/docs/configuration.md index 644b4b5b3..a8a0a48d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -266,6 +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 users: ["987654321098765432"], // optional per-guild user allowlist channels: { general: { allow: true }, diff --git a/docs/discord.md b/docs/discord.md index 7080dedcf..b78276609 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -86,6 +86,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea "123456789012345678": { slug: "friends-of-clawd", requireMention: false, + reactionNotifications: "allowlist", users: ["987654321098765432", "steipete"], channels: { general: { allow: true }, @@ -107,6 +108,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `guilds..users`: optional per-guild user allowlist (ids or names). - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). +- `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). - `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `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). @@ -117,6 +119,8 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `roles` (role add/remove, default `false`) - `moderation` (timeout/kick/ban, default `false`) +Reaction notifications use `guilds..reactionNotifications`; `allowlist` checks `guilds..users`, while `all` ignores that allowlist. + ### Tool action defaults | Action group | Default | Notes | diff --git a/src/config/config.ts b/src/config/config.ts index 99020540d..d798598de 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -203,9 +203,17 @@ export type DiscordGuildChannelConfig = { requireMention?: boolean; }; +export type DiscordReactionNotificationMode = + | "off" + | "own" + | "all" + | "allowlist"; + export type DiscordGuildEntry = { slug?: string; requireMention?: boolean; + /** Reaction notification mode (off|own|all|allowlist). Default: own. */ + reactionNotifications?: DiscordReactionNotificationMode; users?: Array; channels?: Record; }; @@ -1153,6 +1161,9 @@ export const ClawdisSchema = z.object({ .object({ slug: z.string().optional(), requireMention: z.boolean().optional(), + reactionNotifications: z + .enum(["off", "own", "all", "allowlist"]) + .optional(), users: z.array(z.union([z.string(), z.number()])).optional(), channels: z .record( diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 5f3f0f64a..8ae637219 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,12 +1,20 @@ import { ApplicationCommandOptionType, + type Attachment, ChannelType, + type ChatInputCommandInteraction, Client, type CommandInteractionOption, Events, GatewayIntentBits, + type Guild, type Message, + type MessageReaction, + type PartialMessage, + type PartialMessageReaction, Partials, + type PartialUser, + type User, } from "discord.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; @@ -21,6 +29,7 @@ import type { import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose, warn } from "../globals.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; @@ -61,6 +70,7 @@ export type DiscordGuildEntryResolved = { id?: string; slug?: string; requireMention?: boolean; + reactionNotifications?: "off" | "own" | "all" | "allowlist"; users?: Array; channels?: Record; }; @@ -149,10 +159,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, + GatewayIntentBits.DirectMessageReactions, + ], + partials: [ + Partials.Channel, + Partials.Message, + Partials.Reaction, + Partials.User, ], - partials: [Partials.Channel], }); const logger = getChildLogger({ module: "discord-auto-reply" }); @@ -503,6 +520,99 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } }); + const handleReactionEvent = async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + action: "added" | "removed", + ) => { + try { + if (!user || user.bot) return; + const resolvedReaction = reaction.partial + ? await reaction.fetch() + : reaction; + const message = (resolvedReaction.message as Message | PartialMessage) + .partial + ? await resolvedReaction.message.fetch() + : resolvedReaction.message; + const guild = message.guild; + if (!guild) return; + const guildInfo = resolveDiscordGuildEntry({ + guild, + guildEntries, + }); + if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) { + return; + } + const channelName = + "name" in message.channel + ? (message.channel.name ?? undefined) + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: message.channelId, + channelName, + channelSlug, + }); + if (channelConfig?.allowed === false) return; + + 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 emojiLabel = formatDiscordReactionEmoji(resolvedReaction); + const actorLabel = user.tag ?? user.username ?? user.id; + const guildSlug = + guildInfo?.slug || + (guild.name ? normalizeDiscordSlug(guild.name) : guild.id); + const channelLabel = channelSlug + ? `#${channelSlug}` + : channelName + ? `#${normalizeDiscordSlug(channelName)}` + : `#${message.channelId}`; + const authorLabel = message.author?.tag ?? message.author?.username; + const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + enqueueSystemEvent(text, { + contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`, + }); + } catch (err) { + runtime.error?.( + danger(`discord reaction handler failed: ${String(err)}`), + ); + } + }; + + client.on(Events.MessageReactionAdd, async (reaction, user) => { + await handleReactionEvent(reaction, user, "added"); + }); + + client.on(Events.MessageReactionRemove, async (reaction, user) => { + await handleReactionEvent(reaction, user, "removed"); + }); + client.on(Events.InteractionCreate, async (interaction) => { try { if (!slashCommand.enabled) return; @@ -698,7 +808,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } async function resolveMedia( - message: import("discord.js").Message, + message: Message, maxBytes: number, ): Promise { const attachment = message.attachments.first(); @@ -723,7 +833,7 @@ async function resolveMedia( }; } -function inferPlaceholder(attachment: import("discord.js").Attachment): string { +function inferPlaceholder(attachment: Attachment): string { const mime = attachment.contentType ?? ""; if (mime.startsWith("image/")) return ""; if (mime.startsWith("video/")) return ""; @@ -768,17 +878,30 @@ async function resolveReplyContext(message: Message): Promise { } } -function buildDirectLabel(message: import("discord.js").Message) { +function buildDirectLabel(message: Message) { const username = message.author.tag; return `${username} id:${message.author.id}`; } -function buildGuildLabel(message: import("discord.js").Message) { +function buildGuildLabel(message: Message) { const channelName = "name" in message.channel ? message.channel.name : message.channelId; return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`; } +function formatDiscordReactionEmoji( + reaction: MessageReaction | PartialMessageReaction, +) { + if (typeof reaction.emoji.toString === "function") { + const rendered = reaction.emoji.toString(); + if (rendered && rendered !== "[object Object]") return rendered; + } + if (reaction.emoji.id && reaction.emoji.name) { + return `${reaction.emoji.name}:${reaction.emoji.id}`; + } + return reaction.emoji.name ?? "emoji"; +} + export function normalizeDiscordAllowList( raw: Array | undefined, prefixes: string[], @@ -863,7 +986,7 @@ export function allowListMatches( } export function resolveDiscordGuildEntry(params: { - guild: import("discord.js").Guild | null; + guild: Guild | null; guildEntries: Record | undefined; }): DiscordGuildEntryResolved | null { const { guild, guildEntries } = params; @@ -878,6 +1001,7 @@ export function resolveDiscordGuildEntry(params: { id: guildId, slug: direct.slug ?? guildSlug, requireMention: direct.requireMention, + reactionNotifications: direct.reactionNotifications, users: direct.users, channels: direct.channels, }; @@ -888,6 +1012,7 @@ export function resolveDiscordGuildEntry(params: { id: guildId, slug: entry.slug ?? guildSlug, requireMention: entry.requireMention, + reactionNotifications: entry.reactionNotifications, users: entry.users, channels: entry.channels, }; @@ -902,6 +1027,7 @@ export function resolveDiscordGuildEntry(params: { id: guildId, slug: entry.slug ?? guildSlug, requireMention: entry.requireMention, + reactionNotifications: entry.reactionNotifications, users: entry.users, channels: entry.channels, }; @@ -912,6 +1038,7 @@ export function resolveDiscordGuildEntry(params: { id: guildId, slug: wildcard.slug ?? guildSlug, requireMention: wildcard.requireMention, + reactionNotifications: wildcard.reactionNotifications, users: wildcard.users, channels: wildcard.channels, }; @@ -1121,7 +1248,7 @@ async function deliverSlashReplies({ textLimit, }: { replies: ReplyPayload[]; - interaction: import("discord.js").ChatInputCommandInteraction; + interaction: ChatInputCommandInteraction; ephemeral: boolean; textLimit: number; }) { diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index a18f60feb..4cf9c2589 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -197,6 +197,13 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot typeof entry.requireMention === "boolean" ? entry.requireMention : false, + reactionNotifications: + entry.reactionNotifications === "off" || + entry.reactionNotifications === "all" || + entry.reactionNotifications === "own" || + entry.reactionNotifications === "allowlist" + ? entry.reactionNotifications + : "allowlist", users: toList(entry.users), channels, }; diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 8990c1b17..68bdc7409 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -292,6 +292,14 @@ export async function saveDiscordConfig(state: ConnectionsState) { const slug = String(guild.slug ?? "").trim(); if (slug) entry.slug = slug; if (guild.requireMention) entry.requireMention = true; + if ( + guild.reactionNotifications === "off" || + guild.reactionNotifications === "all" || + guild.reactionNotifications === "own" || + guild.reactionNotifications === "allowlist" + ) { + entry.reactionNotifications = guild.reactionNotifications; + } const users = parseList(guild.users); if (users.length > 0) entry.users = users; const channels: Record = {}; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 8850793f6..9eea25bf3 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -31,6 +31,7 @@ export type DiscordGuildForm = { key: string; slug: string; requireMention: boolean; + reactionNotifications: "off" | "own" | "all" | "allowlist"; users: string; channels: DiscordGuildChannelForm[]; }; diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 1f918ace0..2f23d795d 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -645,6 +645,26 @@ function renderProvider( +