Files
silo-server/web/src/pages/admin-settings/NotificationsAdminSettings.tsx
T
QuickandClaude Fable 5 5f05374a1d feat(notifications): Discord bot DM channel with account linking
Adds Discord direct messages as a notification channel. Users link their
Discord account via OAuth2 (identify scope only, one-time server-side
state rows); a bot delivers their inbox notifications as DMs.

- Extract the email channel's watermark sweep into a generic
  account-channel engine; email and Discord are now thin adapters, so
  the SKIP LOCKED claim / watermark-after-send durability logic exists
  once.
- New internal/discord REST client (token exchange, identity, open DM,
  send message) — no Gateway connection, no new dependencies.
- Opt-in master switch (notifications.discord_enabled, default off)
  gates delivery, linking, capability, and the admin settings reveal.
- Admin UI: credentials (secret + bot token encrypted at rest), dev
  portal setup checklist, bot invite link buttons, and a test button
  that bypasses the settings read cache and is disabled while
  credential edits are unsaved.
- DM failures from missing shared guild (Discord 50007) surface as link
  health in user settings and self-heal via capped backoff.
- New combined mode (per_episode_and_digest) for email and Discord:
  instant sends all day plus a daily digest recapping the whole window
  since the previous digest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:51:03 -04:00

430 lines
18 KiB
TypeScript

import { useMemo, useState } from "react";
import { Check, Copy, ExternalLink, Loader2, Send, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/api/client";
import { Button } from "@/components/ui/button";
import { useSettingsForm } from "@/hooks/useSettingsForm";
import { FieldGroup } from "./FieldGroup";
import { SaveBar } from "./SaveBar";
import { SettingField } from "./SettingField";
const KEYS = [
"notifications.release_events_enabled",
"notifications.fanout_enabled",
"notifications.ui_enabled",
"notifications.webhooks_enabled",
"notifications.web_push_enabled",
"notifications.fanout.settle_seconds",
"notifications.fanout.max_series_burst",
"notifications.fanout.max_event_age_hours",
"notifications.webhooks.max_per_profile",
"notifications.webhooks.allow_private_destinations",
"notifications.webhooks.deliveries_per_minute_per_profile",
"notifications.email_enabled",
"notifications.email.allow_per_episode",
"notifications.email.digest_hour",
"notifications.email.external_url",
"notifications.discord_enabled",
"notifications.discord.allow_per_episode",
"notifications.discord.digest_hour",
"discord.client_id",
"discord.client_secret",
"discord.bot_token",
"notifications.retention.read_days",
"notifications.retention.unread_days",
"notifications.retention.event_days",
];
interface DiscordTestResult {
ok: boolean;
duration_ms: number;
message?: string;
}
/**
* Invite link for adding the bot to a Discord server. Membership alone is
* enough to DM, so no permissions are requested.
*/
function discordInviteUrl(clientId: string): string {
return `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(clientId)}&scope=bot&permissions=0`;
}
function InviteBotRow({ clientId }: { clientId: string }) {
const [copied, setCopied] = useState(false);
const trimmed = clientId.trim();
return (
<div className="space-y-2 py-2">
<div className="flex flex-wrap gap-1.5">
<Button
variant="outline"
size="sm"
disabled={!trimmed}
onClick={() => window.open(discordInviteUrl(trimmed), "_blank", "noopener,noreferrer")}
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Invite bot to server
</Button>
<Button
variant="outline"
size="sm"
disabled={!trimmed}
onClick={() => {
void navigator.clipboard.writeText(discordInviteUrl(trimmed));
setCopied(true);
toast.success("Invite link copied");
window.setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<Check className="mr-1.5 h-3.5 w-3.5" />
) : (
<Copy className="mr-1.5 h-3.5 w-3.5" />
)}
Copy link
</Button>
</div>
<div className="text-muted-foreground text-xs">
{trimmed
? "Anyone with Manage Server permission on your Discord server can open this link to add the bot. Users must be members of that server to receive DMs."
: "Enter the Client ID above to generate the invite link."}
</div>
</div>
);
}
function TestDiscordRow({ unsaved }: { unsaved: boolean }) {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<DiscordTestResult | null>(null);
const runTest = async () => {
setPending(true);
setResult(null);
try {
const response = await api<DiscordTestResult>("/admin/notifications/discord/test", {
method: "POST",
});
setResult(response);
if (response.ok) {
toast.success("Discord bot connected");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Test request failed");
} finally {
setPending(false);
}
};
return (
<div className="space-y-2 py-2">
<Button
variant="outline"
size="sm"
disabled={pending || unsaved}
onClick={() => void runTest()}
>
{pending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
Test bot token
</Button>
{unsaved && (
<div className="text-muted-foreground text-xs">
Save your changes first — the test uses the saved credentials.
</div>
)}
{result && (
<div className={`text-xs ${result.ok ? "text-emerald-500" : "text-amber-500"}`}>
{result.ok ? "Success" : "Failed"} ({result.duration_ms}ms)
{result.message ? ` — ${result.message}` : ""}
</div>
)}
</div>
);
}
export default function NotificationsAdminSettings() {
const form = useSettingsForm({ keys: useMemo(() => KEYS, []) });
if (form.isLoading) return <div>Loading...</div>;
// Kill switches default to enabled when unset; the backend treats any
// unrecognized value as the default, so an empty stored value means "on".
const toggleValue = (key: string) => form.getValue(key) || "true";
// Numeric settings fall back to their server-side defaults when unset;
// surface the effective default instead of an empty input.
const numberValue = (key: string, fallback: string) => form.getValue(key) || fallback;
const allowPrivate =
form.getValue("notifications.webhooks.allow_private_destinations") === "true";
// Discord is opt-in (default off); the rest of its settings only appear
// once the bot is enabled.
const discordEnabled = form.getValue("notifications.discord_enabled") === "true";
// The test endpoint reads the SAVED credentials; testing with unsaved
// edits in the form would silently test the old values.
const discordCredentialsDirty = [
"discord.client_id",
"discord.client_secret",
"discord.bot_token",
].some(form.isDirty);
return (
<div className="flex h-full flex-col">
<div className="mb-6 space-y-2">
<h2 className="text-xl font-semibold tracking-tight">Notifications</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Operational controls for the notification system. All settings apply live — no restart
needed. Per-profile preferences live in each user&apos;s own notification settings.
</p>
</div>
<div className="flex-1 space-y-6">
<FieldGroup label="Kill Switches">
<SettingField
label="Release Events"
hint="Record new-episode availability during library scans. Off stops new notifications at the source."
type="toggle"
value={toggleValue("notifications.release_events_enabled")}
onChange={(v) => form.setValue("notifications.release_events_enabled", v)}
/>
<SettingField
label="Fanout"
hint="Deliver recorded events to interested profiles. Off pauses delivery; events queue until re-enabled or they expire."
type="toggle"
value={toggleValue("notifications.fanout_enabled")}
onChange={(v) => form.setValue("notifications.fanout_enabled", v)}
/>
<SettingField
label="Notification UI"
hint="Advertise the inbox and preferences to clients. Off hides the notification UI everywhere."
type="toggle"
value={toggleValue("notifications.ui_enabled")}
onChange={(v) => form.setValue("notifications.ui_enabled", v)}
/>
<SettingField
label="Web Push"
hint="Deliver browser push notifications to subscribed devices."
type="toggle"
value={toggleValue("notifications.web_push_enabled")}
onChange={(v) => form.setValue("notifications.web_push_enabled", v)}
/>
</FieldGroup>
<FieldGroup label="Fanout Tuning">
<SettingField
label="Settle Delay (seconds)"
hint="How long an event must sit before fanout claims it, so one scan's episodes batch together (default 30)"
type="number"
value={numberValue("notifications.fanout.settle_seconds", "30")}
onChange={(v) => form.setValue("notifications.fanout.settle_seconds", v)}
/>
<SettingField
label="Max Series Burst"
hint="Max notifications per series per batch; the rest are suppressed to avoid floods (default 3)"
type="number"
value={numberValue("notifications.fanout.max_series_burst", "3")}
onChange={(v) => form.setValue("notifications.fanout.max_series_burst", v)}
/>
<SettingField
label="Max Event Age (hours)"
hint="Events older than this are dropped instead of delivered late, e.g. after extended downtime (default 72)"
type="number"
value={numberValue("notifications.fanout.max_event_age_hours", "72")}
onChange={(v) => form.setValue("notifications.fanout.max_event_age_hours", v)}
/>
</FieldGroup>
<FieldGroup label="Webhook Guards">
<SettingField
label="Outbound Webhooks"
hint="Let users create personal webhooks (Discord, generic) that receive their notifications. Off by default; sends server-originated requests to user-chosen URLs."
type="toggle"
value={form.getValue("notifications.webhooks_enabled") || "false"}
onChange={(v) => form.setValue("notifications.webhooks_enabled", v)}
/>
<SettingField
label="Max Webhooks Per Profile"
hint="How many webhooks a single profile may create (default 10)"
type="number"
value={numberValue("notifications.webhooks.max_per_profile", "10")}
onChange={(v) => form.setValue("notifications.webhooks.max_per_profile", v)}
/>
<SettingField
label="Deliveries Per Minute Per Profile"
hint="Webhook delivery rate limit; over-limit notifications still reach the inbox (default 60)"
type="number"
value={numberValue("notifications.webhooks.deliveries_per_minute_per_profile", "60")}
onChange={(v) =>
form.setValue("notifications.webhooks.deliveries_per_minute_per_profile", v)
}
/>
<SettingField
label="Allow Private Destinations"
hint="Disables the SSRF guard so webhooks may target private and LAN addresses. Development only."
type="toggle"
value={form.getValue("notifications.webhooks.allow_private_destinations")}
onChange={(v) => form.setValue("notifications.webhooks.allow_private_destinations", v)}
/>
{allowPrivate && (
<div className="flex items-start gap-2 py-3 text-xs text-amber-500">
<TriangleAlert className="mt-0.5 h-4 w-4 flex-shrink-0" />
<p>
Private destinations are allowed: any user with webhook access can make this server
send requests to internal network addresses. Leave this off outside development.
</p>
</div>
)}
</FieldGroup>
<FieldGroup label="Email">
<SettingField
label="Email Notifications"
hint="Deliver notifications by email to accounts that opt in. Requires SMTP to be configured on the Email page."
type="toggle"
value={toggleValue("notifications.email_enabled")}
onChange={(v) => form.setValue("notifications.email_enabled", v)}
/>
<SettingField
label="Allow Per-Episode Email"
hint="Let users choose an email per episode instead of the daily digest. Off coerces those accounts to the digest."
type="toggle"
value={toggleValue("notifications.email.allow_per_episode")}
onChange={(v) => form.setValue("notifications.email.allow_per_episode", v)}
/>
<SettingField
label="Digest Hour"
hint="Hour of day (0-23, server time) when daily digest emails go out (default 8)"
type="number"
value={numberValue("notifications.email.digest_hour", "8")}
onChange={(v) => form.setValue("notifications.email.digest_hour", v)}
/>
<SettingField
label="External URL"
hint="Public base URL of this server (e.g. https://silo.example.com) used for links inside emails. Empty sends emails without links."
type="text"
value={form.getValue("notifications.email.external_url")}
onChange={(v) => form.setValue("notifications.email.external_url", v)}
/>
</FieldGroup>
<FieldGroup label="Discord">
<SettingField
label="Discord Bot"
hint="Let users link their Discord account and receive notifications as bot DMs. Off by default; while off, Discord notifications are hidden everywhere."
type="toggle"
value={form.getValue("notifications.discord_enabled") || "false"}
onChange={(v) => form.setValue("notifications.discord_enabled", v)}
/>
{discordEnabled && (
<>
<div className="text-muted-foreground space-y-1.5 py-2 text-xs leading-relaxed">
<p>Set up at discord.com/developers/applications:</p>
<ol className="list-decimal space-y-1.5 pl-4">
<li>Create an application (or open an existing one).</li>
<li>
OAuth2 page: copy the <strong>Client ID</strong>, reset and copy the{" "}
<strong>Client Secret</strong>, and under Redirects add
<code className="bg-muted mx-1 rounded px-1">
{"<public URL>"}/api/v1/notifications/discord/link/callback
</code>
using this server&apos;s public URL (SILO_PUBLIC_URL) — it must match exactly.
</li>
<li>
Bot page: reset and copy the <strong>Token</strong>. Leave all Privileged
Gateway Intents (Presence, Server Members, Message Content) <strong>off</strong>{" "}
— Silo never connects to the gateway; it only sends DMs.
</li>
<li>
Keep <strong>Requires OAuth2 Code Grant</strong> off, or the invite link below
won&apos;t work. Enable <strong>Public Bot</strong> only if someone other than
the application owner will be inviting it.
</li>
<li>
Paste the three credentials below, save, then use the invite button to add the
bot to your Discord server. It needs <strong>no role permissions</strong> —
membership alone lets it DM members, and users must share that server with the
bot to receive DMs.
</li>
</ol>
</div>
<SettingField
label="Allow Per-Episode DMs"
hint="Let users choose a DM per episode instead of the daily digest. Off coerces those accounts to the digest."
type="toggle"
value={toggleValue("notifications.discord.allow_per_episode")}
onChange={(v) => form.setValue("notifications.discord.allow_per_episode", v)}
/>
<SettingField
label="Digest Hour"
hint="Hour of day (0-23, server time) when daily digest DMs go out (default 8)"
type="number"
value={numberValue("notifications.discord.digest_hour", "8")}
onChange={(v) => form.setValue("notifications.discord.digest_hour", v)}
/>
<SettingField
label="Client ID"
hint="The Discord application's OAuth2 client ID (used for account linking)"
type="text"
value={form.getValue("discord.client_id")}
onChange={(v) => form.setValue("discord.client_id", v)}
/>
<InviteBotRow clientId={form.getValue("discord.client_id")} />
<SettingField
label="Client Secret"
hint="The Discord application's OAuth2 client secret"
type="password"
value={form.getValue("discord.client_secret")}
sensitiveConfigured={form.sensitiveConfigured.includes("discord.client_secret")}
onChange={(v) => form.setValue("discord.client_secret", v)}
/>
<SettingField
label="Bot Token"
hint="The bot user's token (used to send DMs)"
type="password"
value={form.getValue("discord.bot_token")}
sensitiveConfigured={form.sensitiveConfigured.includes("discord.bot_token")}
onChange={(v) => form.setValue("discord.bot_token", v)}
/>
<TestDiscordRow unsaved={discordCredentialsDirty} />
</>
)}
</FieldGroup>
<FieldGroup label="Retention">
<SettingField
label="Read Notifications (days)"
hint="How long read inbox entries are kept (default 90)"
type="number"
value={numberValue("notifications.retention.read_days", "90")}
onChange={(v) => form.setValue("notifications.retention.read_days", v)}
/>
<SettingField
label="Unread Notifications (days)"
hint="How long unread inbox entries are kept (default 180)"
type="number"
value={numberValue("notifications.retention.unread_days", "180")}
onChange={(v) => form.setValue("notifications.retention.unread_days", v)}
/>
<SettingField
label="Processed Events (days)"
hint="How long processed release events are kept for debugging (default 30)"
type="number"
value={numberValue("notifications.retention.event_days", "30")}
onChange={(v) => form.setValue("notifications.retention.event_days", v)}
/>
</FieldGroup>
</div>
<SaveBar
dirtyCount={form.dirtyCount}
onSave={form.save}
onDiscard={form.discard}
isSaving={form.isSaving}
restartRequired={form.restartRequired}
/>
</div>
);
}