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

@@ -13,6 +13,7 @@
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC. - Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients). - Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) — thanks @thewilloftheshadow.
### Fixes ### Fixes
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.

View File

@@ -515,6 +515,17 @@ struct ConnectionsSettings: View {
.labelsHidden() .labelsHidden()
.toggleStyle(.checkbox) .toggleStyle(.checkbox)
} }
GridRow {
self.gridLabel("Reaction notifications")
Picker("", selection: $guild.reactionNotifications) {
Text("Off").tag("off")
Text("Own").tag("own")
Text("All").tag("all")
Text("Allowlist").tag("allowlist")
}
.labelsHidden()
.pickerStyle(.segmented)
}
GridRow { GridRow {
self.gridLabel("Users allowlist") self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users) TextField("123456789, username#1234", text: $guild.users)

View File

@@ -162,6 +162,7 @@ struct DiscordGuildForm: Identifiable {
var key: String var key: String
var slug: String var slug: String
var requireMention: Bool var requireMention: Bool
var reactionNotifications: String
var users: String var users: String
var channels: [DiscordGuildChannelForm] var channels: [DiscordGuildChannelForm]
@@ -169,12 +170,14 @@ struct DiscordGuildForm: Identifiable {
key: String = "", key: String = "",
slug: String = "", slug: String = "",
requireMention: Bool = false, requireMention: Bool = false,
reactionNotifications: String = "allowlist",
users: String = "", users: String = "",
channels: [DiscordGuildChannelForm] = [] channels: [DiscordGuildChannelForm] = []
) { ) {
self.key = key self.key = key
self.slug = slug self.slug = slug
self.requireMention = requireMention self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users self.users = users
self.channels = channels self.channels = channels
} }
@@ -491,6 +494,10 @@ final class ConnectionsStore {
let entry = value.dictionaryValue ?? [:] let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? "" let slug = entry["slug"]?.stringValue ?? ""
let requireMention = entry["requireMention"]?.boolValue ?? false let requireMention = entry["requireMention"]?.boolValue ?? false
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw
: "allowlist"
let users = entry["users"]?.arrayValue? let users = entry["users"]?.arrayValue?
.compactMap { item -> String? in .compactMap { item -> String? in
if let str = item.stringValue { return str } if let str = item.stringValue { return str }
@@ -518,6 +525,7 @@ final class ConnectionsStore {
key: key, key: key,
slug: slug, slug: slug,
requireMention: requireMention, requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users, users: users,
channels: channels) channels: channels)
} }
@@ -794,6 +802,9 @@ final class ConnectionsStore {
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines) let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { payload["slug"] = slug } if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true } if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = entry.users let users = entry.users
.split(separator: ",") .split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

View File

@@ -266,6 +266,7 @@ Configure the Discord bot by setting the bot token and optional gating:
"123456789012345678": { // guild id (preferred) or slug "123456789012345678": { // guild id (preferred) or slug
slug: "friends-of-clawd", slug: "friends-of-clawd",
requireMention: false, // per-guild default requireMention: false, // per-guild default
reactionNotifications: "allowlist", // off | own | all | allowlist
users: ["987654321098765432"], // optional per-guild user allowlist users: ["987654321098765432"], // optional per-guild user allowlist
channels: { channels: {
general: { allow: true }, general: { allow: true },

View File

@@ -86,6 +86,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
"123456789012345678": { "123456789012345678": {
slug: "friends-of-clawd", slug: "friends-of-clawd",
requireMention: false, requireMention: false,
reactionNotifications: "allowlist",
users: ["987654321098765432", "steipete"], users: ["987654321098765432", "steipete"],
channels: { channels: {
general: { allow: true }, general: { allow: true },
@@ -107,6 +108,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names). - `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `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`).
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
- `mediaMaxMb`: clamp inbound media saved to disk. - `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
@@ -117,6 +119,8 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
- `roles` (role add/remove, default `false`) - `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`) - `moderation` (timeout/kick/ban, default `false`)
Reaction notifications use `guilds.<id>.reactionNotifications`; `allowlist` checks `guilds.<id>.users`, while `all` ignores that allowlist.
### Tool action defaults ### Tool action defaults
| Action group | Default | Notes | | Action group | Default | Notes |

View File

@@ -203,9 +203,17 @@ export type DiscordGuildChannelConfig = {
requireMention?: boolean; requireMention?: boolean;
}; };
export type DiscordReactionNotificationMode =
| "off"
| "own"
| "all"
| "allowlist";
export type DiscordGuildEntry = { export type DiscordGuildEntry = {
slug?: string; slug?: string;
requireMention?: boolean; requireMention?: boolean;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: DiscordReactionNotificationMode;
users?: Array<string | number>; users?: Array<string | number>;
channels?: Record<string, DiscordGuildChannelConfig>; channels?: Record<string, DiscordGuildChannelConfig>;
}; };
@@ -1153,6 +1161,9 @@ export const ClawdisSchema = z.object({
.object({ .object({
slug: z.string().optional(), slug: z.string().optional(),
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
reactionNotifications: z
.enum(["off", "own", "all", "allowlist"])
.optional(),
users: z.array(z.union([z.string(), z.number()])).optional(), users: z.array(z.union([z.string(), z.number()])).optional(),
channels: z channels: z
.record( .record(

View File

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

View File

@@ -197,6 +197,13 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof entry.requireMention === "boolean" typeof entry.requireMention === "boolean"
? entry.requireMention ? entry.requireMention
: false, : false,
reactionNotifications:
entry.reactionNotifications === "off" ||
entry.reactionNotifications === "all" ||
entry.reactionNotifications === "own" ||
entry.reactionNotifications === "allowlist"
? entry.reactionNotifications
: "allowlist",
users: toList(entry.users), users: toList(entry.users),
channels, channels,
}; };

View File

@@ -292,6 +292,14 @@ export async function saveDiscordConfig(state: ConnectionsState) {
const slug = String(guild.slug ?? "").trim(); const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug; if (slug) entry.slug = slug;
if (guild.requireMention) entry.requireMention = true; if (guild.requireMention) entry.requireMention = true;
if (
guild.reactionNotifications === "off" ||
guild.reactionNotifications === "all" ||
guild.reactionNotifications === "own" ||
guild.reactionNotifications === "allowlist"
) {
entry.reactionNotifications = guild.reactionNotifications;
}
const users = parseList(guild.users); const users = parseList(guild.users);
if (users.length > 0) entry.users = users; if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {}; const channels: Record<string, unknown> = {};

View File

@@ -31,6 +31,7 @@ export type DiscordGuildForm = {
key: string; key: string;
slug: string; slug: string;
requireMention: boolean; requireMention: boolean;
reactionNotifications: "off" | "own" | "all" | "allowlist";
users: string; users: string;
channels: DiscordGuildChannelForm[]; channels: DiscordGuildChannelForm[];
}; };

View File

@@ -645,6 +645,26 @@ function renderProvider(
<option value="no">No</option> <option value="no">No</option>
</select> </select>
</label> </label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${guild.reactionNotifications}
@change=${(e: Event) => {
const next = [...props.discordForm.guilds];
next[guildIndex] = {
...next[guildIndex],
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all",
};
props.onDiscordChange({ guilds: next });
}}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field"> <label class="field">
<span>Users allowlist</span> <span>Users allowlist</span>
<input <input
@@ -812,6 +832,7 @@ function renderProvider(
key: "", key: "",
slug: "", slug: "",
requireMention: false, requireMention: false,
reactionNotifications: "allowlist",
users: "", users: "",
channels: [], channels: [],
}, },