From 37dfcf75bd683e80735b70c4cc9e56a5b58b7312 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Thu, 29 Jan 2026 21:37:36 +0900 Subject: [PATCH] feat(discord): add reaction trigger feature - Add reactionTrigger config option for guilds - Cache bot messages for reaction trigger detection - Classify reactions as positive/negative/neutral - Trigger session when positive/negative reaction on bot message within time window - Support customizable emoji lists and time window (default 60s) - Add zod schema for config validation - Fix: fallback for userName when user.username is undefined --- src/config/types.discord.ts | 13 ++ src/config/zod-schema.providers-core.ts | 10 ++ src/discord/monitor/allow-list.ts | 8 ++ src/discord/monitor/listeners.ts | 160 +++++++++++++++++++++++- src/discord/monitor/provider.ts | 63 ++++++++++ src/discord/monitor/reply-delivery.ts | 21 +++- 6 files changed, 270 insertions(+), 5 deletions(-) diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 07d4e658f..9df5c15e1 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -41,6 +41,17 @@ export type DiscordGuildChannelConfig = { export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist"; +export type DiscordReactionTriggerConfig = { + /** Enable reaction triggers on bot messages. Default: false. */ + enabled?: boolean; + /** Time window in seconds for valid reactions after bot message. Default: 60. */ + windowSeconds?: number; + /** Positive reaction emojis that trigger affirmative action. */ + positiveEmojis?: string[]; + /** Negative reaction emojis that trigger negative action. */ + negativeEmojis?: string[]; +}; + export type DiscordGuildEntry = { slug?: string; requireMention?: boolean; @@ -49,6 +60,8 @@ export type DiscordGuildEntry = { toolsBySender?: GroupToolPolicyBySenderConfig; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ reactionNotifications?: DiscordReactionNotificationMode; + /** Reaction trigger configuration for bot message responses. */ + reactionTrigger?: DiscordReactionTriggerConfig; users?: Array; channels?: Record; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..581a6e092 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -199,6 +199,15 @@ export const DiscordGuildChannelSchema = z }) .strict(); +export const DiscordReactionTriggerSchema = z + .object({ + enabled: z.boolean().optional(), + windowSeconds: z.number().int().positive().optional(), + positiveEmojis: z.array(z.string()).optional(), + negativeEmojis: z.array(z.string()).optional(), + }) + .strict(); + export const DiscordGuildSchema = z .object({ slug: z.string().optional(), @@ -206,6 +215,7 @@ export const DiscordGuildSchema = z tools: ToolPolicySchema, toolsBySender: ToolPolicyBySenderSchema, reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), + reactionTrigger: DiscordReactionTriggerSchema.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 12c2d1d39..0e9165924 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -17,11 +17,19 @@ export type DiscordAllowList = { export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">; +export type DiscordReactionTriggerResolved = { + enabled?: boolean; + windowSeconds?: number; + positiveEmojis?: string[]; + negativeEmojis?: string[]; +}; + export type DiscordGuildEntryResolved = { id?: string; slug?: string; requireMention?: boolean; reactionNotifications?: "off" | "own" | "all" | "allowlist"; + reactionTrigger?: DiscordReactionTriggerResolved; users?: Array; channels?: Record< string, diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 770ae6d6c..3234e28d5 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -18,10 +18,104 @@ import { resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, shouldEmitDiscordReactionNotification, + type DiscordReactionTriggerResolved, } from "./allow-list.js"; import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; +// ============================================================================ +// Reaction Trigger Support +// ============================================================================ + +// Cache of recent bot messages for reaction trigger feature +// Key: channelId:messageId, Value: { timestamp, content } +type BotMessageCacheEntry = { + timestamp: number; + content: string; + channelId: string; +}; + +const botMessageCache = new Map(); +const BOT_MESSAGE_CACHE_MAX_SIZE = 1000; +const BOT_MESSAGE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +// Default emoji classifications +const DEFAULT_POSITIVE_EMOJIS = ["πŸ‘", "βœ…", "πŸ‘Œ", "❀️", "πŸ™Œ", "⭐", "πŸŽ‰", "πŸ’―", "βœ”οΈ", "πŸ†—", "πŸ‘"]; +const DEFAULT_NEGATIVE_EMOJIS = ["πŸ‘Ž", "❌", "🚫", "β›”", "πŸ›‘"]; + +export function cacheBotMessage(params: { channelId: string; messageId: string; content: string }) { + const key = `${params.channelId}:${params.messageId}`; + botMessageCache.set(key, { + timestamp: Date.now(), + content: params.content, + channelId: params.channelId, + }); + + // Cleanup old entries + if (botMessageCache.size > BOT_MESSAGE_CACHE_MAX_SIZE) { + const now = Date.now(); + for (const [k, v] of botMessageCache) { + if (now - v.timestamp > BOT_MESSAGE_CACHE_TTL_MS) { + botMessageCache.delete(k); + } + } + } +} + +function getBotMessageFromCache(channelId: string, messageId: string): BotMessageCacheEntry | null { + const key = `${channelId}:${messageId}`; + const entry = botMessageCache.get(key); + if (!entry) return null; + // Check TTL + if (Date.now() - entry.timestamp > BOT_MESSAGE_CACHE_TTL_MS) { + botMessageCache.delete(key); + return null; + } + return entry; +} + +type ReactionSentiment = "positive" | "negative" | "neutral"; + +function classifyReactionEmoji( + emoji: string, + config?: DiscordReactionTriggerResolved, +): ReactionSentiment { + const positiveEmojis = config?.positiveEmojis ?? DEFAULT_POSITIVE_EMOJIS; + const negativeEmojis = config?.negativeEmojis ?? DEFAULT_NEGATIVE_EMOJIS; + + if (positiveEmojis.includes(emoji)) return "positive"; + if (negativeEmojis.includes(emoji)) return "negative"; + return "neutral"; +} + +function shouldTriggerOnReaction(params: { + botUserId?: string; + messageAuthorId?: string; + messageTimestamp: number; + config?: DiscordReactionTriggerResolved; + emojiSentiment: ReactionSentiment; +}): boolean { + const { botUserId, messageAuthorId, messageTimestamp, config, emojiSentiment } = params; + + // Must be enabled + if (!config?.enabled) return false; + + // Must be bot's own message + if (!botUserId || messageAuthorId !== botUserId) return false; + + // Must be within time window + const windowMs = (config.windowSeconds ?? 60) * 1000; + const elapsed = Date.now() - messageTimestamp; + if (elapsed > windowMs) return false; + + // Must be positive or negative (not neutral) + if (emojiSentiment === "neutral") return false; + + return true; +} + +// ============================================================================ + type LoadedConfig = ReturnType; type RuntimeEnv = import("../../runtime.js").RuntimeEnv; type Logger = ReturnType; @@ -92,6 +186,17 @@ export class DiscordMessageListener extends MessageCreateListener { } } +export type ReactionTriggerCallback = (params: { + channelId: string; + messageId: string; + originalContent: string; + emoji: string; + sentiment: ReactionSentiment; + userId: string; + userName: string; + client: Client; +}) => Promise; + export class DiscordReactionListener extends MessageReactionAddListener { constructor( private params: { @@ -101,6 +206,7 @@ export class DiscordReactionListener extends MessageReactionAddListener { botUserId?: string; guildEntries?: Record; logger: Logger; + onReactionTrigger?: ReactionTriggerCallback; }, ) { super(); @@ -118,6 +224,7 @@ export class DiscordReactionListener extends MessageReactionAddListener { botUserId: this.params.botUserId, guildEntries: this.params.guildEntries, logger: this.params.logger, + onReactionTrigger: this.params.onReactionTrigger, }); } finally { logSlowDiscordListener({ @@ -177,9 +284,10 @@ async function handleDiscordReactionEvent(params: { botUserId?: string; guildEntries?: Record; logger: Logger; + onReactionTrigger?: ReactionTriggerCallback; }) { try { - const { data, client, action, botUserId, guildEntries } = params; + const { data, client, action, botUserId, guildEntries, onReactionTrigger } = params; if (!("user" in data)) return; const user = data.user; if (!user || user.bot) return; @@ -254,8 +362,6 @@ async function handleDiscordReactionEvent(params: { ? `#${normalizeDiscordSlug(channelName)}` : `#${data.channel_id}`; const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined; - const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; - const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; const route = resolveAgentRoute({ cfg: params.cfg, channel: "discord", @@ -263,6 +369,54 @@ async function handleDiscordReactionEvent(params: { guildId: data.guild_id ?? undefined, peer: { kind: "channel", id: data.channel_id }, }); + + // Check reaction trigger conditions (only for "added" action) + if (action === "added" && onReactionTrigger) { + const reactionTriggerConfig = guildInfo?.reactionTrigger; + const emojiSentiment = classifyReactionEmoji(emojiLabel, reactionTriggerConfig); + + // Try to get cached bot message info + const cachedMessage = getBotMessageFromCache(data.channel_id, data.message_id); + const messageTimestamp = cachedMessage?.timestamp ?? message?.createdAt?.getTime() ?? 0; + const messageContent = cachedMessage?.content ?? message?.content ?? ""; + + const shouldTrigger = shouldTriggerOnReaction({ + botUserId, + messageAuthorId, + messageTimestamp, + config: reactionTriggerConfig, + emojiSentiment, + }); + + if (shouldTrigger) { + // Build enhanced system event text for reaction trigger + const sentimentLabel = emojiSentiment === "positive" ? "POSITIVE" : "NEGATIVE"; + const triggerText = `[Reaction Trigger] ${sentimentLabel} response (${emojiLabel}) from ${actorLabel} to bot message: "${messageContent.slice(0, 200)}${messageContent.length > 200 ? "..." : ""}"`; + + enqueueSystemEvent(triggerText, { + sessionKey: route.sessionKey, + contextKey: `discord:reaction-trigger:${data.message_id}:${user.id}:${emojiLabel}`, + }); + + // Call the trigger callback to wake the session + await onReactionTrigger({ + channelId: data.channel_id, + messageId: data.message_id, + originalContent: messageContent, + emoji: emojiLabel, + sentiment: emojiSentiment, + userId: user.id, + userName: user.username || actorLabel || user.id, + client, + }); + + return; // Don't also emit regular notification + } + } + + // Regular reaction notification (existing behavior) + const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; enqueueSystemEvent(text, { sessionKey: route.sessionKey, contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`, diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 3ab56a478..7b2671b13 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -3,10 +3,12 @@ import { Client } from "@buape/carbon"; import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; import { Routes } from "discord-api-types/v10"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; +import { dispatchInboundMessageWithDispatcher } from "../../auto-reply/dispatch.js"; import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js"; import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js"; +import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, @@ -27,11 +29,13 @@ import { resolveDiscordChannelAllowlist } from "../resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../resolve-users.js"; import { normalizeDiscordToken } from "../token.js"; import { + cacheBotMessage, DiscordMessageListener, DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, registerDiscordListener, + type ReactionTriggerCallback, } from "./listeners.js"; import { createDiscordMessageHandler } from "./message-handler.js"; import { @@ -518,6 +522,64 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger)); + + // Reaction trigger callback - dispatches to session when reaction trigger conditions are met + const onReactionTrigger: import("./listeners.js").ReactionTriggerCallback = async (params) => { + const { + channelId, + originalContent, + emoji, + sentiment, + userId, + userName, + client: triggerClient, + } = params; + const sentimentLabel = sentiment === "positive" ? "YES/확인" : "NO/κ±°λΆ€"; + const triggerMessage = `[λ¦¬μ•‘μ…˜ 응닡: ${sentimentLabel}] ${userName}λ‹˜μ΄ ${emoji} λ¦¬μ•‘μ…˜μœΌλ‘œ μ‘λ‹΅ν–ˆμŠ΅λ‹ˆλ‹€. 원본 λ©”μ‹œμ§€: "${originalContent.slice(0, 150)}${originalContent.length > 150 ? "..." : ""}"`; + + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: account.accountId, + guildId: undefined, // Will be resolved from channel + peer: { kind: "channel", id: channelId }, + }); + + // Fire-and-forget: don't await to avoid blocking the reaction listener + void dispatchInboundMessageWithDispatcher({ + ctx: { + Body: triggerMessage, + BodyForAgent: triggerMessage, + SessionKey: route.sessionKey, + Provider: "discord", + Surface: "discord", + AccountId: account.accountId, + From: userName, + SenderName: userName, + SenderId: userId, + MessageSid: `reaction-trigger-${Date.now()}`, + ChatType: "group", + }, + cfg, + dispatcherOptions: { + deliver: async (reply) => { + // Send reply to Discord channel + if (!reply.text) return; + try { + const channel = await triggerClient.fetchChannel(channelId); + if (channel && "send" in channel) { + await channel.send({ content: reply.text }); + } + } catch (err) { + logger.error(danger(`reaction trigger reply failed: ${String(err)}`)); + } + }, + }, + }).catch((err) => { + logger.error(danger(`reaction trigger dispatch failed: ${String(err)}`)); + }); + }; + registerDiscordListener( client.listeners, new DiscordReactionListener({ @@ -527,6 +589,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { botUserId, guildEntries, logger, + onReactionTrigger, }), ); registerDiscordListener( diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 3b63f8842..3554d365c 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -7,6 +7,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { sendMessageDiscord } from "../send.js"; +import { cacheBotMessage } from "./listeners.js"; export async function deliverDiscordReply(params: { replies: ReplyPayload[]; @@ -42,12 +43,20 @@ export async function deliverDiscordReply(params: { for (const chunk of chunks) { const trimmed = chunk.trim(); if (!trimmed) continue; - await sendMessageDiscord(params.target, trimmed, { + const result = await sendMessageDiscord(params.target, trimmed, { token: params.token, rest: params.rest, accountId: params.accountId, replyTo: isFirstChunk ? replyTo : undefined, }); + // Cache bot message for reaction trigger feature + if (result.messageId && result.messageId !== "unknown") { + cacheBotMessage({ + channelId: result.channelId, + messageId: result.messageId, + content: trimmed, + }); + } isFirstChunk = false; } continue; @@ -55,13 +64,21 @@ export async function deliverDiscordReply(params: { const firstMedia = mediaList[0]; if (!firstMedia) continue; - await sendMessageDiscord(params.target, text, { + const mediaResult = await sendMessageDiscord(params.target, text, { token: params.token, rest: params.rest, mediaUrl: firstMedia, accountId: params.accountId, replyTo, }); + // Cache bot message for reaction trigger feature + if (mediaResult.messageId && mediaResult.messageId !== "unknown" && text) { + cacheBotMessage({ + channelId: mediaResult.channelId, + messageId: mediaResult.messageId, + content: text, + }); + } for (const extra of mediaList.slice(1)) { await sendMessageDiscord(params.target, "", { token: params.token,