From 024613c06d749bdd58b6e33f7eb0f71c9ccf5da7 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 21:04:45 -0700 Subject: [PATCH] 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 --- discord.py | 63 +++++++++++++++ discord_link.py | 26 ++++++- models.py | 4 + notify.py | 20 ++++- schemas.py | 1 + tests/test_discord_dm.py | 161 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 tests/test_discord_dm.py diff --git a/discord.py b/discord.py index 3911fd8..2fd86a5 100644 --- a/discord.py +++ b/discord.py @@ -13,6 +13,7 @@ from __future__ import annotations import datetime import os +import time import httpx @@ -21,6 +22,15 @@ import homepage # The webhook URL IS the credential — env only, never the repo. Unset => disabled. 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. POST_LIMIT = 8 @@ -111,3 +121,56 @@ def post_daily_feed(feed: dict | None = None) -> bool: return 200 <= resp.status_code < 300 except Exception: # noqa: BLE001 - best-effort side channel 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 diff --git a/discord_link.py b/discord_link.py index 2177727..236ce17 100644 --- a/discord_link.py +++ b/discord_link.py @@ -25,6 +25,7 @@ import urllib.parse import httpx from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse +from pydantic import BaseModel from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession @@ -150,8 +151,10 @@ async def link_callback( return _account_redirect("error") if not discord_id: 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( - 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() return _account_redirect("linked") @@ -163,6 +166,25 @@ async def unlink( session: AsyncSession = Depends(get_async_session), ): 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() diff --git a/models.py b/models.py index 2fb4cde..d993916 100644 --- a/models.py +++ b/models.py @@ -36,6 +36,10 @@ class User(SQLAlchemyBaseUserTableUUID, Base): # 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). 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): diff --git a/notify.py b/notify.py index 58e5ae7..dcd7610 100644 --- a/notify.py +++ b/notify.py @@ -37,7 +37,7 @@ import homepage import metrics import push 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 _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 +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 ----------------------------------------------------- def _obs_from_row(row) -> dict: 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) except Exception: # noqa: BLE001 - push failures stay isolated from the DB write 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() return created diff --git a/schemas.py b/schemas.py index dae5ac4..ebc0247 100644 --- a/schemas.py +++ b/schemas.py @@ -23,6 +23,7 @@ class UserRead(schemas.BaseUser[uuid.UUID]): # Present so the frontend can show linked/unlinked state; set via the OAuth # flow (discord_link.py), not editable through the profile. discord_id: str | None = None + discord_dm: bool = False class UserCreate(schemas.BaseUserCreate): diff --git a/tests/test_discord_dm.py b/tests/test_discord_dm.py new file mode 100644 index 0000000..d85e36d --- /dev/null +++ b/tests/test_discord_dm.py @@ -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