Discord: add reaction notification allowlist

This commit is contained in:
Shadow
2026-01-03 12:29:39 -06:00
committed by Peter Steinberger
parent cdfbd6e7eb
commit 451174ca10
11 changed files with 210 additions and 7 deletions

View File

@@ -1,12 +1,20 @@
import {
ApplicationCommandOptionType,
type Attachment,
ChannelType,
type ChatInputCommandInteraction,
Client,
type CommandInteractionOption,
Events,
GatewayIntentBits,
type Guild,
type Message,
type MessageReaction,
type PartialMessage,
type PartialMessageReaction,
Partials,
type PartialUser,
type User,
} from "discord.js";
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
@@ -21,6 +29,7 @@ import type {
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose, warn } 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";
@@ -61,6 +70,7 @@ 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 }>;
};
@@ -149,10 +159,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
GatewayIntentBits.DirectMessageReactions,
],
partials: [
Partials.Channel,
Partials.Message,
Partials.Reaction,
Partials.User,
],
partials: [Partials.Channel],
});
const logger = getChildLogger({ module: "discord-auto-reply" });
@@ -503,6 +520,99 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
});
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 ?? "allowlist";
if (reactionMode === "off") return;
if (reactionMode === "own") {
const authorId = message.author?.id;
if (!botId || authorId !== botId) return;
}
if (reactionMode === "allowlist") {
const userAllow = guildInfo?.users;
if (!Array.isArray(userAllow) || userAllow.length === 0) return;
const users = normalizeDiscordAllowList(userAllow, [
"discord:",
"user:",
]);
const userOk =
!!users &&
allowListMatches(users, {
id: user.id,
name: user.username,
tag: user.tag,
});
if (!userOk) 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;
enqueueSystemEvent(text, {
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");
});
client.on(Events.InteractionCreate, async (interaction) => {
try {
if (!slashCommand.enabled) return;
@@ -698,7 +808,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}
async function resolveMedia(
message: import("discord.js").Message,
message: Message,
maxBytes: number,
): Promise<DiscordMediaInfo | null> {
const attachment = message.attachments.first();
@@ -723,7 +833,7 @@ async function resolveMedia(
};
}
function inferPlaceholder(attachment: import("discord.js").Attachment): string {
function inferPlaceholder(attachment: Attachment): string {
const mime = attachment.contentType ?? "";
if (mime.startsWith("image/")) return "<media:image>";
if (mime.startsWith("video/")) return "<media:video>";
@@ -768,17 +878,30 @@ async function resolveReplyContext(message: Message): Promise<string | null> {
}
}
function buildDirectLabel(message: import("discord.js").Message) {
function buildDirectLabel(message: Message) {
const username = message.author.tag;
return `${username} id:${message.author.id}`;
}
function buildGuildLabel(message: import("discord.js").Message) {
function buildGuildLabel(message: Message) {
const channelName =
"name" in message.channel ? message.channel.name : message.channelId;
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
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[],
@@ -863,7 +986,7 @@ export function allowListMatches(
}
export function resolveDiscordGuildEntry(params: {
guild: import("discord.js").Guild | null;
guild: Guild | null;
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;
}): DiscordGuildEntryResolved | null {
const { guild, guildEntries } = params;
@@ -878,6 +1001,7 @@ export function resolveDiscordGuildEntry(params: {
id: guildId,
slug: direct.slug ?? guildSlug,
requireMention: direct.requireMention,
reactionNotifications: direct.reactionNotifications,
users: direct.users,
channels: direct.channels,
};
@@ -888,6 +1012,7 @@ export function resolveDiscordGuildEntry(params: {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
reactionNotifications: entry.reactionNotifications,
users: entry.users,
channels: entry.channels,
};
@@ -902,6 +1027,7 @@ export function resolveDiscordGuildEntry(params: {
id: guildId,
slug: entry.slug ?? guildSlug,
requireMention: entry.requireMention,
reactionNotifications: entry.reactionNotifications,
users: entry.users,
channels: entry.channels,
};
@@ -912,6 +1038,7 @@ export function resolveDiscordGuildEntry(params: {
id: guildId,
slug: wildcard.slug ?? guildSlug,
requireMention: wildcard.requireMention,
reactionNotifications: wildcard.reactionNotifications,
users: wildcard.users,
channels: wildcard.channels,
};
@@ -1121,7 +1248,7 @@ async function deliverSlashReplies({
textLimit,
}: {
replies: ReplyPayload[];
interaction: import("discord.js").ChatInputCommandInteraction;
interaction: ChatInputCommandInteraction;
ephemeral: boolean;
textLimit: number;
}) {