thermograph/notifications/discord.py
Emi Griffith d17ac794fd Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module

Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.

Groundwork for moving modules into packages without re-pointing paths.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62

* Split the backend into domain packages

Group the flat backend modules into packages that mirror their concerns:
  data/          climate, grading, scoring, grid, places, cities,
                 city_events, store
  web/           app, views, homepage, content, schemas
  notifications/ notify, digest, push, mailer, discord,
                 discord_interactions, discord_link
  accounts/      models, users, api_accounts, db
  core/          metrics, singleton, audit

Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.

Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00

176 lines
6.6 KiB
Python

"""Discord delivery — currently just the daily "most unusual right now" post.
A once-daily message to a Discord channel via an **incoming webhook** (a single
secret URL; no bot, no gateway, no persistent process). It rides the notifier
daemon like the homepage feed refresh does, so it inherits the single-leader
invariant and costs no new infrastructure. Entirely optional: with no webhook
configured, everything here no-ops.
The message is built from the same `data/homepage.json` feed the homepage renders
(homepage.build/refresh), so the Discord post and the site always agree.
"""
from __future__ import annotations
import datetime
import os
import time
import httpx
from web 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
# Embed accent: warm when the top anomaly is hot, cool when it's cold.
_HOT = 0xE8663B
_COLD = 0x4393C3
_TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool:
return bool(WEBHOOK_URL)
def _f_c(value) -> str:
"""A Fahrenheit reading as '104°F (40°C)' — Discord's audience is global, so
the post carries both, even though the site picks one per city."""
if value is None:
return ""
f = round(value)
c = round((value - 32) * 5 / 9)
return f"{f}°F ({c}°C)"
def _line(card: dict) -> str:
"""One city's line in the post."""
display = card.get("display") or card.get("name") or ""
metric = (card.get("metric_label") or "").lower() or "reading"
val = _f_c(card.get("value"))
normal = _f_c(card.get("normal")) if card.get("normal") is not None else None
pct = card.get("pct_label") or ""
grade = card.get("grade_label") or card.get("grade") or ""
tag = " · ".join(p for p in (pct, grade) if p)
when = f" ({card['date_label']})" if card.get("date_label") else ""
normal_txt = f", normal {normal}" if normal else ""
return f"**{display}**: {metric} {val}{normal_txt}{when}" + (f" · {tag}" if tag else "")
def build_embed(feed: dict) -> dict:
"""A Discord rich embed for the day's most unusual cities, or raise ValueError
if the feed has nothing to show."""
ranked = (feed or {}).get("ranked") or []
if not ranked:
raise ValueError("empty feed")
top = ranked[:POST_LIMIT]
color = _COLD if top[0].get("tail") == "cold" else _HOT
date_s = feed.get("date") or datetime.date.today().isoformat()
description = "\n".join(_line(c) for c in top)
embed = {
"title": "🌡️ Most unusual weather right now",
"description": description,
"url": "https://thermograph.org/",
"color": color,
"footer": {"text": "thermograph.org · every day graded against ~45 years of local history"},
}
# ISO8601 timestamp from the feed's build time, when available.
gen = feed.get("generated_at")
if gen:
embed["timestamp"] = datetime.datetime.fromtimestamp(
float(gen), tz=datetime.timezone.utc
).isoformat()
else:
embed["footer"]["text"] += f" · {date_s}"
return embed
def post_daily_feed(feed: dict | None = None) -> bool:
"""Post the daily feed to the configured webhook. Best-effort: returns True on
a 2xx, False otherwise (disabled, empty/stale feed, or any HTTP error) and
never raises — a Discord failure must not disturb the notifier pass."""
if not enabled():
return False
if feed is None:
feed = homepage.load()
if not feed or homepage.is_stale(feed):
return False
try:
embed = build_embed(feed)
except ValueError:
return False
payload = {
"username": "Thermograph",
"avatar_url": "https://thermograph.org/logo.png?v=3",
"embeds": [embed],
}
try:
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
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