From 37dfcf75bd683e80735b70c4cc9e56a5b58b7312 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Thu, 29 Jan 2026 21:37:36 +0900 Subject: [PATCH 1/3] 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, From 74a92db9aa914cc75b66f68cd20b66ab788e8837 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Thu, 29 Jan 2026 21:58:15 +0900 Subject: [PATCH 2/3] docs(discord): add reaction trigger and reactionNotifications - Document reactionTrigger in Discord channel and gateway config - Add reactionNotifications to tools/reactions (Discord, Signal-style) - Add Reaction trigger section and config example in discord.md --- docs/channels/discord.md | 34 ++++++++++++++++++++++++++++++++++ docs/gateway/configuration.md | 9 ++++++++- docs/tools/reactions.md | 3 ++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ce9dc04f3..809a0ecd1 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -57,6 +57,7 @@ Minimal config: 12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `channels.discord.actions.*`). - Reaction removal semantics: see [/tools/reactions](/tools/reactions). - The `discord` tool is only exposed when the current channel is Discord. + - User reactions on the bot's messages can **trigger** the session (yes/no style) when `guilds..reactionTrigger` is enabled; see [Reaction trigger](#reaction-trigger). 13. Native commands use isolated session keys (`agent::discord:slash:`) rather than the shared `main` session. Note: Name β†’ id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions. @@ -266,6 +267,12 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after slug: "friends-of-clawd", requireMention: false, reactionNotifications: "own", + reactionTrigger: { + enabled: true, + windowSeconds: 60, + positiveEmojis: ["πŸ‘", "βœ…", "πŸ‘Œ"], + negativeEmojis: ["πŸ‘Ž", "❌"] + }, users: ["987654321098765432", "steipete"], channels: { general: { allow: true }, @@ -311,6 +318,7 @@ ack reaction after the bot replies. - `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`). +- `guilds..reactionTrigger`: optional config to turn user reactions on the bot's messages into session triggers. When enabled, positive/negative emoji reactions on the bot's recent messages (within a time window) dispatch an inbound message to the session instead of only emitting a system event. See [Reaction trigger](#reaction-trigger) below. - `textChunkLimit`: outbound text chunk size (chars). Default: 2000. - `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `maxLinesPerMessage`: soft max line count per message. Default: 17. @@ -332,6 +340,32 @@ Reaction notifications use `guilds..reactionNotifications`: - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). +#### Reaction trigger + +When `guilds..reactionTrigger.enabled` is `true`, reactions on the **bot's own messages** within a short time window are treated as session triggers: the agent receives an inbound message describing the reaction (e.g. positive/negative and who reacted), and can reply in the same channel. Useful for yes/no or confirm/cancel flows without typing a new message. + +- **Scope**: only reactions on messages sent by the bot; only within `reactionTrigger.windowSeconds` (default 60) after the bot message. +- **Classification**: emoji are classified as positive (e.g. πŸ‘ βœ… πŸ‘Œ) or negative (e.g. πŸ‘Ž ❌). Custom lists: `reactionTrigger.positiveEmojis` and `reactionTrigger.negativeEmojis`. Neutral emoji do not trigger. +- **Behavior**: when a positive or negative reaction matches, a system event is enqueued and an inbound message is dispatched to the session; the agent can reply. Regular reaction notifications are not emitted for that reaction. + +Example: + +```json5 +"YOUR_GUILD_ID": { + "requireMention": false, + "reactionNotifications": "own", + "reactionTrigger": { + "enabled": true, + "windowSeconds": 60, + "positiveEmojis": ["πŸ‘", "βœ…", "πŸ‘Œ"], + "negativeEmojis": ["πŸ‘Ž", "❌"] + }, + "channels": { "general": { "allow": true } } +} +``` + +Omit `positiveEmojis`/`negativeEmojis` to use built-in default lists. + ### Tool action defaults | Action group | Default | Notes | diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1d270974d..e4e1f8f25 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1090,6 +1090,12 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc slug: "friends-of-clawd", requireMention: false, // per-guild default reactionNotifications: "own", // off | own | all | allowlist + reactionTrigger: { // optional: turn reactions on bot messages into session triggers + enabled: true, + windowSeconds: 60, + positiveEmojis: ["πŸ‘", "βœ…", "πŸ‘Œ"], + negativeEmojis: ["πŸ‘Ž", "❌"] + }, users: ["987654321098765432"], // optional per-guild user allowlist channels: { general: { allow: true }, @@ -1121,11 +1127,12 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc Moltbot starts Discord only when a `channels.discord` config section exists. The token is resolved from `channels.discord.token`, with `DISCORD_BOT_TOKEN` as a fallback for the default account (unless `channels.discord.enabled` is `false`). Use `user:` (DM) or `channel:` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected. Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity. Bot-authored messages are ignored by default. Enable with `channels.discord.allowBots` (own messages are still filtered to prevent self-reply loops). -Reaction notification modes: +Reaction notification modes (`guilds..reactionNotifications`): - `off`: no reaction events. - `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). +Optional `guilds..reactionTrigger`: when enabled, positive/negative reactions on the bot's recent messages (within `windowSeconds`, default 60) dispatch an inbound message to the session instead of only emitting a system event. See [Discord](/channels/discord#reaction-trigger). Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry). diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 364f38695..ea77604e7 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -13,7 +13,8 @@ Shared reaction semantics across channels: Channel notes: -- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. +- **Discord**: Inbound reaction notifications emit system events per guild via `channels.discord.guilds..reactionNotifications` (`off` | `own` | `all` | `allowlist`); see [Discord](/channels/discord). Reactions on the bot's messages can also **trigger** the session when `reactionTrigger` is enabled for the guild (see [Discord](/channels/discord#reaction-trigger)). Empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. +- **Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. - **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji. - **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. - **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). From a0a4c617e2e04f932d05dd5f460a3a7a4fdf5600 Mon Sep 17 00:00:00 2001 From: jjangg96 Date: Thu, 29 Jan 2026 22:12:09 +0900 Subject: [PATCH 3/3] fix(discord): remove unused imports from monitor provider --- src/discord/monitor/provider.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 7b2671b13..b05e37438 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -29,13 +29,11 @@ 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 {