Discord: add reaction notification allowlist
This commit is contained in:
committed by
Peter Steinberger
parent
cdfbd6e7eb
commit
451174ca10
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -31,6 +31,7 @@ export type DiscordGuildForm = {
|
||||
key: string;
|
||||
slug: string;
|
||||
requireMention: boolean;
|
||||
reactionNotifications: "off" | "own" | "all" | "allowlist";
|
||||
users: string;
|
||||
channels: DiscordGuildChannelForm[];
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user