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>
This commit is contained in:
Quick
2026-06-11 19:51:03 -04:00
co-authored by Claude Fable 5
parent 27b9006ef5
commit 5f05374a1d
25 changed files with 2496 additions and 406 deletions
+29 -2
View File
@@ -2327,22 +2327,49 @@ export interface NotificationWebhookTestResult {
message?: string;
}
/** An account-level digest channel (email, Discord DMs). */
export interface NotificationAccountChannelCapability {
available: boolean;
modes: string[];
digest_hour: number;
}
export interface NotificationCapability {
in_app: { enabled: boolean };
apple_push: { available: boolean; provider: string; supported_modes: string[] };
android_push: { available: boolean; provider: string; supported_modes: string[] };
web_push: { available: boolean; public_key?: string };
webhooks: { available: boolean; max_per_profile: number; supported_types: string[] };
email: { available: boolean; modes: string[]; digest_hour: number };
email: NotificationAccountChannelCapability;
discord: NotificationAccountChannelCapability;
}
export type NotificationEmailMode = "off" | "per_episode" | "daily_digest";
export type NotificationChannelMode =
| "off"
| "per_episode"
| "daily_digest"
| "per_episode_and_digest";
export type NotificationEmailMode = NotificationChannelMode;
export type NotificationDiscordMode = NotificationChannelMode;
/** Account-level (not per-profile): one mode covers all profiles. */
export interface NotificationEmailPreferences {
mode: NotificationEmailMode;
}
/** Account-level Discord DM channel: link state, mode, and delivery health. */
export interface NotificationDiscordPreferences {
linked: boolean;
discord_username?: string;
mode: NotificationDiscordMode;
/** Last DM delivery failure, surfaced as link health. Empty when healthy. */
link_failure?: string;
}
export interface NotificationDiscordLinkInit {
url: string;
}
export interface WebPushSubscriptionView {
id: string;
endpoint: string;