From 5ad27128ad791ed40ba2cac883bc58497b42f724 Mon Sep 17 00:00:00 2001 From: David Marsh Date: Thu, 29 Jan 2026 07:57:37 -0800 Subject: [PATCH] feat(discord): add safety features to reaction triggers - Only trigger on reaction ADD (not remove) - Add rate limiting: 30s cooldown per user per message (configurable) - Add emoji filtering: reactionTriggerEmojis array to limit which emojis trigger - Update documentation with new options --- docs/channels/discord.md | 10 +++- src/config/types.discord.ts | 4 ++ src/config/zod-schema.providers-core.ts | 4 ++ src/discord/monitor/allow-list.ts | 4 ++ src/discord/monitor/listeners.ts | 77 +++++++++++++++++++++---- 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 72597ec2a..626ac844e 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -341,10 +341,18 @@ Use `guilds..reactionTrigger` to invoke an agent turn when a user reacts to - `all`: trigger agent on reactions to any message. - `allowlist`: trigger agent for reactions from `guilds..users` only. +Additional options: +- `guilds..reactionTriggerEmojis`: array of emojis that trigger (e.g., `["🤖", "👀"]`). Omit to allow all emojis. +- `guilds..reactionTriggerCooldownMs`: cooldown between triggers per user per message. Default: 30000 (30 seconds). Set to 0 to disable. + When triggered, the agent receives a synthetic message containing: - The reactor's user tag and the emoji they used - The content of the reacted-to message (if available) -- Action type (added or removed) + +Notes: +- Only reaction **adds** trigger the agent (removes do not) +- Rate limiting prevents spam from rapid reactions +- Uses the guild's `users` allowlist when mode is `allowlist` This is useful for interactive workflows where reactions serve as commands (e.g., 👀 to request analysis, 🤖 to trigger a response). diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index e852d7a92..02307edaa 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -53,6 +53,10 @@ export type DiscordGuildEntry = { reactionNotifications?: DiscordReactionNotificationMode; /** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */ reactionTrigger?: DiscordReactionTriggerMode; + /** Only trigger on specific emojis (e.g., ["🤖", "👀"]). Empty/omitted = all emojis. */ + reactionTriggerEmojis?: string[]; + /** Cooldown in ms between reaction triggers per user per message. Default: 30000 (30s). */ + reactionTriggerCooldownMs?: number; users?: Array; channels?: Record; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1ca78b9b0..8484c9308 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -208,6 +208,10 @@ export const DiscordGuildSchema = z reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), /** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */ reactionTrigger: z.enum(["off", "own", "all", "allowlist"]).optional(), + /** Only trigger on specific emojis (e.g., ["🤖", "👀"]). Empty/omitted = all emojis. */ + reactionTriggerEmojis: z.array(z.string()).optional(), + /** Cooldown in ms between reaction triggers per user per message. Default: 30000 (30s). */ + reactionTriggerCooldownMs: z.number().int().min(0).optional(), users: z.array(z.union([z.string(), z.number()])).optional(), channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(), }) diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 89fb8ceb9..f241c0dc1 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -24,6 +24,10 @@ export type DiscordGuildEntryResolved = { reactionNotifications?: "off" | "own" | "all" | "allowlist"; /** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */ reactionTrigger?: "off" | "own" | "all" | "allowlist"; + /** Only trigger on specific emojis. Empty/omitted = all emojis. */ + reactionTriggerEmojis?: string[]; + /** Cooldown in ms between reaction triggers per user per message. Default: 30000 (30s). */ + reactionTriggerCooldownMs?: number; users?: Array; channels?: Record< string, diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index aa941ff11..63fa12264 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -28,6 +28,39 @@ type LoadedConfig = ReturnType; +// Rate limiting for reaction triggers: Map<"messageId:userId", lastTriggerTimestamp> +const reactionTriggerCooldowns = new Map(); +const DEFAULT_REACTION_TRIGGER_COOLDOWN_MS = 30_000; // 30 seconds + +function checkReactionTriggerCooldown(params: { + messageId: string; + userId: string; + cooldownMs?: number; +}): boolean { + const { messageId, userId, cooldownMs = DEFAULT_REACTION_TRIGGER_COOLDOWN_MS } = params; + if (cooldownMs <= 0) return true; // No cooldown + + const key = `${messageId}:${userId}`; + const now = Date.now(); + const lastTrigger = reactionTriggerCooldowns.get(key); + + if (lastTrigger && now - lastTrigger < cooldownMs) { + return false; // Still in cooldown + } + + reactionTriggerCooldowns.set(key, now); + + // Cleanup old entries periodically (keep map from growing unbounded) + if (reactionTriggerCooldowns.size > 1000) { + const cutoff = now - cooldownMs * 2; + for (const [k, v] of reactionTriggerCooldowns) { + if (v < cutoff) reactionTriggerCooldowns.delete(k); + } + } + + return true; // Allowed +} + export type DiscordMessageEvent = Parameters[0]; export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise; @@ -242,16 +275,38 @@ async function handleDiscordReactionEvent(params: { const message = await data.message.fetch().catch(() => null); const messageAuthorId = message?.author?.id ?? undefined; - // Check if we should trigger an agent turn - const shouldTrigger = shouldTriggerDiscordReaction({ - mode: reactionTriggerMode, - botId: botUserId, - messageAuthorId, - userId: user.id, - userName: user.username, - userTag: formatDiscordUserTag(user), - allowlist: guildInfo?.users, - }); + const emojiLabel = formatDiscordReactionEmoji(data.emoji); + + // Check if we should trigger an agent turn (only on "added", not "removed") + let shouldTrigger = false; + if (action === "added") { + shouldTrigger = shouldTriggerDiscordReaction({ + mode: reactionTriggerMode, + botId: botUserId, + messageAuthorId, + userId: user.id, + userName: user.username, + userTag: formatDiscordUserTag(user), + allowlist: guildInfo?.users, + }); + + // Check emoji filter if configured + if (shouldTrigger && guildInfo?.reactionTriggerEmojis?.length) { + const allowedEmojis = guildInfo.reactionTriggerEmojis; + shouldTrigger = allowedEmojis.includes(emojiLabel); + } + + // Check rate limit + if (shouldTrigger) { + const cooldownMs = + guildInfo?.reactionTriggerCooldownMs ?? DEFAULT_REACTION_TRIGGER_COOLDOWN_MS; + shouldTrigger = checkReactionTriggerCooldown({ + messageId: data.message_id, + userId: user.id, + cooldownMs, + }); + } + } // Check if we should notify via system event (only if not triggering) const shouldNotify = @@ -267,8 +322,6 @@ async function handleDiscordReactionEvent(params: { }); if (!shouldTrigger && !shouldNotify) return; - - const emojiLabel = formatDiscordReactionEmoji(data.emoji); const actorLabel = formatDiscordUserTag(user); const guildSlug = guildInfo?.slug || (data.guild?.name ? normalizeDiscordSlug(data.guild.name) : data.guild_id);