Discord: default reaction notifications to own

This commit is contained in:
Peter Steinberger
2026-01-03 18:48:10 +00:00
parent 7abd6713c8
commit 52458a5628
7 changed files with 145 additions and 33 deletions

View File

@@ -170,7 +170,7 @@ struct DiscordGuildForm: Identifiable {
key: String = "", key: String = "",
slug: String = "", slug: String = "",
requireMention: Bool = false, requireMention: Bool = false,
reactionNotifications: String = "allowlist", reactionNotifications: String = "own",
users: String = "", users: String = "",
channels: [DiscordGuildChannelForm] = [] channels: [DiscordGuildChannelForm] = []
) { ) {
@@ -497,7 +497,7 @@ final class ConnectionsStore {
let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? "" let reactionModeRaw = entry["reactionNotifications"]?.stringValue ?? ""
let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw) let reactionNotifications = ["off", "own", "all", "allowlist"].contains(reactionModeRaw)
? reactionModeRaw ? reactionModeRaw
: "allowlist" : "own"
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 }

View File

@@ -266,7 +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 reactionNotifications: "own", // 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 },
@@ -283,9 +283,9 @@ Clawdis starts Discord only when a `discord` config section exists. The token is
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity. Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Reaction notification modes: Reaction notification modes:
- `off`: no reaction events. - `off`: no reaction events.
- `own`: all reactions on the bot's own messages. - `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages. - `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages. - `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
### `imessage` (imsg CLI) ### `imessage` (imsg CLI)

View File

@@ -86,7 +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", reactionNotifications: "own",
users: ["987654321098765432", "steipete"], users: ["987654321098765432", "steipete"],
channels: { channels: {
general: { allow: true }, general: { allow: true },
@@ -121,9 +121,9 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
Reaction notifications use `guilds.<id>.reactionNotifications`: Reaction notifications use `guilds.<id>.reactionNotifications`:
- `off`: no reaction events. - `off`: no reaction events.
- `own`: all reactions on the bot's own messages. - `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages. - `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages. - `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
### Tool action defaults ### Tool action defaults

View File

@@ -8,6 +8,7 @@ import {
resolveDiscordGuildEntry, resolveDiscordGuildEntry,
resolveDiscordReplyTarget, resolveDiscordReplyTarget,
resolveGroupDmAllow, resolveGroupDmAllow,
shouldEmitDiscordReactionNotification,
} from "./monitor.js"; } from "./monitor.js";
const fakeGuild = (id: string, name: string) => const fakeGuild = (id: string, name: string) =>
@@ -21,6 +22,7 @@ const makeEntries = (
out[key] = { out[key] = {
slug: value.slug, slug: value.slug,
requireMention: value.requireMention, requireMention: value.requireMention,
reactionNotifications: value.reactionNotifications,
users: value.users, users: value.users,
channels: value.channels, channels: value.channels,
}; };
@@ -207,3 +209,87 @@ describe("discord reply target selection", () => {
).toBe("123"); ).toBe("123");
}); });
}); });
describe("discord reaction notification gating", () => {
it("defaults to own when mode is unset", () => {
expect(
shouldEmitDiscordReactionNotification({
mode: undefined,
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-1",
}),
).toBe(true);
expect(
shouldEmitDiscordReactionNotification({
mode: undefined,
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
}),
).toBe(false);
});
it("skips when mode is off", () => {
expect(
shouldEmitDiscordReactionNotification({
mode: "off",
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-1",
}),
).toBe(false);
});
it("allows all reactions when mode is all", () => {
expect(
shouldEmitDiscordReactionNotification({
mode: "all",
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
}),
).toBe(true);
});
it("requires bot ownership when mode is own", () => {
expect(
shouldEmitDiscordReactionNotification({
mode: "own",
botId: "bot-1",
messageAuthorId: "bot-1",
userId: "user-2",
}),
).toBe(true);
expect(
shouldEmitDiscordReactionNotification({
mode: "own",
botId: "bot-1",
messageAuthorId: "user-2",
userId: "user-3",
}),
).toBe(false);
});
it("requires allowlist matches when mode is allowlist", () => {
expect(
shouldEmitDiscordReactionNotification({
mode: "allowlist",
botId: "bot-1",
messageAuthorId: "user-1",
userId: "user-2",
allowlist: [],
}),
).toBe(false);
expect(
shouldEmitDiscordReactionNotification({
mode: "allowlist",
botId: "bot-1",
messageAuthorId: "user-1",
userId: "123",
userName: "steipete",
allowlist: ["123", "other"],
}),
).toBe(true);
});
});

View File

@@ -559,28 +559,17 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botId = client.user?.id; const botId = client.user?.id;
if (botId && user.id === botId) return; if (botId && user.id === botId) return;
const reactionMode = guildInfo?.reactionNotifications ?? "allowlist"; const reactionMode = guildInfo?.reactionNotifications ?? "own";
if (reactionMode === "off") return; const shouldNotify = shouldEmitDiscordReactionNotification({
if (reactionMode === "own") { mode: reactionMode,
const authorId = message.author?.id; botId,
if (!botId || authorId !== botId) return; messageAuthorId: message.author?.id,
} userId: user.id,
if (reactionMode === "allowlist") { userName: user.username,
const userAllow = guildInfo?.users; userTag: user.tag,
if (!Array.isArray(userAllow) || userAllow.length === 0) return; allowlist: guildInfo?.users,
const users = normalizeDiscordAllowList(userAllow, [ });
"discord:", if (!shouldNotify) return;
"user:",
]);
const userOk =
!!users &&
allowListMatches(users, {
id: user.id,
name: user.username,
tag: user.tag,
});
if (!userOk) return;
}
const emojiLabel = formatDiscordReactionEmoji(resolvedReaction); const emojiLabel = formatDiscordReactionEmoji(resolvedReaction);
const actorLabel = user.tag ?? user.username ?? user.id; const actorLabel = user.tag ?? user.username ?? user.id;
@@ -985,6 +974,43 @@ export function allowListMatches(
return false; return false;
} }
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: { export function resolveDiscordGuildEntry(params: {
guild: Guild | null; guild: Guild | null;
guildEntries: Record<string, DiscordGuildEntryResolved> | undefined; guildEntries: Record<string, DiscordGuildEntryResolved> | undefined;

View File

@@ -203,7 +203,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
entry.reactionNotifications === "own" || entry.reactionNotifications === "own" ||
entry.reactionNotifications === "allowlist" entry.reactionNotifications === "allowlist"
? entry.reactionNotifications ? entry.reactionNotifications
: "allowlist", : "own",
users: toList(entry.users), users: toList(entry.users),
channels, channels,
}; };

View File

@@ -654,7 +654,7 @@ function renderProvider(
next[guildIndex] = { next[guildIndex] = {
...next[guildIndex], ...next[guildIndex],
reactionNotifications: (e.target as HTMLSelectElement) reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all", .value as "off" | "own" | "all" | "allowlist",
}; };
props.onDiscordChange({ guilds: next }); props.onDiscordChange({ guilds: next });
}} }}
@@ -832,7 +832,7 @@ function renderProvider(
key: "", key: "",
slug: "", slug: "",
requireMention: false, requireMention: false,
reactionNotifications: "allowlist", reactionNotifications: "own",
users: "", users: "",
channels: [], channels: [],
}, },