Merge 5ad27128ad into 6af205a13a
This commit is contained in:
commit
56a5ab8c7e
@ -311,6 +311,7 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
|
- `guilds.<id>.reactionTrigger`: reaction trigger mode - invoke agent turn on reaction (`off`, `own`, `all`, `allowlist`). Default: `off`.
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `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.
|
- `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.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
@ -332,6 +333,29 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
|
|
||||||
|
### Reaction triggers
|
||||||
|
|
||||||
|
Use `guilds.<id>.reactionTrigger` to invoke an agent turn when a user reacts to a message (instead of just queueing a system event):
|
||||||
|
- `off`: no agent invocation on reactions (default).
|
||||||
|
- `own`: trigger agent when reacting to the bot's own messages.
|
||||||
|
- `all`: trigger agent on reactions to any message.
|
||||||
|
- `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:
|
||||||
|
- The reactor's user tag and the emoji they used
|
||||||
|
- The content of the reacted-to message (if available)
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
### Tool action defaults
|
### Tool action defaults
|
||||||
|
|
||||||
| Action group | Default | Notes |
|
| Action group | Default | Notes |
|
||||||
|
|||||||
13304
package-lock.json
generated
Normal file
13304
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,8 @@ export type DiscordGuildChannelConfig = {
|
|||||||
|
|
||||||
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||||
|
|
||||||
|
export type DiscordReactionTriggerMode = "off" | "own" | "all" | "allowlist";
|
||||||
|
|
||||||
export type DiscordGuildEntry = {
|
export type DiscordGuildEntry = {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
@ -49,6 +51,12 @@ export type DiscordGuildEntry = {
|
|||||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||||
reactionNotifications?: DiscordReactionNotificationMode;
|
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<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -206,6 +206,12 @@ export const DiscordGuildSchema = z
|
|||||||
tools: ToolPolicySchema,
|
tools: ToolPolicySchema,
|
||||||
toolsBySender: ToolPolicyBySenderSchema,
|
toolsBySender: ToolPolicyBySenderSchema,
|
||||||
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. */
|
||||||
|
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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -22,6 +22,12 @@ export type DiscordGuildEntryResolved = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
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<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<
|
channels?: Record<
|
||||||
string,
|
string,
|
||||||
@ -366,3 +372,34 @@ export function shouldEmitDiscordReactionNotification(params: {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a reaction should trigger an agent turn (invoke the agent).
|
||||||
|
* Uses the same logic as shouldEmitDiscordReactionNotification but with a separate config.
|
||||||
|
*/
|
||||||
|
export function shouldTriggerDiscordReaction(params: {
|
||||||
|
mode?: "off" | "own" | "all" | "allowlist";
|
||||||
|
botId?: string;
|
||||||
|
messageAuthorId?: string;
|
||||||
|
userId: string;
|
||||||
|
userName?: string;
|
||||||
|
userTag?: string;
|
||||||
|
allowlist?: Array<string | number>;
|
||||||
|
}): boolean {
|
||||||
|
const mode = params.mode ?? "off"; // Default: off (don't trigger)
|
||||||
|
if (mode === "off") return false;
|
||||||
|
if (mode === "all") return true;
|
||||||
|
if (mode === "own") {
|
||||||
|
return Boolean(params.botId && params.messageAuthorId === params.botId);
|
||||||
|
}
|
||||||
|
if (mode === "allowlist") {
|
||||||
|
const list = normalizeDiscordAllowList(params.allowlist, ["discord:", "user:"]);
|
||||||
|
if (!list) return false;
|
||||||
|
return allowListMatches(list, {
|
||||||
|
id: params.userId,
|
||||||
|
name: params.userName,
|
||||||
|
tag: params.userTag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@ -18,14 +18,49 @@ import {
|
|||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
shouldEmitDiscordReactionNotification,
|
shouldEmitDiscordReactionNotification,
|
||||||
|
shouldTriggerDiscordReaction,
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||||
|
import { dispatchReactionTrigger } from "./reaction-trigger.js";
|
||||||
|
|
||||||
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||||
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>;
|
||||||
@ -97,6 +132,7 @@ export class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
private params: {
|
private params: {
|
||||||
cfg: LoadedConfig;
|
cfg: LoadedConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
token: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
@ -115,6 +151,7 @@ export class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
action: "added",
|
action: "added",
|
||||||
cfg: this.params.cfg,
|
cfg: this.params.cfg,
|
||||||
accountId: this.params.accountId,
|
accountId: this.params.accountId,
|
||||||
|
token: this.params.token,
|
||||||
botUserId: this.params.botUserId,
|
botUserId: this.params.botUserId,
|
||||||
guildEntries: this.params.guildEntries,
|
guildEntries: this.params.guildEntries,
|
||||||
logger: this.params.logger,
|
logger: this.params.logger,
|
||||||
@ -135,6 +172,7 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener
|
|||||||
private params: {
|
private params: {
|
||||||
cfg: LoadedConfig;
|
cfg: LoadedConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
token: string;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
@ -153,6 +191,7 @@ export class DiscordReactionRemoveListener extends MessageReactionRemoveListener
|
|||||||
action: "removed",
|
action: "removed",
|
||||||
cfg: this.params.cfg,
|
cfg: this.params.cfg,
|
||||||
accountId: this.params.accountId,
|
accountId: this.params.accountId,
|
||||||
|
token: this.params.token,
|
||||||
botUserId: this.params.botUserId,
|
botUserId: this.params.botUserId,
|
||||||
guildEntries: this.params.guildEntries,
|
guildEntries: this.params.guildEntries,
|
||||||
logger: this.params.logger,
|
logger: this.params.logger,
|
||||||
@ -174,6 +213,7 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
action: "added" | "removed";
|
action: "added" | "removed";
|
||||||
cfg: LoadedConfig;
|
cfg: LoadedConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
token: string;
|
||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@ -230,21 +270,58 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
|
|
||||||
if (botUserId && user.id === botUserId) return;
|
if (botUserId && user.id === botUserId) return;
|
||||||
|
|
||||||
const reactionMode = guildInfo?.reactionNotifications ?? "own";
|
const reactionNotifyMode = guildInfo?.reactionNotifications ?? "own";
|
||||||
|
const reactionTriggerMode = guildInfo?.reactionTrigger ?? "off";
|
||||||
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;
|
||||||
const shouldNotify = shouldEmitDiscordReactionNotification({
|
|
||||||
mode: reactionMode,
|
|
||||||
botId: botUserId,
|
|
||||||
messageAuthorId,
|
|
||||||
userId: user.id,
|
|
||||||
userName: user.username,
|
|
||||||
userTag: formatDiscordUserTag(user),
|
|
||||||
allowlist: guildInfo?.users,
|
|
||||||
});
|
|
||||||
if (!shouldNotify) return;
|
|
||||||
|
|
||||||
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
|
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 =
|
||||||
|
!shouldTrigger &&
|
||||||
|
shouldEmitDiscordReactionNotification({
|
||||||
|
mode: reactionNotifyMode,
|
||||||
|
botId: botUserId,
|
||||||
|
messageAuthorId,
|
||||||
|
userId: user.id,
|
||||||
|
userName: user.username,
|
||||||
|
userTag: formatDiscordUserTag(user),
|
||||||
|
allowlist: guildInfo?.users,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldTrigger && !shouldNotify) return;
|
||||||
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);
|
||||||
@ -253,9 +330,7 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
: channelName
|
: channelName
|
||||||
? `#${normalizeDiscordSlug(channelName)}`
|
? `#${normalizeDiscordSlug(channelName)}`
|
||||||
: `#${data.channel_id}`;
|
: `#${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({
|
const route = resolveAgentRoute({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
channel: "discord",
|
channel: "discord",
|
||||||
@ -263,6 +338,33 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
guildId: data.guild_id ?? undefined,
|
guildId: data.guild_id ?? undefined,
|
||||||
peer: { kind: "channel", id: data.channel_id },
|
peer: { kind: "channel", id: data.channel_id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If trigger mode, dispatch to agent
|
||||||
|
if (shouldTrigger) {
|
||||||
|
await dispatchReactionTrigger({
|
||||||
|
cfg: params.cfg,
|
||||||
|
client,
|
||||||
|
accountId: params.accountId,
|
||||||
|
token: params.token,
|
||||||
|
logger: params.logger,
|
||||||
|
route,
|
||||||
|
emoji: emojiLabel,
|
||||||
|
action,
|
||||||
|
reactor: user,
|
||||||
|
message: message as import("@buape/carbon").Message<true> | null,
|
||||||
|
messageId: data.message_id,
|
||||||
|
channelId: data.channel_id,
|
||||||
|
guildId: data.guild_id,
|
||||||
|
guildSlug: typeof guildSlug === "string" ? guildSlug : undefined,
|
||||||
|
channelSlug: channelSlug || undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, queue system event for notification
|
||||||
|
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;
|
||||||
enqueueSystemEvent(text, {
|
enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`,
|
contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`,
|
||||||
|
|||||||
@ -523,6 +523,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
new DiscordReactionListener({
|
new DiscordReactionListener({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
@ -534,6 +535,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
new DiscordReactionRemoveListener({
|
new DiscordReactionRemoveListener({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
token,
|
||||||
runtime,
|
runtime,
|
||||||
botUserId,
|
botUserId,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
|
|||||||
207
src/discord/monitor/reaction-trigger.ts
Normal file
207
src/discord/monitor/reaction-trigger.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Discord reaction trigger dispatch module.
|
||||||
|
*
|
||||||
|
* When reactionTrigger is enabled, this module handles invoking an agent turn
|
||||||
|
* when a user reacts to a message, instead of just queueing a system event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Client, Message, User } from "@buape/carbon";
|
||||||
|
|
||||||
|
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||||
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
||||||
|
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
|
import { resolveStorePath } from "../../config/sessions.js";
|
||||||
|
import { recordInboundSession } from "../../channels/session.js";
|
||||||
|
import { danger, logVerbose } from "../../globals.js";
|
||||||
|
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||||
|
import { normalizeDiscordSlug } from "./allow-list.js";
|
||||||
|
import { formatDiscordUserTag } from "./format.js";
|
||||||
|
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||||
|
|
||||||
|
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||||
|
type Logger = ReturnType<typeof import("../../logging/subsystem.js").createSubsystemLogger>;
|
||||||
|
|
||||||
|
export type DiscordReactionTriggerParams = {
|
||||||
|
cfg: LoadedConfig;
|
||||||
|
client: Client;
|
||||||
|
accountId: string;
|
||||||
|
token: string;
|
||||||
|
logger: Logger;
|
||||||
|
route: ResolvedAgentRoute;
|
||||||
|
// Reaction info
|
||||||
|
emoji: string;
|
||||||
|
action: "added" | "removed";
|
||||||
|
reactor: User;
|
||||||
|
// Message info
|
||||||
|
message: Message<true> | null;
|
||||||
|
messageId: string;
|
||||||
|
channelId: string;
|
||||||
|
guildId: string;
|
||||||
|
guildSlug?: string;
|
||||||
|
channelSlug?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the body text for a reaction-triggered agent turn.
|
||||||
|
*/
|
||||||
|
function formatReactionTriggerBody(params: {
|
||||||
|
emoji: string;
|
||||||
|
action: "added" | "removed";
|
||||||
|
reactor: User;
|
||||||
|
messageContent?: string;
|
||||||
|
messageAuthor?: User;
|
||||||
|
}): string {
|
||||||
|
const { emoji, action, reactor, messageContent, messageAuthor } = params;
|
||||||
|
const reactorTag = formatDiscordUserTag(reactor);
|
||||||
|
const authorTag = messageAuthor ? formatDiscordUserTag(messageAuthor) : "unknown";
|
||||||
|
|
||||||
|
const actionLabel = action === "added" ? "reacted with" : "removed reaction";
|
||||||
|
|
||||||
|
// Format as a clear action request, not just a notification
|
||||||
|
if (messageContent?.trim()) {
|
||||||
|
return `${reactorTag} ${actionLabel} ${emoji} to this message from ${authorTag}:\n"${messageContent}"\n\nPlease acknowledge or respond to this reaction.`;
|
||||||
|
}
|
||||||
|
return `${reactorTag} ${actionLabel} ${emoji} to a message. Please acknowledge this reaction.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a reaction as an agent turn.
|
||||||
|
*
|
||||||
|
* This creates a synthetic inbound message from the reaction and invokes the agent,
|
||||||
|
* similar to how a regular message would be processed.
|
||||||
|
*/
|
||||||
|
export async function dispatchReactionTrigger(params: DiscordReactionTriggerParams): Promise<void> {
|
||||||
|
const {
|
||||||
|
cfg,
|
||||||
|
client,
|
||||||
|
accountId,
|
||||||
|
token,
|
||||||
|
logger,
|
||||||
|
route,
|
||||||
|
emoji,
|
||||||
|
action,
|
||||||
|
reactor,
|
||||||
|
message,
|
||||||
|
messageId,
|
||||||
|
channelId,
|
||||||
|
guildId,
|
||||||
|
guildSlug,
|
||||||
|
channelSlug,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageContent = message?.content ?? undefined;
|
||||||
|
const messageAuthor = message?.author ?? undefined;
|
||||||
|
|
||||||
|
// Build the body text
|
||||||
|
const body = formatReactionTriggerBody({
|
||||||
|
emoji,
|
||||||
|
action,
|
||||||
|
reactor,
|
||||||
|
messageContent,
|
||||||
|
messageAuthor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build envelope options
|
||||||
|
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||||
|
|
||||||
|
// Format as inbound envelope
|
||||||
|
const fromLabel = guildSlug
|
||||||
|
? `Discord ${guildSlug} #${channelSlug ?? channelId}`
|
||||||
|
: `Discord #${channelSlug ?? channelId}`;
|
||||||
|
const senderTag = formatDiscordUserTag(reactor);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
const combinedBody = formatInboundEnvelope({
|
||||||
|
channel: "Discord",
|
||||||
|
from: fromLabel,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
chatType: "channel",
|
||||||
|
senderLabel: senderTag,
|
||||||
|
envelope: envelopeOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build inbound context
|
||||||
|
const ctxPayload = finalizeInboundContext({
|
||||||
|
Body: combinedBody,
|
||||||
|
RawBody: body,
|
||||||
|
CommandBody: body,
|
||||||
|
From: `discord:${reactor.id}`,
|
||||||
|
To: `channel:${channelId}`,
|
||||||
|
SessionKey: route.sessionKey,
|
||||||
|
AccountId: accountId,
|
||||||
|
ChatType: "channel",
|
||||||
|
ConversationLabel: fromLabel,
|
||||||
|
SenderName: reactor.globalName ?? reactor.username,
|
||||||
|
SenderId: reactor.id,
|
||||||
|
SenderUsername: reactor.username,
|
||||||
|
SenderTag: senderTag,
|
||||||
|
GroupChannel: channelSlug ? `#${channelSlug}` : undefined,
|
||||||
|
GroupSpace: guildId,
|
||||||
|
Provider: "discord",
|
||||||
|
Surface: "discord",
|
||||||
|
WasMentioned: false,
|
||||||
|
// Use a unique MessageSid for reactions to avoid dedupe with the original message
|
||||||
|
MessageSid: `${messageId}:reaction:${reactor.id}:${emoji}:${action}`,
|
||||||
|
Timestamp: timestamp,
|
||||||
|
CommandAuthorized: true,
|
||||||
|
CommandSource: "reaction",
|
||||||
|
OriginatingChannel: "discord",
|
||||||
|
OriginatingTo: `channel:${channelId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record session
|
||||||
|
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
|
||||||
|
await recordInboundSession({
|
||||||
|
storePath,
|
||||||
|
sessionKey: route.sessionKey,
|
||||||
|
ctx: ctxPayload,
|
||||||
|
onRecordError: (err: unknown) => {
|
||||||
|
logVerbose(`discord reaction trigger: failed to record session: ${String(err)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logVerbose(
|
||||||
|
`discord reaction trigger: ${emoji} by ${senderTag} on msg ${messageId} → dispatching to agent`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create reply dispatcher
|
||||||
|
const discordConfig = cfg.channels?.discord;
|
||||||
|
const textLimit = discordConfig?.textChunkLimit ?? 2000;
|
||||||
|
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverDiscordReply({
|
||||||
|
replies: [payload],
|
||||||
|
target: `channel:${channelId}`,
|
||||||
|
token,
|
||||||
|
accountId,
|
||||||
|
rest: client.rest,
|
||||||
|
runtime: {
|
||||||
|
log: () => {},
|
||||||
|
error: (msg: string) => logger.error(msg),
|
||||||
|
exit: (() => {}) as unknown as (code: number) => never,
|
||||||
|
},
|
||||||
|
textLimit,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: unknown, info: { kind: string }) => {
|
||||||
|
logger.error(danger(`discord reaction trigger ${info.kind} failed: ${String(err)}`));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch to agent
|
||||||
|
await dispatchInboundMessage({
|
||||||
|
ctx: ctxPayload,
|
||||||
|
cfg,
|
||||||
|
dispatcher,
|
||||||
|
replyOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
markDispatchIdle();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(danger(`discord reaction trigger failed: ${String(err)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user