Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
"""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
|
2026-07-20 04:04:45 +00:00
|
|
|
import time
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
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
|
|
|
from web import homepage
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
|
|
|
|
|
# The webhook URL IS the credential — env only, never the repo. Unset => disabled.
|
|
|
|
|
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
|
|
|
|
|
|
2026-07-20 04:04:45 +00:00
|
|
|
# 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
|
|
|
|
|
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
# 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 ""
|
Remove em-dashes from site copy and tighten the prose (#206)
Replace em-dashes in user-facing copy across the server-rendered pages,
static frontend views, and the strings the app injects at runtime, using
colons, commas, parentheses or full stops as the context wants. Data
placeholder glyphs (a lone "—" for a missing reading) are left alone,
since a hyphen there reads as a minus sign in temperature columns.
Also tighten the high-visibility surfaces (home hero and meta, about,
privacy, city and records ledes, glossary blurbs) toward a plainer,
more direct voice while keeping every factual claim intact.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 01:48:33 +00:00
|
|
|
return f"**{display}**: {metric} {val}{normal_txt}{when}" + (f" · {tag}" if tag else "")
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-20 04:04:45 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|