Merge a0a4c617e2 into 4583f88626
This commit is contained in:
commit
f9a7a710e7
@ -57,6 +57,7 @@ Minimal config:
|
|||||||
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `channels.discord.actions.*`).
|
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `channels.discord.actions.*`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
- The `discord` tool is only exposed when the current channel is Discord.
|
- 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.<id>.reactionTrigger` is enabled; see [Reaction trigger](#reaction-trigger).
|
||||||
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) 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.
|
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",
|
slug: "friends-of-clawd",
|
||||||
requireMention: false,
|
requireMention: false,
|
||||||
reactionNotifications: "own",
|
reactionNotifications: "own",
|
||||||
|
reactionTrigger: {
|
||||||
|
enabled: true,
|
||||||
|
windowSeconds: 60,
|
||||||
|
positiveEmojis: ["👍", "✅", "👌"],
|
||||||
|
negativeEmojis: ["👎", "❌"]
|
||||||
|
},
|
||||||
users: ["987654321098765432", "steipete"],
|
users: ["987654321098765432", "steipete"],
|
||||||
channels: {
|
channels: {
|
||||||
general: { allow: true },
|
general: { allow: true },
|
||||||
@ -311,6 +318,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`: 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.
|
- `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 +340,32 @@ 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 trigger
|
||||||
|
|
||||||
|
When `guilds.<id>.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
|
### Tool action defaults
|
||||||
|
|
||||||
| Action group | Default | Notes |
|
| Action group | Default | Notes |
|
||||||
|
|||||||
@ -1090,6 +1090,12 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
|
|||||||
slug: "friends-of-clawd",
|
slug: "friends-of-clawd",
|
||||||
requireMention: false, // per-guild default
|
requireMention: false, // per-guild default
|
||||||
reactionNotifications: "own", // off | own | all | allowlist
|
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
|
users: ["987654321098765432"], // optional per-guild user allowlist
|
||||||
channels: {
|
channels: {
|
||||||
general: { allow: true },
|
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:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands; bare numeric IDs are ambiguous and rejected.
|
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:<id>` (DM) or `channel:<id>` (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.
|
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).
|
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.<id>.reactionNotifications`):
|
||||||
- `off`: no reaction events.
|
- `off`: no reaction events.
|
||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `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).
|
||||||
|
Optional `guilds.<id>.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.
|
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).
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,8 @@ Shared reaction semantics across channels:
|
|||||||
|
|
||||||
Channel notes:
|
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.<id>.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.
|
- **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.
|
- **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`).
|
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||||
|
|||||||
@ -41,6 +41,17 @@ export type DiscordGuildChannelConfig = {
|
|||||||
|
|
||||||
export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
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 = {
|
export type DiscordGuildEntry = {
|
||||||
slug?: string;
|
slug?: string;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
@ -49,6 +60,8 @@ 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 configuration for bot message responses. */
|
||||||
|
reactionTrigger?: DiscordReactionTriggerConfig;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
channels?: Record<string, DiscordGuildChannelConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -199,6 +199,15 @@ export const DiscordGuildChannelSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
export const DiscordGuildSchema = z
|
||||||
.object({
|
.object({
|
||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
@ -206,6 +215,7 @@ 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(),
|
||||||
|
reactionTrigger: DiscordReactionTriggerSchema.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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -17,11 +17,19 @@ export type DiscordAllowList = {
|
|||||||
|
|
||||||
export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">;
|
export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">;
|
||||||
|
|
||||||
|
export type DiscordReactionTriggerResolved = {
|
||||||
|
enabled?: boolean;
|
||||||
|
windowSeconds?: number;
|
||||||
|
positiveEmojis?: string[];
|
||||||
|
negativeEmojis?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type DiscordGuildEntryResolved = {
|
export type DiscordGuildEntryResolved = {
|
||||||
id?: string;
|
id?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
||||||
|
reactionTrigger?: DiscordReactionTriggerResolved;
|
||||||
users?: Array<string | number>;
|
users?: Array<string | number>;
|
||||||
channels?: Record<
|
channels?: Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -18,10 +18,104 @@ import {
|
|||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
shouldEmitDiscordReactionNotification,
|
shouldEmitDiscordReactionNotification,
|
||||||
|
type DiscordReactionTriggerResolved,
|
||||||
} 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";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 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>;
|
||||||
@ -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 {
|
export class DiscordReactionListener extends MessageReactionAddListener {
|
||||||
constructor(
|
constructor(
|
||||||
private params: {
|
private params: {
|
||||||
@ -101,6 +206,7 @@ export class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
onReactionTrigger?: ReactionTriggerCallback;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -118,6 +224,7 @@ export class DiscordReactionListener extends MessageReactionAddListener {
|
|||||||
botUserId: this.params.botUserId,
|
botUserId: this.params.botUserId,
|
||||||
guildEntries: this.params.guildEntries,
|
guildEntries: this.params.guildEntries,
|
||||||
logger: this.params.logger,
|
logger: this.params.logger,
|
||||||
|
onReactionTrigger: this.params.onReactionTrigger,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
logSlowDiscordListener({
|
logSlowDiscordListener({
|
||||||
@ -177,9 +284,10 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
botUserId?: string;
|
botUserId?: string;
|
||||||
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
onReactionTrigger?: ReactionTriggerCallback;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const { data, client, action, botUserId, guildEntries } = params;
|
const { data, client, action, botUserId, guildEntries, onReactionTrigger } = params;
|
||||||
if (!("user" in data)) return;
|
if (!("user" in data)) return;
|
||||||
const user = data.user;
|
const user = data.user;
|
||||||
if (!user || user.bot) return;
|
if (!user || user.bot) return;
|
||||||
@ -254,8 +362,6 @@ async function handleDiscordReactionEvent(params: {
|
|||||||
? `#${normalizeDiscordSlug(channelName)}`
|
? `#${normalizeDiscordSlug(channelName)}`
|
||||||
: `#${data.channel_id}`;
|
: `#${data.channel_id}`;
|
||||||
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
|
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 +369,54 @@ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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, {
|
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}`,
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import { Client } from "@buape/carbon";
|
|||||||
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
||||||
import { Routes } from "discord-api-types/v10";
|
import { Routes } from "discord-api-types/v10";
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
|
import { dispatchInboundMessageWithDispatcher } from "../../auto-reply/dispatch.js";
|
||||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||||
import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js";
|
import { mergeAllowlist, summarizeMapping } from "../../channels/allowlists/resolve-utils.js";
|
||||||
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import {
|
import {
|
||||||
isNativeCommandsExplicitlyDisabled,
|
isNativeCommandsExplicitlyDisabled,
|
||||||
resolveNativeCommandsEnabled,
|
resolveNativeCommandsEnabled,
|
||||||
@ -518,6 +520,64 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
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(
|
registerDiscordListener(
|
||||||
client.listeners,
|
client.listeners,
|
||||||
new DiscordReactionListener({
|
new DiscordReactionListener({
|
||||||
@ -527,6 +587,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
botUserId,
|
botUserId,
|
||||||
guildEntries,
|
guildEntries,
|
||||||
logger,
|
logger,
|
||||||
|
onReactionTrigger,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
registerDiscordListener(
|
registerDiscordListener(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js";
|
|||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||||
import { sendMessageDiscord } from "../send.js";
|
import { sendMessageDiscord } from "../send.js";
|
||||||
|
import { cacheBotMessage } from "./listeners.js";
|
||||||
|
|
||||||
export async function deliverDiscordReply(params: {
|
export async function deliverDiscordReply(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
@ -42,12 +43,20 @@ export async function deliverDiscordReply(params: {
|
|||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
await sendMessageDiscord(params.target, trimmed, {
|
const result = await sendMessageDiscord(params.target, trimmed, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
replyTo: isFirstChunk ? replyTo : undefined,
|
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;
|
isFirstChunk = false;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@ -55,13 +64,21 @@ export async function deliverDiscordReply(params: {
|
|||||||
|
|
||||||
const firstMedia = mediaList[0];
|
const firstMedia = mediaList[0];
|
||||||
if (!firstMedia) continue;
|
if (!firstMedia) continue;
|
||||||
await sendMessageDiscord(params.target, text, {
|
const mediaResult = await sendMessageDiscord(params.target, text, {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
rest: params.rest,
|
rest: params.rest,
|
||||||
mediaUrl: firstMedia,
|
mediaUrl: firstMedia,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
replyTo,
|
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)) {
|
for (const extra of mediaList.slice(1)) {
|
||||||
await sendMessageDiscord(params.target, "", {
|
await sendMessageDiscord(params.target, "", {
|
||||||
token: params.token,
|
token: params.token,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user