Deliver unusual-weather alerts as Discord DMs for linked users (#210)

Adds Discord DM as a notification channel beside web push, for users who linked
their Discord account and opted in. It rides the same fan-out point as push
(_dispatch_discord next to _dispatch_push in the notifier pass), reusing the
transport-agnostic title/body/deep-link, and stays best-effort and isolated: the
in-app row is already committed, and push/email remain the fallback for anyone
Discord can't reach (its DM rule requires a shared server / user install).

- discord.py: send_dm() opens the DM channel then posts an embed via the bot token,
  with one capped 429 retry. Absolute deep links (a DM can't resolve a relative
  path the way the service worker can).
- notify.py: _dispatch_discord() delivers only when the subscriber has a linked
  discord_id and discord_dm on; no-ops entirely when no bot token is configured.
- models.py: User.discord_dm (opt-in flag) + migration 002. Linking sets it True
  (an active opt-in); a new POST /discord/dm mutes it without unlinking, and unlink
  clears it. Exposed on UserRead; account popover shows an on/off toggle.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
This commit is contained in:
Emi Griffith 2026-07-19 21:04:45 -07:00 committed by GitHub
parent 169678e1bc
commit 9a2764ae33

View file

@ -302,7 +302,8 @@ function renderHeader() {
<div class="acct-pop" hidden>
<a href="${APP_BASE}/alerts" class="acct-pop-link">My alerts</a>
${currentUser.discord_id
? '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>'
? `<button type="button" class="acct-pop-link acct-discord-dm">${currentUser.discord_dm ? "Discord alerts: on" : "Discord alerts: off"}</button>`
+ '<button type="button" class="acct-pop-link acct-discord-unlink">Unlink Discord</button>'
: `<a href="${APP_BASE}/api/v2/discord/link/start" class="acct-pop-link">Link Discord</a>`}
<button type="button" class="acct-pop-link acct-signout">Sign out</button>
</div>
@ -338,6 +339,15 @@ function renderHeader() {
await refreshUser();
emitAuth(); // repaint the popover in its unlinked state
});
const dmBtn = el.querySelector(".acct-discord-dm");
if (dmBtn) dmBtn.addEventListener("click", async () => {
dmBtn.disabled = true;
try {
await apiFetch("api/v2/discord/dm", { method: "POST", json: { enabled: !currentUser.discord_dm } });
} catch (e) {}
await refreshUser();
emitAuth(); // repaint with the new on/off label
});
paintBadge();
paintNotifList();
startNotifPolling();