1240 lines
39 KiB
TypeScript
1240 lines
39 KiB
TypeScript
import {
|
|
type Attachment,
|
|
ChannelType,
|
|
Client,
|
|
Events,
|
|
GatewayIntentBits,
|
|
type Guild,
|
|
type Message,
|
|
type MessageReaction,
|
|
type MessageSnapshot,
|
|
MessageType,
|
|
type PartialMessage,
|
|
type PartialMessageReaction,
|
|
Partials,
|
|
type PartialUser,
|
|
type User,
|
|
} from "discord.js";
|
|
|
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
|
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
import type { ReplyToMode } from "../config/config.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import {
|
|
resolveSessionKey,
|
|
resolveStorePath,
|
|
updateLastRoute,
|
|
} from "../config/sessions.js";
|
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
|
import { getChildLogger } from "../logging.js";
|
|
import { detectMime } from "../media/mime.js";
|
|
import { saveMediaBuffer } from "../media/store.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { sendMessageDiscord } from "./send.js";
|
|
import { normalizeDiscordToken } from "./token.js";
|
|
|
|
export type MonitorDiscordOpts = {
|
|
token?: string;
|
|
runtime?: RuntimeEnv;
|
|
abortSignal?: AbortSignal;
|
|
mediaMaxMb?: number;
|
|
historyLimit?: number;
|
|
replyToMode?: ReplyToMode;
|
|
};
|
|
|
|
type DiscordMediaInfo = {
|
|
path: string;
|
|
contentType?: string;
|
|
placeholder: string;
|
|
};
|
|
|
|
type DiscordHistoryEntry = {
|
|
sender: string;
|
|
body: string;
|
|
timestamp?: number;
|
|
messageId?: string;
|
|
};
|
|
|
|
export type DiscordAllowList = {
|
|
allowAll: boolean;
|
|
ids: Set<string>;
|
|
names: Set<string>;
|
|
};
|
|
|
|
export type DiscordGuildEntryResolved = {
|
|
id?: string;
|
|
slug?: string;
|
|
requireMention?: boolean;
|
|
reactionNotifications?: "off" | "own" | "all" | "allowlist";
|
|
users?: Array<string | number>;
|
|
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
|
};
|
|
|
|
export type DiscordChannelConfigResolved = {
|
|
allowed: boolean;
|
|
requireMention?: boolean;
|
|
};
|
|
|
|
export function resolveDiscordReplyTarget(opts: {
|
|
replyToMode: ReplyToMode;
|
|
replyToId?: string;
|
|
hasReplied: boolean;
|
|
}): string | undefined {
|
|
if (opts.replyToMode === "off") return undefined;
|
|
const replyToId = opts.replyToId?.trim();
|
|
if (!replyToId) return undefined;
|
|
if (opts.replyToMode === "all") return replyToId;
|
|
return opts.hasReplied ? undefined : replyToId;
|
|
}
|
|
|
|
function summarizeAllowList(list?: Array<string | number>) {
|
|
if (!list || list.length === 0) return "any";
|
|
const sample = list.slice(0, 4).map((entry) => String(entry));
|
|
const suffix =
|
|
list.length > sample.length ? ` (+${list.length - sample.length})` : "";
|
|
return `${sample.join(", ")}${suffix}`;
|
|
}
|
|
|
|
function summarizeGuilds(entries?: Record<string, DiscordGuildEntryResolved>) {
|
|
if (!entries || Object.keys(entries).length === 0) return "any";
|
|
const keys = Object.keys(entries);
|
|
const sample = keys.slice(0, 4);
|
|
const suffix =
|
|
keys.length > sample.length ? ` (+${keys.length - sample.length})` : "";
|
|
return `${sample.join(", ")}${suffix}`;
|
|
}
|
|
|
|
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|
const cfg = loadConfig();
|
|
const token = normalizeDiscordToken(
|
|
opts.token ??
|
|
process.env.DISCORD_BOT_TOKEN ??
|
|
cfg.discord?.token ??
|
|
undefined,
|
|
);
|
|
if (!token) {
|
|
throw new Error(
|
|
"DISCORD_BOT_TOKEN or discord.token is required for Discord gateway",
|
|
);
|
|
}
|
|
|
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
|
log: console.log,
|
|
error: console.error,
|
|
exit: (code: number): never => {
|
|
throw new Error(`exit ${code}`);
|
|
},
|
|
};
|
|
|
|
const dmConfig = cfg.discord?.dm;
|
|
const guildEntries = cfg.discord?.guilds;
|
|
const allowFrom = dmConfig?.allowFrom;
|
|
const mediaMaxBytes =
|
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
|
const textLimit = resolveTextChunkLimit(cfg, "discord");
|
|
const historyLimit = Math.max(
|
|
0,
|
|
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
|
|
);
|
|
const replyToMode = opts.replyToMode ?? cfg.discord?.replyToMode ?? "off";
|
|
const dmEnabled = dmConfig?.enabled ?? true;
|
|
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
|
const groupDmChannels = dmConfig?.groupChannels;
|
|
|
|
if (shouldLogVerbose()) {
|
|
logVerbose(
|
|
`discord: config dm=${dmEnabled ? "on" : "off"} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))}`,
|
|
);
|
|
}
|
|
|
|
const client = new Client({
|
|
intents: [
|
|
GatewayIntentBits.Guilds,
|
|
GatewayIntentBits.GuildMessages,
|
|
GatewayIntentBits.GuildMessageReactions,
|
|
GatewayIntentBits.MessageContent,
|
|
GatewayIntentBits.DirectMessages,
|
|
GatewayIntentBits.DirectMessageReactions,
|
|
],
|
|
partials: [
|
|
Partials.Channel,
|
|
Partials.Message,
|
|
Partials.Reaction,
|
|
Partials.User,
|
|
],
|
|
});
|
|
|
|
const logger = getChildLogger({ module: "discord-auto-reply" });
|
|
const guildHistories = new Map<string, DiscordHistoryEntry[]>();
|
|
|
|
client.once(Events.ClientReady, () => {
|
|
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
|
});
|
|
|
|
client.on(Events.Error, (err) => {
|
|
runtime.error?.(danger(`client error: ${String(err)}`));
|
|
});
|
|
|
|
client.on(Events.MessageCreate, async (message) => {
|
|
try {
|
|
if (message.author?.bot) return;
|
|
if (!message.author) return;
|
|
|
|
// Discord.js typing excludes GroupDM for message.channel.type; widen for runtime check.
|
|
const channelType = message.channel.type as ChannelType;
|
|
const isGroupDm = channelType === ChannelType.GroupDM;
|
|
const isDirectMessage = channelType === ChannelType.DM;
|
|
const isGuildMessage = Boolean(message.guild);
|
|
if (isGroupDm && !groupDmEnabled) {
|
|
logVerbose("discord: drop group dm (group dms disabled)");
|
|
return;
|
|
}
|
|
if (isDirectMessage && !dmEnabled) {
|
|
logVerbose("discord: drop dm (dms disabled)");
|
|
return;
|
|
}
|
|
const botId = client.user?.id;
|
|
const wasMentioned =
|
|
!isDirectMessage && Boolean(botId && message.mentions.has(botId));
|
|
const forwardedSnapshot = resolveForwardedSnapshot(message);
|
|
const forwardedText = forwardedSnapshot
|
|
? resolveDiscordSnapshotText(forwardedSnapshot.snapshot)
|
|
: "";
|
|
const baseText = resolveDiscordMessageText(message, forwardedText);
|
|
if (shouldLogVerbose()) {
|
|
logVerbose(
|
|
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
isGuildMessage &&
|
|
(message.type === MessageType.ChatInputCommand ||
|
|
message.type === MessageType.ContextMenuCommand)
|
|
) {
|
|
logVerbose("discord: drop channel command message");
|
|
return;
|
|
}
|
|
|
|
const guildInfo = isGuildMessage
|
|
? resolveDiscordGuildEntry({
|
|
guild: message.guild,
|
|
guildEntries,
|
|
})
|
|
: null;
|
|
if (
|
|
isGuildMessage &&
|
|
guildEntries &&
|
|
Object.keys(guildEntries).length > 0 &&
|
|
!guildInfo
|
|
) {
|
|
logVerbose(
|
|
`Blocked discord guild ${message.guild?.id ?? "unknown"} (not in discord.guilds)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const channelName =
|
|
(isGuildMessage || isGroupDm) && "name" in message.channel
|
|
? message.channel.name
|
|
: undefined;
|
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
|
const guildSlug =
|
|
guildInfo?.slug ||
|
|
(message.guild?.name ? normalizeDiscordSlug(message.guild.name) : "");
|
|
const channelConfig = isGuildMessage
|
|
? resolveDiscordChannelConfig({
|
|
guildInfo,
|
|
channelId: message.channelId,
|
|
channelName,
|
|
channelSlug,
|
|
})
|
|
: null;
|
|
|
|
const groupDmAllowed =
|
|
isGroupDm &&
|
|
resolveGroupDmAllow({
|
|
channels: groupDmChannels,
|
|
channelId: message.channelId,
|
|
channelName,
|
|
channelSlug,
|
|
});
|
|
if (isGroupDm && !groupDmAllowed) return;
|
|
|
|
if (isGuildMessage && channelConfig?.allowed === false) {
|
|
logVerbose(
|
|
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isGuildMessage && historyLimit > 0 && baseText) {
|
|
const history = guildHistories.get(message.channelId) ?? [];
|
|
history.push({
|
|
sender: message.member?.displayName ?? message.author.tag,
|
|
body: baseText,
|
|
timestamp: message.createdTimestamp,
|
|
messageId: message.id,
|
|
});
|
|
while (history.length > historyLimit) history.shift();
|
|
guildHistories.set(message.channelId, history);
|
|
}
|
|
|
|
const resolvedRequireMention =
|
|
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
|
|
const hasAnyMention = Boolean(
|
|
!isDirectMessage &&
|
|
(message.mentions?.everyone ||
|
|
(message.mentions?.users?.size ?? 0) > 0 ||
|
|
(message.mentions?.roles?.size ?? 0) > 0),
|
|
);
|
|
const commandAuthorized = resolveDiscordCommandAuthorized({
|
|
isDirectMessage,
|
|
allowFrom,
|
|
guildInfo,
|
|
author: message.author,
|
|
});
|
|
const shouldBypassMention =
|
|
isGuildMessage &&
|
|
resolvedRequireMention &&
|
|
!wasMentioned &&
|
|
!hasAnyMention &&
|
|
commandAuthorized &&
|
|
hasControlCommand(baseText);
|
|
if (isGuildMessage && resolvedRequireMention) {
|
|
if (botId && !wasMentioned && !shouldBypassMention) {
|
|
logVerbose(
|
|
`discord: drop guild message (mention required, botId=${botId})`,
|
|
);
|
|
logger.info(
|
|
{
|
|
channelId: message.channelId,
|
|
reason: "no-mention",
|
|
},
|
|
"discord: skipping guild message",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (isGuildMessage) {
|
|
const userAllow = guildInfo?.users;
|
|
if (Array.isArray(userAllow) && userAllow.length > 0) {
|
|
const users = normalizeDiscordAllowList(userAllow, [
|
|
"discord:",
|
|
"user:",
|
|
]);
|
|
const userOk =
|
|
!users ||
|
|
allowListMatches(users, {
|
|
id: message.author.id,
|
|
name: message.author.username,
|
|
tag: message.author.tag,
|
|
});
|
|
if (!userOk) {
|
|
logVerbose(
|
|
`Blocked discord guild sender ${message.author.id} (not in guild users allowlist)`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
|
|
const allowList = normalizeDiscordAllowList(allowFrom, [
|
|
"discord:",
|
|
"user:",
|
|
]);
|
|
const permitted =
|
|
allowList &&
|
|
allowListMatches(allowList, {
|
|
id: message.author.id,
|
|
name: message.author.username,
|
|
tag: message.author.tag,
|
|
});
|
|
if (!permitted) {
|
|
logVerbose(
|
|
`Blocked unauthorized discord sender ${message.author.id} (not in allowFrom)`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const systemText = resolveDiscordSystemEvent(message);
|
|
if (systemText) {
|
|
const sessionCfg = cfg.session;
|
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
const sessionKey = resolveSessionKey(
|
|
sessionScope,
|
|
{
|
|
From: isDirectMessage
|
|
? `discord:${message.author.id}`
|
|
: `group:${message.channelId}`,
|
|
ChatType: isDirectMessage ? "direct" : "group",
|
|
Surface: "discord",
|
|
},
|
|
mainKey,
|
|
);
|
|
enqueueSystemEvent(systemText, {
|
|
sessionKey,
|
|
contextKey: `discord:system:${message.channelId}:${message.id}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const media = await resolveMedia(message, mediaMaxBytes);
|
|
const text =
|
|
message.content?.trim() ??
|
|
media?.placeholder ??
|
|
message.embeds[0]?.description ??
|
|
(forwardedSnapshot ? "<forwarded message>" : "");
|
|
if (!text) {
|
|
logVerbose(`discord: drop message ${message.id} (empty content)`);
|
|
return;
|
|
}
|
|
|
|
const fromLabel = isDirectMessage
|
|
? buildDirectLabel(message)
|
|
: buildGuildLabel(message);
|
|
const groupRoom =
|
|
isGuildMessage && channelSlug ? `#${channelSlug}` : undefined;
|
|
const groupSubject = isDirectMessage ? undefined : groupRoom;
|
|
const messageText = text;
|
|
let combinedBody = formatAgentEnvelope({
|
|
surface: "Discord",
|
|
from: fromLabel,
|
|
timestamp: message.createdTimestamp,
|
|
body: messageText,
|
|
});
|
|
let shouldClearHistory = false;
|
|
if (!isDirectMessage) {
|
|
const history =
|
|
historyLimit > 0 ? (guildHistories.get(message.channelId) ?? []) : [];
|
|
const historyWithoutCurrent =
|
|
history.length > 0 ? history.slice(0, -1) : [];
|
|
if (historyWithoutCurrent.length > 0) {
|
|
const historyText = historyWithoutCurrent
|
|
.map((entry) =>
|
|
formatAgentEnvelope({
|
|
surface: "Discord",
|
|
from: fromLabel,
|
|
timestamp: entry.timestamp,
|
|
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
|
}),
|
|
)
|
|
.join("\n");
|
|
combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`;
|
|
}
|
|
const name = message.author.tag;
|
|
const id = message.author.id;
|
|
combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`;
|
|
shouldClearHistory = true;
|
|
}
|
|
const replyContext = await resolveReplyContext(message);
|
|
if (replyContext) {
|
|
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
|
}
|
|
if (forwardedSnapshot) {
|
|
const forwarderName = message.author.tag ?? message.author.username;
|
|
const forwarder = forwarderName
|
|
? `${forwarderName} id:${message.author.id}`
|
|
: message.author.id;
|
|
const snapshotText =
|
|
resolveDiscordSnapshotText(forwardedSnapshot.snapshot) ||
|
|
"<forwarded message>";
|
|
const forwardMetaParts = [
|
|
forwardedSnapshot.messageId
|
|
? `forwarded message id: ${forwardedSnapshot.messageId}`
|
|
: null,
|
|
forwardedSnapshot.channelId
|
|
? `channel: ${forwardedSnapshot.channelId}`
|
|
: null,
|
|
forwardedSnapshot.guildId
|
|
? `guild: ${forwardedSnapshot.guildId}`
|
|
: null,
|
|
typeof forwardedSnapshot.snapshot.type === "number"
|
|
? `snapshot type: ${forwardedSnapshot.snapshot.type}`
|
|
: null,
|
|
].filter((entry): entry is string => Boolean(entry));
|
|
const forwardedBody = forwardMetaParts.length
|
|
? `${snapshotText}\n[${forwardMetaParts.join(" ")}]`
|
|
: snapshotText;
|
|
const forwardedEnvelope = formatAgentEnvelope({
|
|
surface: "Discord",
|
|
from: `Forwarded by ${forwarder}`,
|
|
timestamp:
|
|
forwardedSnapshot.snapshot.createdTimestamp ??
|
|
message.createdTimestamp ??
|
|
undefined,
|
|
body: forwardedBody,
|
|
});
|
|
combinedBody = `[Forwarded message]\n${forwardedEnvelope}\n\n${combinedBody}`;
|
|
}
|
|
|
|
const ctxPayload = {
|
|
Body: combinedBody,
|
|
From: isDirectMessage
|
|
? `discord:${message.author.id}`
|
|
: `group:${message.channelId}`,
|
|
To: isDirectMessage
|
|
? `user:${message.author.id}`
|
|
: `channel:${message.channelId}`,
|
|
ChatType: isDirectMessage ? "direct" : "group",
|
|
SenderName: message.member?.displayName ?? message.author.tag,
|
|
SenderId: message.author.id,
|
|
SenderUsername: message.author.username,
|
|
SenderTag: message.author.tag,
|
|
GroupSubject: groupSubject,
|
|
GroupRoom: groupRoom,
|
|
GroupSpace: isGuildMessage
|
|
? (guildInfo?.id ?? guildSlug) || undefined
|
|
: undefined,
|
|
Surface: "discord" as const,
|
|
WasMentioned: wasMentioned,
|
|
MessageSid: message.id,
|
|
Timestamp: message.createdTimestamp,
|
|
MediaPath: media?.path,
|
|
MediaType: media?.contentType,
|
|
MediaUrl: media?.path,
|
|
CommandAuthorized: commandAuthorized,
|
|
};
|
|
const replyTarget = ctxPayload.To ?? undefined;
|
|
if (!replyTarget) {
|
|
runtime.error?.(danger("discord: missing reply target"));
|
|
return;
|
|
}
|
|
|
|
if (isDirectMessage) {
|
|
const sessionCfg = cfg.session;
|
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
const storePath = resolveStorePath(sessionCfg?.store);
|
|
await updateLastRoute({
|
|
storePath,
|
|
sessionKey: mainKey,
|
|
channel: "discord",
|
|
to: `user:${message.author.id}`,
|
|
});
|
|
}
|
|
|
|
if (shouldLogVerbose()) {
|
|
const preview = combinedBody.slice(0, 200).replace(/\n/g, "\\n");
|
|
logVerbose(
|
|
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
|
|
);
|
|
}
|
|
|
|
let didSendReply = false;
|
|
let blockSendChain: Promise<void> = Promise.resolve();
|
|
const sendBlockReply = (payload: ReplyPayload) => {
|
|
if (
|
|
!payload?.text &&
|
|
!payload?.mediaUrl &&
|
|
!(payload?.mediaUrls?.length ?? 0)
|
|
) {
|
|
return;
|
|
}
|
|
blockSendChain = blockSendChain
|
|
.then(async () => {
|
|
await deliverReplies({
|
|
replies: [payload],
|
|
target: replyTarget,
|
|
token,
|
|
runtime,
|
|
replyToMode,
|
|
textLimit,
|
|
});
|
|
didSendReply = true;
|
|
})
|
|
.catch((err) => {
|
|
runtime.error?.(
|
|
danger(`discord block reply failed: ${String(err)}`),
|
|
);
|
|
});
|
|
};
|
|
|
|
const replyResult = await getReplyFromConfig(
|
|
ctxPayload,
|
|
{
|
|
onReplyStart: () => sendTyping(message),
|
|
onBlockReply: sendBlockReply,
|
|
},
|
|
cfg,
|
|
);
|
|
const replies = replyResult
|
|
? Array.isArray(replyResult)
|
|
? replyResult
|
|
: [replyResult]
|
|
: [];
|
|
await blockSendChain;
|
|
if (replies.length === 0) {
|
|
if (
|
|
isGuildMessage &&
|
|
shouldClearHistory &&
|
|
historyLimit > 0 &&
|
|
didSendReply
|
|
) {
|
|
guildHistories.set(message.channelId, []);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await deliverReplies({
|
|
replies,
|
|
target: replyTarget,
|
|
token,
|
|
runtime,
|
|
replyToMode,
|
|
textLimit,
|
|
});
|
|
didSendReply = true;
|
|
if (shouldLogVerbose()) {
|
|
logVerbose(
|
|
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
|
);
|
|
}
|
|
if (
|
|
isGuildMessage &&
|
|
shouldClearHistory &&
|
|
historyLimit > 0 &&
|
|
didSendReply
|
|
) {
|
|
guildHistories.set(message.channelId, []);
|
|
}
|
|
} catch (err) {
|
|
runtime.error?.(danger(`handler failed: ${String(err)}`));
|
|
}
|
|
});
|
|
|
|
const handleReactionEvent = async (
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
user: User | PartialUser,
|
|
action: "added" | "removed",
|
|
) => {
|
|
try {
|
|
if (!user || user.bot) return;
|
|
const resolvedReaction = reaction.partial
|
|
? await reaction.fetch()
|
|
: reaction;
|
|
const message = (resolvedReaction.message as Message | PartialMessage)
|
|
.partial
|
|
? await resolvedReaction.message.fetch()
|
|
: resolvedReaction.message;
|
|
const guild = message.guild;
|
|
if (!guild) return;
|
|
const guildInfo = resolveDiscordGuildEntry({
|
|
guild,
|
|
guildEntries,
|
|
});
|
|
if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) {
|
|
return;
|
|
}
|
|
const channelName =
|
|
"name" in message.channel
|
|
? (message.channel.name ?? undefined)
|
|
: undefined;
|
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
|
const channelConfig = resolveDiscordChannelConfig({
|
|
guildInfo,
|
|
channelId: message.channelId,
|
|
channelName,
|
|
channelSlug,
|
|
});
|
|
if (channelConfig?.allowed === false) return;
|
|
|
|
const botId = client.user?.id;
|
|
if (botId && user.id === botId) return;
|
|
|
|
const reactionMode = guildInfo?.reactionNotifications ?? "own";
|
|
const shouldNotify = shouldEmitDiscordReactionNotification({
|
|
mode: reactionMode,
|
|
botId,
|
|
messageAuthorId: message.author?.id,
|
|
userId: user.id,
|
|
userName: user.username,
|
|
userTag: user.tag,
|
|
allowlist: guildInfo?.users,
|
|
});
|
|
if (!shouldNotify) return;
|
|
|
|
const emojiLabel = formatDiscordReactionEmoji(resolvedReaction);
|
|
const actorLabel = user.tag ?? user.username ?? user.id;
|
|
const guildSlug =
|
|
guildInfo?.slug ||
|
|
(guild.name ? normalizeDiscordSlug(guild.name) : guild.id);
|
|
const channelLabel = channelSlug
|
|
? `#${channelSlug}`
|
|
: channelName
|
|
? `#${normalizeDiscordSlug(channelName)}`
|
|
: `#${message.channelId}`;
|
|
const authorLabel = message.author?.tag ?? message.author?.username;
|
|
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${message.id}`;
|
|
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
|
|
const sessionCfg = cfg.session;
|
|
const sessionScope = sessionCfg?.scope ?? "per-sender";
|
|
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
|
|
const sessionKey = resolveSessionKey(
|
|
sessionScope,
|
|
{
|
|
From: `group:${message.channelId}`,
|
|
ChatType: "group",
|
|
Surface: "discord",
|
|
},
|
|
mainKey,
|
|
);
|
|
enqueueSystemEvent(text, {
|
|
sessionKey,
|
|
contextKey: `discord:reaction:${action}:${message.id}:${user.id}:${emojiLabel}`,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(
|
|
danger(`discord reaction handler failed: ${String(err)}`),
|
|
);
|
|
}
|
|
};
|
|
|
|
client.on(Events.MessageReactionAdd, async (reaction, user) => {
|
|
await handleReactionEvent(reaction, user, "added");
|
|
});
|
|
|
|
client.on(Events.MessageReactionRemove, async (reaction, user) => {
|
|
await handleReactionEvent(reaction, user, "removed");
|
|
});
|
|
|
|
await client.login(token);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onAbort = () => {
|
|
cleanup();
|
|
void client.destroy();
|
|
resolve();
|
|
};
|
|
const onError = (err: Error) => {
|
|
cleanup();
|
|
reject(err);
|
|
};
|
|
const cleanup = () => {
|
|
opts.abortSignal?.removeEventListener("abort", onAbort);
|
|
client.off(Events.Error, onError);
|
|
};
|
|
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
client.on(Events.Error, onError);
|
|
});
|
|
}
|
|
|
|
async function resolveMedia(
|
|
message: Message,
|
|
maxBytes: number,
|
|
): Promise<DiscordMediaInfo | null> {
|
|
const attachment = message.attachments.first();
|
|
if (!attachment) return null;
|
|
const res = await fetch(attachment.url);
|
|
if (!res.ok) {
|
|
throw new Error(
|
|
`Failed to download discord attachment: HTTP ${res.status}`,
|
|
);
|
|
}
|
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
const mime = await detectMime({
|
|
buffer,
|
|
headerMime: attachment.contentType ?? res.headers.get("content-type"),
|
|
filePath: attachment.name ?? attachment.url,
|
|
});
|
|
const saved = await saveMediaBuffer(buffer, mime, "inbound", maxBytes);
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: inferPlaceholder(attachment),
|
|
};
|
|
}
|
|
|
|
function inferPlaceholder(attachment: Attachment): string {
|
|
const mime = attachment.contentType ?? "";
|
|
if (mime.startsWith("image/")) return "<media:image>";
|
|
if (mime.startsWith("video/")) return "<media:video>";
|
|
if (mime.startsWith("audio/")) return "<media:audio>";
|
|
return "<media:document>";
|
|
}
|
|
|
|
function resolveDiscordMessageText(
|
|
message: Message,
|
|
fallbackText?: string,
|
|
): string {
|
|
const attachment = message.attachments.first();
|
|
return (
|
|
message.content?.trim() ||
|
|
(attachment ? inferPlaceholder(attachment) : "") ||
|
|
message.embeds[0]?.description ||
|
|
fallbackText?.trim() ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function resolveDiscordSnapshotText(snapshot: MessageSnapshot): string {
|
|
return snapshot.content?.trim() || snapshot.embeds[0]?.description || "";
|
|
}
|
|
|
|
async function resolveReplyContext(message: Message): Promise<string | null> {
|
|
if (!message.reference?.messageId) return null;
|
|
try {
|
|
const referenced = await message.fetchReference();
|
|
if (!referenced?.author) return null;
|
|
const referencedText = resolveDiscordMessageText(referenced);
|
|
if (!referencedText) return null;
|
|
const channelType = referenced.channel.type as ChannelType;
|
|
const isDirectMessage = channelType === ChannelType.DM;
|
|
const fromLabel = isDirectMessage
|
|
? buildDirectLabel(referenced)
|
|
: (referenced.member?.displayName ?? referenced.author.tag);
|
|
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${referenced.author.tag} user id:${referenced.author.id}]`;
|
|
return formatAgentEnvelope({
|
|
surface: "Discord",
|
|
from: fromLabel,
|
|
timestamp: referenced.createdTimestamp,
|
|
body,
|
|
});
|
|
} catch (err) {
|
|
logVerbose(
|
|
`discord: failed to fetch reply context for ${message.id}: ${String(err)}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildDirectLabel(message: Message) {
|
|
const username = message.author.tag;
|
|
return `${username} user id:${message.author.id}`;
|
|
}
|
|
|
|
function buildGuildLabel(message: Message) {
|
|
const channelName =
|
|
"name" in message.channel ? message.channel.name : message.channelId;
|
|
return `${message.guild?.name ?? "Guild"} #${channelName} channel id:${message.channelId}`;
|
|
}
|
|
|
|
function resolveDiscordSystemEvent(message: Message): string | null {
|
|
switch (message.type) {
|
|
case MessageType.ChannelPinnedMessage:
|
|
return buildDiscordSystemEvent(message, "pinned a message");
|
|
case MessageType.RecipientAdd:
|
|
return buildDiscordSystemEvent(message, "added a recipient");
|
|
case MessageType.RecipientRemove:
|
|
return buildDiscordSystemEvent(message, "removed a recipient");
|
|
case MessageType.UserJoin:
|
|
return buildDiscordSystemEvent(message, "user joined");
|
|
case MessageType.GuildBoost:
|
|
return buildDiscordSystemEvent(message, "boosted the server");
|
|
case MessageType.GuildBoostTier1:
|
|
return buildDiscordSystemEvent(
|
|
message,
|
|
"boosted the server (Tier 1 reached)",
|
|
);
|
|
case MessageType.GuildBoostTier2:
|
|
return buildDiscordSystemEvent(
|
|
message,
|
|
"boosted the server (Tier 2 reached)",
|
|
);
|
|
case MessageType.GuildBoostTier3:
|
|
return buildDiscordSystemEvent(
|
|
message,
|
|
"boosted the server (Tier 3 reached)",
|
|
);
|
|
case MessageType.ThreadCreated:
|
|
return buildDiscordSystemEvent(message, "created a thread");
|
|
case MessageType.AutoModerationAction:
|
|
return buildDiscordSystemEvent(message, "auto moderation action");
|
|
case MessageType.GuildIncidentAlertModeEnabled:
|
|
return buildDiscordSystemEvent(message, "raid protection enabled");
|
|
case MessageType.GuildIncidentAlertModeDisabled:
|
|
return buildDiscordSystemEvent(message, "raid protection disabled");
|
|
case MessageType.GuildIncidentReportRaid:
|
|
return buildDiscordSystemEvent(message, "raid reported");
|
|
case MessageType.GuildIncidentReportFalseAlarm:
|
|
return buildDiscordSystemEvent(message, "raid report marked false alarm");
|
|
case MessageType.StageStart:
|
|
return buildDiscordSystemEvent(message, "stage started");
|
|
case MessageType.StageEnd:
|
|
return buildDiscordSystemEvent(message, "stage ended");
|
|
case MessageType.StageSpeaker:
|
|
return buildDiscordSystemEvent(message, "stage speaker updated");
|
|
case MessageType.StageTopic:
|
|
return buildDiscordSystemEvent(message, "stage topic updated");
|
|
case MessageType.PollResult:
|
|
return buildDiscordSystemEvent(message, "poll results posted");
|
|
case MessageType.PurchaseNotification:
|
|
return buildDiscordSystemEvent(message, "purchase notification");
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveForwardedSnapshot(message: Message): {
|
|
snapshot: MessageSnapshot;
|
|
messageId?: string;
|
|
channelId?: string;
|
|
guildId?: string;
|
|
} | null {
|
|
const snapshots = message.messageSnapshots;
|
|
if (!snapshots || snapshots.size === 0) return null;
|
|
const snapshot = snapshots.first();
|
|
if (!snapshot) return null;
|
|
const reference = message.reference;
|
|
return {
|
|
snapshot,
|
|
messageId: reference?.messageId ?? undefined,
|
|
channelId: reference?.channelId ?? undefined,
|
|
guildId: reference?.guildId ?? undefined,
|
|
};
|
|
}
|
|
|
|
function buildDiscordSystemEvent(message: Message, action: string) {
|
|
const channelName =
|
|
"name" in message.channel ? message.channel.name : message.channelId;
|
|
const channelType = message.channel.type as ChannelType;
|
|
const location = message.guild?.name
|
|
? `${message.guild.name} #${channelName}`
|
|
: channelType === ChannelType.GroupDM
|
|
? `Group DM #${channelName}`
|
|
: "DM";
|
|
const authorLabel = message.author?.tag ?? message.author?.username;
|
|
const actor = authorLabel ? `${authorLabel} ` : "";
|
|
return `Discord system: ${actor}${action} in ${location}`;
|
|
}
|
|
|
|
function formatDiscordReactionEmoji(
|
|
reaction: MessageReaction | PartialMessageReaction,
|
|
) {
|
|
if (typeof reaction.emoji.toString === "function") {
|
|
const rendered = reaction.emoji.toString();
|
|
if (rendered && rendered !== "[object Object]") return rendered;
|
|
}
|
|
if (reaction.emoji.id && reaction.emoji.name) {
|
|
return `${reaction.emoji.name}:${reaction.emoji.id}`;
|
|
}
|
|
return reaction.emoji.name ?? "emoji";
|
|
}
|
|
|
|
export function normalizeDiscordAllowList(
|
|
raw: Array<string | number> | undefined,
|
|
prefixes: string[],
|
|
): DiscordAllowList | null {
|
|
if (!raw || raw.length === 0) return null;
|
|
const ids = new Set<string>();
|
|
const names = new Set<string>();
|
|
let allowAll = false;
|
|
|
|
for (const rawEntry of raw) {
|
|
let entry = String(rawEntry).trim();
|
|
if (!entry) continue;
|
|
if (entry === "*") {
|
|
allowAll = true;
|
|
continue;
|
|
}
|
|
for (const prefix of prefixes) {
|
|
if (entry.toLowerCase().startsWith(prefix)) {
|
|
entry = entry.slice(prefix.length);
|
|
break;
|
|
}
|
|
}
|
|
const mentionMatch = entry.match(/^<[@#][!]?(\d+)>$/);
|
|
if (mentionMatch?.[1]) {
|
|
ids.add(mentionMatch[1]);
|
|
continue;
|
|
}
|
|
entry = entry.trim();
|
|
if (entry.startsWith("@") || entry.startsWith("#")) {
|
|
entry = entry.slice(1);
|
|
}
|
|
if (/^\d+$/.test(entry)) {
|
|
ids.add(entry);
|
|
continue;
|
|
}
|
|
const normalized = normalizeDiscordName(entry);
|
|
if (normalized) names.add(normalized);
|
|
const slugged = normalizeDiscordSlug(entry);
|
|
if (slugged) names.add(slugged);
|
|
}
|
|
|
|
if (!allowAll && ids.size === 0 && names.size === 0) return null;
|
|
return { allowAll, ids, names };
|
|
}
|
|
|
|
function normalizeDiscordName(value?: string | null) {
|
|
if (!value) return "";
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
export function normalizeDiscordSlug(value?: string | null) {
|
|
if (!value) return "";
|
|
let text = value.trim().toLowerCase();
|
|
if (!text) return "";
|
|
text = text.replace(/^[@#]+/, "");
|
|
text = text.replace(/[\s_]+/g, "-");
|
|
text = text.replace(/[^a-z0-9-]+/g, "-");
|
|
text = text.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
|
|
return text;
|
|
}
|
|
|
|
export function allowListMatches(
|
|
allowList: DiscordAllowList,
|
|
candidates: {
|
|
id?: string;
|
|
name?: string | null;
|
|
tag?: string | null;
|
|
},
|
|
) {
|
|
if (allowList.allowAll) return true;
|
|
const { id, name, tag } = candidates;
|
|
if (id && allowList.ids.has(id)) return true;
|
|
const normalizedName = normalizeDiscordName(name);
|
|
if (normalizedName && allowList.names.has(normalizedName)) return true;
|
|
const normalizedTag = normalizeDiscordName(tag);
|
|
if (normalizedTag && allowList.names.has(normalizedTag)) return true;
|
|
const slugName = normalizeDiscordSlug(name);
|
|
if (slugName && allowList.names.has(slugName)) return true;
|
|
const slugTag = normalizeDiscordSlug(tag);
|
|
if (slugTag && allowList.names.has(slugTag)) return true;
|
|
return false;
|
|
}
|
|
|
|
function resolveDiscordCommandAuthorized(params: {
|
|
isDirectMessage: boolean;
|
|
allowFrom?: Array<string | number>;
|
|
guildInfo?: DiscordGuildEntryResolved | null;
|
|
author: User;
|
|
}): boolean {
|
|
const { isDirectMessage, allowFrom, guildInfo, author } = params;
|
|
if (isDirectMessage) {
|
|
if (!Array.isArray(allowFrom) || allowFrom.length === 0) return true;
|
|
const allowList = normalizeDiscordAllowList(allowFrom, [
|
|
"discord:",
|
|
"user:",
|
|
]);
|
|
if (!allowList) return true;
|
|
return allowListMatches(allowList, {
|
|
id: author.id,
|
|
name: author.username,
|
|
tag: author.tag,
|
|
});
|
|
}
|
|
const users = guildInfo?.users;
|
|
if (!Array.isArray(users) || users.length === 0) return true;
|
|
const allowList = normalizeDiscordAllowList(users, ["discord:", "user:"]);
|
|
if (!allowList) return true;
|
|
return allowListMatches(allowList, {
|
|
id: author.id,
|
|
name: author.username,
|
|
tag: author.tag,
|
|
});
|
|
}
|
|
|
|
export function shouldEmitDiscordReactionNotification(params: {
|
|
mode: "off" | "own" | "all" | "allowlist" | undefined;
|
|
botId?: string | null;
|
|
messageAuthorId?: string | null;
|
|
userId: string;
|
|
userName?: string | null;
|
|
userTag?: string | null;
|
|
allowlist?: Array<string | number> | null;
|
|
}) {
|
|
const { mode, botId, messageAuthorId, userId, userName, userTag, allowlist } =
|
|
params;
|
|
const effectiveMode = mode ?? "own";
|
|
if (effectiveMode === "off") return false;
|
|
if (effectiveMode === "own") {
|
|
if (!botId || !messageAuthorId) return false;
|
|
return messageAuthorId === botId;
|
|
}
|
|
if (effectiveMode === "allowlist") {
|
|
if (!Array.isArray(allowlist) || allowlist.length === 0) return false;
|
|
const users = normalizeDiscordAllowList(allowlist, ["discord:", "user:"]);
|
|
if (!users) return false;
|
|
return allowListMatches(users, {
|
|
id: userId,
|
|
name: userName ?? undefined,
|
|
tag: userTag ?? undefined,
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function resolveDiscordGuildEntry(params: {
|
|
guild: Guild | null;
|
|
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
|
|
}): DiscordGuildEntryResolved | null {
|
|
const { guild, guildEntries } = params;
|
|
if (!guild || !guildEntries || Object.keys(guildEntries).length === 0) {
|
|
return null;
|
|
}
|
|
const guildId = guild.id;
|
|
const guildSlug = normalizeDiscordSlug(guild.name);
|
|
const direct = guildEntries[guildId];
|
|
if (direct) {
|
|
return {
|
|
id: guildId,
|
|
slug: direct.slug ?? guildSlug,
|
|
requireMention: direct.requireMention,
|
|
reactionNotifications: direct.reactionNotifications,
|
|
users: direct.users,
|
|
channels: direct.channels,
|
|
};
|
|
}
|
|
if (guildSlug && guildEntries[guildSlug]) {
|
|
const entry = guildEntries[guildSlug];
|
|
return {
|
|
id: guildId,
|
|
slug: entry.slug ?? guildSlug,
|
|
requireMention: entry.requireMention,
|
|
reactionNotifications: entry.reactionNotifications,
|
|
users: entry.users,
|
|
channels: entry.channels,
|
|
};
|
|
}
|
|
const matchBySlug = Object.entries(guildEntries).find(([, entry]) => {
|
|
const entrySlug = normalizeDiscordSlug(entry.slug);
|
|
return entrySlug && entrySlug === guildSlug;
|
|
});
|
|
if (matchBySlug) {
|
|
const entry = matchBySlug[1];
|
|
return {
|
|
id: guildId,
|
|
slug: entry.slug ?? guildSlug,
|
|
requireMention: entry.requireMention,
|
|
reactionNotifications: entry.reactionNotifications,
|
|
users: entry.users,
|
|
channels: entry.channels,
|
|
};
|
|
}
|
|
const wildcard = guildEntries["*"];
|
|
if (wildcard) {
|
|
return {
|
|
id: guildId,
|
|
slug: wildcard.slug ?? guildSlug,
|
|
requireMention: wildcard.requireMention,
|
|
reactionNotifications: wildcard.reactionNotifications,
|
|
users: wildcard.users,
|
|
channels: wildcard.channels,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function resolveDiscordChannelConfig(params: {
|
|
guildInfo: DiscordGuildEntryResolved | null;
|
|
channelId: string;
|
|
channelName?: string;
|
|
channelSlug?: string;
|
|
}): DiscordChannelConfigResolved | null {
|
|
const { guildInfo, channelId, channelName, channelSlug } = params;
|
|
const channelEntries = guildInfo?.channels;
|
|
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
|
const entry =
|
|
channelEntries[channelId] ??
|
|
(channelSlug
|
|
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
|
: undefined) ??
|
|
(channelName
|
|
? channelEntries[normalizeDiscordSlug(channelName)]
|
|
: undefined);
|
|
if (!entry) return { allowed: false };
|
|
return {
|
|
allowed: entry.allow !== false,
|
|
requireMention: entry.requireMention,
|
|
};
|
|
}
|
|
return { allowed: true };
|
|
}
|
|
|
|
export function resolveGroupDmAllow(params: {
|
|
channels: Array<string | number> | undefined;
|
|
channelId: string;
|
|
channelName?: string;
|
|
channelSlug?: string;
|
|
}) {
|
|
const { channels, channelId, channelName, channelSlug } = params;
|
|
if (!channels || channels.length === 0) return true;
|
|
const allowList = normalizeDiscordAllowList(channels, ["channel:"]);
|
|
if (!allowList) return true;
|
|
return allowListMatches(allowList, {
|
|
id: channelId,
|
|
name: channelSlug || channelName,
|
|
});
|
|
}
|
|
|
|
async function sendTyping(message: Message) {
|
|
try {
|
|
const channel = message.channel;
|
|
if (channel.isSendable()) {
|
|
await channel.sendTyping();
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
async function deliverReplies({
|
|
replies,
|
|
target,
|
|
token,
|
|
runtime,
|
|
replyToMode,
|
|
textLimit,
|
|
}: {
|
|
replies: ReplyPayload[];
|
|
target: string;
|
|
token: string;
|
|
runtime: RuntimeEnv;
|
|
replyToMode: ReplyToMode;
|
|
textLimit: number;
|
|
}) {
|
|
let hasReplied = false;
|
|
const chunkLimit = Math.min(textLimit, 2000);
|
|
for (const payload of replies) {
|
|
const mediaList =
|
|
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
const text = payload.text ?? "";
|
|
const replyToId = payload.replyToId;
|
|
if (!text && mediaList.length === 0) continue;
|
|
if (mediaList.length === 0) {
|
|
for (const chunk of chunkText(text, chunkLimit)) {
|
|
const replyTo = resolveDiscordReplyTarget({
|
|
replyToMode,
|
|
replyToId,
|
|
hasReplied,
|
|
});
|
|
await sendMessageDiscord(target, chunk, {
|
|
token,
|
|
replyTo,
|
|
});
|
|
if (replyTo && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
} else {
|
|
let first = true;
|
|
for (const mediaUrl of mediaList) {
|
|
const caption = first ? text : "";
|
|
first = false;
|
|
const replyTo = resolveDiscordReplyTarget({
|
|
replyToMode,
|
|
replyToId,
|
|
hasReplied,
|
|
});
|
|
await sendMessageDiscord(target, caption, {
|
|
token,
|
|
mediaUrl,
|
|
replyTo,
|
|
});
|
|
if (replyTo && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
}
|
|
runtime.log?.(`delivered reply to ${target}`);
|
|
}
|
|
}
|