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.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- 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
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.

View File

@@ -515,6 +515,17 @@ struct ConnectionsSettings: View {
.labelsHidden()
.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 {
self.gridLabel("Users allowlist")
TextField("123456789, username#1234", text: $guild.users)

View File

@@ -162,6 +162,7 @@ struct DiscordGuildForm: Identifiable {
var key: String
var slug: String
var requireMention: Bool
var reactionNotifications: String
var users: String
var channels: [DiscordGuildChannelForm]
@@ -169,12 +170,14 @@ struct DiscordGuildForm: Identifiable {
key: String = "",
slug: String = "",
requireMention: Bool = false,
reactionNotifications: String = "allowlist",
users: String = "",
channels: [DiscordGuildChannelForm] = []
) {
self.key = key
self.slug = slug
self.requireMention = requireMention
self.reactionNotifications = reactionNotifications
self.users = users
self.channels = channels
}
@@ -491,6 +494,10 @@ final class ConnectionsStore {
let entry = value.dictionaryValue ?? [:]
let slug = entry["slug"]?.stringValue ?? ""
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?
.compactMap { item -> String? in
if let str = item.stringValue { return str }
@@ -518,6 +525,7 @@ final class ConnectionsStore {
key: key,
slug: slug,
requireMention: requireMention,
reactionNotifications: reactionNotifications,
users: users,
channels: channels)
}
@@ -794,6 +802,9 @@ final class ConnectionsStore {
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = entry.users
.split(separator: ",")
.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
slug: "friends-of-clawd",
requireMention: false, // per-guild default
reactionNotifications: "allowlist", // off | own | all | allowlist
users: ["987654321098765432"], // optional per-guild user allowlist
channels: {
general: { allow: true },

View File

@@ -86,6 +86,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
"123456789012345678": {
slug: "friends-of-clawd",
requireMention: false,
reactionNotifications: "allowlist",
users: ["987654321098765432", "steipete"],
channels: {
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>.channels`: channel rules (keys are channel slugs or ids).
- `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).
- `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).
@@ -117,6 +119,8 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
- `roles` (role add/remove, 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
| Action group | Default | Notes |

View File

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

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;
}) {

View File

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

View File

@@ -292,6 +292,14 @@ export async function saveDiscordConfig(state: ConnectionsState) {
const slug = String(guild.slug ?? "").trim();
if (slug) entry.slug = slug;
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);
if (users.length > 0) entry.users = users;
const channels: Record<string, unknown> = {};

View File

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

View File

@@ -645,6 +645,26 @@ function renderProvider(
<option value="no">No</option>
</select>
</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">
<span>Users allowlist</span>
<input
@@ -812,6 +832,7 @@ function renderProvider(
key: "",
slug: "",
requireMention: false,
reactionNotifications: "allowlist",
users: "",
channels: [],
},