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
This commit is contained in:
parent
4be297d650
commit
5ad27128ad
@ -341,10 +341,18 @@ Use `guilds.<id>.reactionTrigger` to invoke an agent turn when a user reacts to
|
|||||||
- `all`: trigger agent on reactions to any message.
|
- `all`: trigger agent on reactions to any message.
|
||||||
- `allowlist`: trigger agent for reactions from `guilds.<id>.users` only.
|
- `allowlist`: trigger agent for reactions from `guilds.<id>.users` only.
|
||||||
|
|
||||||
|
Additional options:
|
||||||
|
- `guilds.<id>.reactionTriggerEmojis`: array of emojis that trigger (e.g., `["🤖", "👀"]`). Omit to allow all emojis.
|
||||||
|
- `guilds.<id>.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:
|
When triggered, the agent receives a synthetic message containing:
|
||||||
- The reactor's user tag and the emoji they used
|
- The reactor's user tag and the emoji they used
|
||||||
- The content of the reacted-to message (if available)
|
- 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).
|
This is useful for interactive workflows where reactions serve as commands (e.g., 👀 to request analysis, 🤖 to trigger a response).
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export type DiscordGuildEntry = {
|
|||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
reactionNotifications?: DiscordReactionNotificationMode;
|
||||||
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
||||||
reactionTrigger?: DiscordReactionTriggerMode;
|
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<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -208,6 +208,10 @@ export const DiscordGuildSchema = z
|
|||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
||||||
reactionTrigger: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
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(),
|
users: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export type DiscordGuildEntryResolved = {
|
|||||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
||||||
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
/** Reaction trigger mode: invoke agent turn on reaction (off|own|all|allowlist). Default: off. */
|
||||||
reactionTrigger?: "off" | "own" | "all" | "allowlist";
|
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<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<
|
channels?: Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -28,6 +28,39 @@ type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfi
|
|||||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||||
type Logger = ReturnType<typeof import("../../logging/subsystem.js").createSubsystemLogger>;
|
type Logger = ReturnType<typeof import("../../logging/subsystem.js").createSubsystemLogger>;
|
||||||
|
|
||||||
|
// Rate limiting for reaction triggers: Map<"messageId:userId", lastTriggerTimestamp>
|
||||||
|
const reactionTriggerCooldowns = new Map<string, number>();
|
||||||
|
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<MessageCreateListener["handle"]>[0];
|
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
|
||||||
|
|
||||||
export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise<void>;
|
export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client) => Promise<void>;
|
||||||
@ -242,16 +275,38 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
const message = await data.message.fetch().catch(() => null);
|
const message = await data.message.fetch().catch(() => null);
|
||||||
const messageAuthorId = message?.author?.id ?? undefined;
|
const messageAuthorId = message?.author?.id ?? undefined;
|
||||||
|
|
||||||
// Check if we should trigger an agent turn
|
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
|
||||||
const shouldTrigger = shouldTriggerDiscordReaction({
|
|
||||||
mode: reactionTriggerMode,
|
// Check if we should trigger an agent turn (only on "added", not "removed")
|
||||||
botId: botUserId,
|
let shouldTrigger = false;
|
||||||
messageAuthorId,
|
if (action === "added") {
|
||||||
userId: user.id,
|
shouldTrigger = shouldTriggerDiscordReaction({
|
||||||
userName: user.username,
|
mode: reactionTriggerMode,
|
||||||
userTag: formatDiscordUserTag(user),
|
botId: botUserId,
|
||||||
allowlist: guildInfo?.users,
|
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)
|
// Check if we should notify via system event (only if not triggering)
|
||||||
const shouldNotify =
|
const shouldNotify =
|
||||||
@ -267,8 +322,6 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!shouldTrigger && !shouldNotify) return;
|
if (!shouldTrigger && !shouldNotify) return;
|
||||||
|
|
||||||
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
|
|
||||||
const actorLabel = formatDiscordUserTag(user);
|
const actorLabel = formatDiscordUserTag(user);
|
||||||
const guildSlug =
|
const guildSlug =
|
||||||
guildInfo?.slug || (data.guild?.name ? normalizeDiscordSlug(data.guild.name) : data.guild_id);
|
guildInfo?.slug || (data.guild?.name ? normalizeDiscordSlug(data.guild.name) : data.guild_id);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user