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:
parent
3bd41a41ff
commit
024613c06d
6 changed files with 272 additions and 3 deletions
63
discord.py
63
discord.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
20
notify.py
20
notify.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
161
tests/test_discord_dm.py
Normal 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
|
||||||
Loading…
Reference in a new issue