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
This commit is contained in:
jjangg96 2026-01-29 21:37:36 +09:00
parent 5f4715acfc
commit 37dfcf75bd
6 changed files with 270 additions and 5 deletions

View File

@ -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<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>;
};

View File

@ -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(),
})

View File

@ -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<string | number>;
channels?: Record<
string,

View File

@ -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<string, BotMessageCacheEntry>();
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<typeof import("../../config/config.js").loadConfig>;
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
type Logger = ReturnType<typeof import("../../logging/subsystem.js").createSubsystemLogger>;
@ -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<void>;
export class DiscordReactionListener extends MessageReactionAddListener {
constructor(
private params: {
@ -101,6 +206,7 @@ export class DiscordReactionListener extends MessageReactionAddListener {
botUserId?: string;
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
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<string, import("./allow-list.js").DiscordGuildEntryResolved>;
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}`,

View File

@ -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(

View File

@ -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,