thermograph/discord_link.py
Emi Griffith 024613c06d 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
2026-07-20 04:04:45 +00:00

190 lines
6.8 KiB
Python

"""Link a Thermograph account to a Discord account via OAuth2 (identify scope).
The Discord user id we store here is the durable key a later feature (DM alerts)
uses to reach the person. The flow is the standard authorization-code grant:
/link/start -> redirect the logged-in user to Discord's consent screen
/link/callback -> exchange the code, read the Discord user id, store it
/unlink -> forget it
`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
carries the Thermograph user id, so the callback can't be replayed or bound to a
different account. Everything requires an active session, so linking always acts on
the person who is actually logged in.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
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
from db import get_async_session
from models import User
from users import SECRET, current_active_user
router = APIRouter(tags=["discord"])
CLIENT_ID = os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip()
CLIENT_SECRET = os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()
BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/")
_AUTHORIZE = "https://discord.com/api/oauth2/authorize"
_TOKEN = "https://discord.com/api/oauth2/token"
_ME = "https://discord.com/api/users/@me"
# How long a start->callback round trip may take before the signed state expires.
_STATE_TTL = 600
_TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool:
return bool(CLIENT_ID and CLIENT_SECRET)
# --- signed state ------------------------------------------------------------
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _unb64(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def _sign_state(uid: str) -> str:
payload = _b64(json.dumps({"u": uid, "t": int(time.time())}).encode())
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
return f"{payload}.{mac}"
def _verify_state(state: str) -> str | None:
"""The Thermograph user id the state was minted for, or None if it's missing,
tampered, or older than the TTL."""
try:
payload, mac = state.split(".", 1)
except (ValueError, AttributeError):
return None
expect = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
if not hmac.compare_digest(mac, expect):
return None
try:
data = json.loads(_unb64(payload))
except (ValueError, json.JSONDecodeError):
return None
if time.time() - float(data.get("t", 0)) > _STATE_TTL:
return None
return data.get("u")
def _redirect_uri(request: Request) -> str:
"""The callback URL, matched to how the app is actually reached (scheme/host +
base path). Must equal the redirect registered in the Discord portal."""
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}{BASE}/api/v2/discord/link/callback"
def _account_redirect(status: str) -> RedirectResponse:
# Back to the alerts page, which surfaces the link state; 303 so the browser
# issues a GET after the callback.
return RedirectResponse(url=f"{BASE}/subscriptions?discord={status}", status_code=303)
# --- routes ------------------------------------------------------------------
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
if not enabled():
return _account_redirect("unavailable")
params = {
"client_id": CLIENT_ID,
"redirect_uri": _redirect_uri(request),
"response_type": "code",
"scope": "identify",
"state": _sign_state(str(user.id)),
"prompt": "consent",
}
return RedirectResponse(url=f"{_AUTHORIZE}?{urllib.parse.urlencode(params)}", status_code=303)
@router.get("/discord/link/callback")
async def link_callback(
request: Request,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
if not enabled():
return _account_redirect("unavailable")
# User declined on Discord's screen, or the state doesn't check out.
code = request.query_params.get("code")
state = request.query_params.get("state", "")
if not code:
return _account_redirect("cancelled")
if _verify_state(state) != str(user.id):
return _account_redirect("error")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
tok = await client.post(_TOKEN, data={
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(request),
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
if tok.status_code >= 300:
return _account_redirect("error")
access = tok.json().get("access_token")
me = await client.get(_ME, headers={"Authorization": f"Bearer {access}"})
if me.status_code >= 300:
return _account_redirect("error")
discord_id = str(me.json().get("id") or "")
except Exception: # noqa: BLE001 - any network/parse failure is a graceful error
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, discord_dm=True)
)
await session.commit()
return _account_redirect("linked")
@router.post("/discord/unlink", status_code=204)
async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
await session.execute(
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()