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
|
|
|
|
|
|
2026-07-21 16:09:35 +00:00
|
|
|
from api 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"
|
|
|
|
|
|
2026-07-22 19:07:46 +00:00
|
|
|
# Channel IDs the bot posts into (numeric snowflakes, from env; empty => that feed
|
|
|
|
|
# is off). SUBSCRIPTION is this deployment's dev/uat/prod test feed — it mirrors
|
|
|
|
|
# every notification the notifier creates so a run can be watched live. WEATHER is
|
|
|
|
|
# the prod "notable weather" channel the daily digest broadcasts into.
|
|
|
|
|
SUBSCRIPTION_CHANNEL_ID = os.environ.get("THERMOGRAPH_DISCORD_SUBSCRIPTION_CHANNEL", "").strip()
|
|
|
|
|
WEATHER_CHANNEL_ID = os.environ.get("THERMOGRAPH_DISCORD_WEATHER_CHANNEL", "").strip()
|
|
|
|
|
|
2026-07-20 04:04:45 +00:00
|
|
|
# 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)
|
|
|
|
|
|
2026-07-23 04:41:19 +00:00
|
|
|
# One shared client instead of a bare httpx.post per call — same reused-client
|
|
|
|
|
# pattern as web/app.py's _frontend_client, so every webhook/bot-REST call in
|
|
|
|
|
# this module reuses one connection pool instead of paying a fresh TCP+TLS
|
|
|
|
|
# handshake per notification.
|
|
|
|
|
_client = httpx.Client(timeout=_TIMEOUT)
|
|
|
|
|
|
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 enabled() -> bool:
|
2026-07-22 19:07:46 +00:00
|
|
|
return bool(WEBHOOK_URL) or weather_enabled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def weather_enabled() -> bool:
|
|
|
|
|
"""The daily digest has a bot channel (prod's notable-weather feed) to post into."""
|
|
|
|
|
return bool(BOT_TOKEN and WEATHER_CHANNEL_ID)
|
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 _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
|
2026-07-22 19:07:46 +00:00
|
|
|
ok = False
|
|
|
|
|
if WEBHOOK_URL:
|
|
|
|
|
payload = {
|
|
|
|
|
"username": "Thermograph",
|
|
|
|
|
"avatar_url": "https://thermograph.org/logo.png?v=3",
|
|
|
|
|
"embeds": [embed],
|
|
|
|
|
}
|
|
|
|
|
try:
|
2026-07-23 04:41:19 +00:00
|
|
|
resp = _client.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
|
2026-07-22 19:07:46 +00:00
|
|
|
ok = 200 <= resp.status_code < 300
|
|
|
|
|
except Exception: # noqa: BLE001 - best-effort side channel
|
|
|
|
|
pass
|
|
|
|
|
if weather_enabled():
|
|
|
|
|
# Broadcast the same digest into the prod notable-weather channel via the
|
|
|
|
|
# bot — independent of the webhook, so either destination can run alone.
|
|
|
|
|
ok = _post_channel_embed(WEATHER_CHANNEL_ID, embed) or ok
|
|
|
|
|
return ok
|
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:
|
2026-07-23 04:41:19 +00:00
|
|
|
resp = _client.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT)
|
2026-07-20 04:04:45 +00:00
|
|
|
if resp.status_code == 429:
|
|
|
|
|
time.sleep(_retry_after(resp))
|
2026-07-23 04:41:19 +00:00
|
|
|
resp = _client.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT)
|
2026-07-20 04:04:45 +00:00
|
|
|
return resp
|
|
|
|
|
except Exception: # noqa: BLE001 - best-effort side channel
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 19:07:46 +00:00
|
|
|
def _alert_embed(title: str, body: str | None, path: str, footer: str) -> dict:
|
|
|
|
|
"""A single-alert embed (DM or channel): title, body text, a deep link back to
|
|
|
|
|
the site, warm accent, and a channel-appropriate footer."""
|
|
|
|
|
return {
|
|
|
|
|
"title": title,
|
|
|
|
|
"description": body or "",
|
|
|
|
|
"url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL,
|
|
|
|
|
"color": _HOT,
|
|
|
|
|
"footer": {"text": footer},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _post_channel_embed(channel_id: str, embed: dict) -> bool:
|
|
|
|
|
"""POST an embed into a channel by id via the bot. False on any failure."""
|
|
|
|
|
msg = _bot_post(f"/channels/{channel_id}/messages", {"embeds": [embed]})
|
|
|
|
|
return msg is not None and 200 <= msg.status_code < 300
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def subscription_enabled() -> bool:
|
|
|
|
|
"""The notifier has a bot channel (this env's dev/uat/prod test feed) to post into."""
|
|
|
|
|
return bool(BOT_TOKEN and SUBSCRIPTION_CHANNEL_ID)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def post_subscription_alert(title: str, body: str | None, path: str = "") -> bool:
|
|
|
|
|
"""Mirror one just-created notification into this deployment's subscription
|
|
|
|
|
channel (dev/uat/prod), so a notifier run can be watched live. Best-effort:
|
|
|
|
|
returns True on a 2xx, False otherwise (unconfigured or any HTTP error), and
|
|
|
|
|
never raises — the in-app row and per-user DM are unaffected either way."""
|
|
|
|
|
if not subscription_enabled():
|
|
|
|
|
return False
|
|
|
|
|
embed = _alert_embed(title, body, path, "thermograph.org · subscription notifier")
|
|
|
|
|
return _post_channel_embed(SUBSCRIPTION_CHANNEL_ID, embed)
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 04:04:45 +00:00
|
|
|
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
|
2026-07-22 19:07:46 +00:00
|
|
|
embed = _alert_embed(
|
|
|
|
|
title, body, path,
|
|
|
|
|
"thermograph.org · mute Discord alerts anytime in your account",
|
|
|
|
|
)
|
|
|
|
|
return _post_channel_embed(channel_id, embed)
|