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 3bd41a41ff
commit 024613c06d
6 changed files with 272 additions and 3 deletions

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import datetime import datetime
import os import os
import time
import httpx import httpx
@ -21,6 +22,15 @@ import homepage
# The webhook URL IS the credential — env only, never the repo. Unset => disabled. # The webhook URL IS the credential — env only, never the repo. Unset => disabled.
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip() WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
# Bot token (a credential) for sending DMs; public site URL for absolute deep links
# in a DM (a webhook/DM can't resolve a relative path the way the SW-backed push can).
BOT_TOKEN = os.environ.get("THERMOGRAPH_DISCORD_BOT_TOKEN", "").strip()
PUBLIC_URL = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
_API = "https://discord.com/api/v10"
# One 429 retry, capped so a rate-limit can't stall the notifier pass for long.
_MAX_BACKOFF_S = 5.0
# How many cities the daily post lists. # How many cities the daily post lists.
POST_LIMIT = 8 POST_LIMIT = 8
@ -111,3 +121,56 @@ def post_daily_feed(feed: dict | None = None) -> bool:
return 200 <= resp.status_code < 300 return 200 <= resp.status_code < 300
except Exception: # noqa: BLE001 - best-effort side channel except Exception: # noqa: BLE001 - best-effort side channel
return False return False
# --- direct messages ---------------------------------------------------------
def dm_enabled() -> bool:
return bool(BOT_TOKEN)
def _retry_after(resp) -> float:
"""Seconds to wait after a 429, capped. Reads the JSON body Discord returns."""
try:
return min(float(resp.json().get("retry_after", 1.0)), _MAX_BACKOFF_S)
except Exception: # noqa: BLE001
return 1.0
def _bot_post(path: str, json_body: dict) -> httpx.Response | None:
"""POST to the bot REST API with one 429 retry. None on a transport error."""
headers = {"Authorization": f"Bot {BOT_TOKEN}"}
try:
resp = httpx.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT)
if resp.status_code == 429:
time.sleep(_retry_after(resp))
resp = httpx.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT)
return resp
except Exception: # noqa: BLE001 - best-effort side channel
return None
def send_dm(discord_id: str, title: str, body: str | None, path: str = "") -> bool:
"""DM one linked user an alert. Opens (or reuses) the DM channel, then posts an
embed. Best-effort: returns True on success, False on anything else, never
raises. Discord only delivers to users who share the server / installed the app;
an undeliverable DM just returns False and the user keeps their other channels."""
if not BOT_TOKEN or not discord_id:
return False
chan = _bot_post("/users/@me/channels", {"recipient_id": str(discord_id)})
if chan is None or chan.status_code >= 300:
return False
try:
channel_id = chan.json().get("id")
except Exception: # noqa: BLE001
return False
if not channel_id:
return False
embed = {
"title": title,
"description": body or "",
"url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL,
"color": _HOT,
"footer": {"text": "thermograph.org · mute Discord alerts anytime in your account"},
}
msg = _bot_post(f"/channels/{channel_id}/messages", {"embeds": [embed]})
return msg is not None and 200 <= msg.status_code < 300

View file

@ -25,6 +25,7 @@ import urllib.parse
import httpx import httpx
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import update from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -150,8 +151,10 @@ async def link_callback(
return _account_redirect("error") return _account_redirect("error")
if not discord_id: if not discord_id:
return _account_redirect("error") return _account_redirect("error")
# Linking is an active opt-in, so turn DM alerts on; the user can mute them
# (POST /discord/dm) while staying linked.
await session.execute( await session.execute(
update(User).where(User.id == user.id).values(discord_id=discord_id) update(User).where(User.id == user.id).values(discord_id=discord_id, discord_dm=True)
) )
await session.commit() await session.commit()
return _account_redirect("linked") return _account_redirect("linked")
@ -163,6 +166,25 @@ async def unlink(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
await session.execute( await session.execute(
update(User).where(User.id == user.id).values(discord_id=None) update(User).where(User.id == user.id).values(discord_id=None, discord_dm=False)
)
await session.commit()
class DmToggle(BaseModel):
enabled: bool
@router.post("/discord/dm", status_code=204)
async def set_dm(
payload: DmToggle,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Turn Discord DM alerts on or off without unlinking. A no-op unless a Discord
account is actually linked."""
await session.execute(
update(User).where(User.id == user.id, User.discord_id.is_not(None))
.values(discord_dm=payload.enabled)
) )
await session.commit() await session.commit()

View file

@ -36,6 +36,10 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# by. NB: create_all only makes this on a fresh DB; an existing prod accounts.db # by. NB: create_all only makes this on a fresh DB; an existing prod accounts.db
# needs the manual migration in deploy/migrations/ (no Alembic in this project). # needs the manual migration in deploy/migrations/ (no Alembic in this project).
discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True) discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True)
# Whether to also deliver alerts as a Discord DM. Set True on linking (an active
# opt-in); the user can mute it while staying linked. Same migration caveat.
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
server_default="0")
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base): class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):

View file

@ -37,7 +37,7 @@ import homepage
import metrics import metrics
import push import push
from db import sync_session_maker from db import sync_session_maker
from models import AccessToken, Notification, PushSubscription, Subscription from models import AccessToken, Notification, PushSubscription, Subscription, User
from views import OBS_COLS from views import OBS_COLS
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
@ -136,6 +136,19 @@ def _dispatch_push(session, sub, title, body, event_date) -> None:
session.delete(row) # committed with the rest of the pass session.delete(row) # committed with the rest of the pass
def _dispatch_discord(session, sub, title, body, event_date) -> None:
"""Best-effort Discord DM of a just-created notification, when the subscriber
has linked Discord and opted in. Isolated like the push side-channel the
in-app row is already committed, and push/email remain the fallback for anyone
not reachable on Discord. Runs in the notifier thread (no event loop)."""
if not discord.dm_enabled():
return
user = session.get(User, sub.user_id)
if not user or not user.discord_id or not user.discord_dm:
return
discord.send_dm(user.discord_id, title, body, _deep_link(sub, event_date))
# --- per-cell evaluation ----------------------------------------------------- # --- per-cell evaluation -----------------------------------------------------
def _obs_from_row(row) -> dict: def _obs_from_row(row) -> dict:
return {k: row[k] for k in OBS_COLS if k in row} return {k: row[k] for k in OBS_COLS if k in row}
@ -252,6 +265,11 @@ def _process_cell(session, cell_id, subs, today, now, archive_budget):
_dispatch_push(session, sub, title, body, event_date) _dispatch_push(session, sub, title, body, event_date)
except Exception: # noqa: BLE001 - push failures stay isolated from the DB write except Exception: # noqa: BLE001 - push failures stay isolated from the DB write
pass pass
# And, independently, a Discord DM for linked opted-in users.
try:
_dispatch_discord(session, sub, title, body, event_date)
except Exception: # noqa: BLE001 - Discord failures stay isolated too
pass
session.commit() session.commit()
return created return created

View file

@ -23,6 +23,7 @@ class UserRead(schemas.BaseUser[uuid.UUID]):
# Present so the frontend can show linked/unlinked state; set via the OAuth # Present so the frontend can show linked/unlinked state; set via the OAuth
# flow (discord_link.py), not editable through the profile. # flow (discord_link.py), not editable through the profile.
discord_id: str | None = None discord_id: str | None = None
discord_dm: bool = False
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):

161
tests/test_discord_dm.py Normal file
View file

@ -0,0 +1,161 @@
"""Discord DM alerts: send_dm REST mechanics (mocked), the notify dispatch gate,
the on/off toggle endpoint, and that linking opts in."""
import types
import pytest
from fastapi.testclient import TestClient
import app as appmod
import db
import discord
import discord_link as dl
import notify
V2 = "/thermograph/api/v2"
PW = "supersecret123"
@pytest.fixture(scope="module", autouse=True)
def _tables():
db.Base.metadata.create_all(db.sync_engine)
# --- send_dm REST mechanics --------------------------------------------------
class _Resp:
def __init__(self, code, data=None): self.status_code = code; self._data = data or {}
def json(self): return self._data
def _mock_posts(monkeypatch, script):
"""Route discord.httpx.post by URL; `script` maps a URL substring -> _Resp."""
calls = []
def _post(url, json=None, headers=None, timeout=None):
calls.append((url, json))
for frag, resp in script.items():
if frag in url:
return resp
return _Resp(404)
monkeypatch.setattr(discord.httpx, "post", _post)
return calls
def test_send_dm_opens_channel_then_posts(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {
"/users/@me/channels": _Resp(200, {"id": "chan-1"}),
"/channels/chan-1/messages": _Resp(204),
})
ok = discord.send_dm("discord-9", "Tehran hit a Near Record high", "104F, 99th pct", "/day#x")
assert ok is True
assert calls[0][0].endswith("/users/@me/channels") and calls[0][1] == {"recipient_id": "discord-9"}
assert "/channels/chan-1/messages" in calls[1][0]
embed = calls[1][1]["embeds"][0]
assert embed["title"].startswith("Tehran") and embed["url"].endswith("/day#x")
def test_send_dm_disabled_or_no_recipient(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "")
assert discord.send_dm("d", "t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot")
assert discord.send_dm("", "t", "b") is False
def test_send_dm_gives_up_if_channel_open_fails(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
calls = _mock_posts(monkeypatch, {"/users/@me/channels": _Resp(403)})
assert discord.send_dm("d", "t", "b") is False
assert len(calls) == 1 # never tried to post a message
def test_bot_post_retries_once_on_429(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait
seq = iter([_Resp(429, {"retry_after": 0.01}), _Resp(200, {"id": "c"})])
monkeypatch.setattr(discord.httpx, "post", lambda *a, **k: next(seq))
resp = discord._bot_post("/users/@me/channels", {"recipient_id": "d"})
assert resp.status_code == 200 # the retry succeeded
# --- notify dispatch gate ----------------------------------------------------
def _fake_session(user):
return types.SimpleNamespace(get=lambda model, uid: user)
def _sub():
return types.SimpleNamespace(user_id="u1", lat=47.6, lon=-122.3)
def test_dispatch_sends_for_linked_opted_in_user(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
user = types.SimpleNamespace(discord_id="d9", discord_dm=True)
notify._dispatch_discord(_fake_session(user), _sub(), "T", "B", "2026-07-19")
assert len(sent) == 1 and sent[0][0] == "d9"
def test_dispatch_skips_unlinked_or_opted_out(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: True)
sent = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: sent.append(a) or True)
# linked but opted out
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=False)),
_sub(), "T", "B", "2026-07-19")
# not linked
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id=None, discord_dm=True)),
_sub(), "T", "B", "2026-07-19")
assert sent == []
def test_dispatch_noop_when_bot_unconfigured(monkeypatch):
monkeypatch.setattr(discord, "dm_enabled", lambda: False)
called = []
monkeypatch.setattr(discord, "send_dm", lambda *a, **k: called.append(1))
notify._dispatch_discord(_fake_session(types.SimpleNamespace(discord_id="d", discord_dm=True)),
_sub(), "T", "B", "2026-07-19")
assert called == [] # never even looked the user up beyond the gate
# --- the on/off toggle + link opt-in -----------------------------------------
def _login(client, email):
r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW})
assert r.status_code in (201, 400)
assert client.post(f"{V2}/auth/login", data={"username": email, "password": PW}).status_code == 204
class _MockClient:
def __init__(self, *a, **k): pass
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def post(self, url, **k): return _Resp(200, {"access_token": "tok"})
async def get(self, url, **k): return _Resp(200, {"id": "discord-777"})
def test_linking_opts_in_and_toggle_flips(monkeypatch):
monkeypatch.setattr(dl, "CLIENT_ID", "app")
monkeypatch.setattr(dl, "CLIENT_SECRET", "sec")
monkeypatch.setattr(dl.httpx, "AsyncClient", _MockClient)
c = TestClient(appmod.app)
_login(c, "dm-toggle@example.com")
uid = c.get(f"{V2}/users/me").json()["id"]
# Link -> opted in by default.
r = c.get(f"{V2}/discord/link/callback?code=x&state={dl._sign_state(uid)}", follow_redirects=False)
assert r.status_code == 303
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is True
# Mute without unlinking.
assert c.post(f"{V2}/discord/dm", json={"enabled": False}).status_code == 204
me = c.get(f"{V2}/users/me").json()
assert me["discord_id"] == "discord-777" and me["discord_dm"] is False
# Turn back on.
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 204
assert c.get(f"{V2}/users/me").json()["discord_dm"] is True
def test_dm_toggle_requires_auth():
c = TestClient(appmod.app)
assert c.post(f"{V2}/discord/dm", json={"enabled": True}).status_code == 401