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
This commit is contained in:
Emi Griffith 2026-07-19 17:48:22 -07:00 committed by GitHub
parent d4bcf01612
commit 6726544b56
3 changed files with 273 additions and 0 deletions

113
discord.py Normal file
View file

@ -0,0 +1,113 @@
"""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 httpx
import homepage
# The webhook URL IS the credential — env only, never the repo. Unset => disabled.
WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip()
# 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

View file

@ -30,6 +30,7 @@ import polars as pl
from sqlalchemy import delete, select from sqlalchemy import delete, select
import climate import climate
import discord
import grading import grading
import grid import grid
import homepage import homepage
@ -312,6 +313,25 @@ def _maybe_refresh_homepage() -> None:
pass pass
# The daily "most unusual right now" post to Discord rides this loop too: once per
# day, after the feed is refreshed, leader-only like everything in run_loop. No
# webhook configured => no-op.
_last_discord_post_day: str | None = None
def _maybe_post_discord() -> None:
global _last_discord_post_day
if not discord.enabled():
return
today = datetime.date.today().isoformat()
if _last_discord_post_day == today:
return
# Only record the day as done once a post actually lands, so a transient
# failure retries on the next wake instead of silently skipping the day.
if discord.post_daily_feed():
_last_discord_post_day = today
def run_loop(): def run_loop():
# Beat once at startup so the dashboard shows the notifier alive immediately after a # Beat once at startup so the dashboard shows the notifier alive immediately after a
# (re)start, without waiting a full INTERVAL for the first pass. Then beat every wake: # (re)start, without waiting a full INTERVAL for the first pass. Then beat every wake:
@ -326,6 +346,7 @@ def run_loop():
except Exception: # noqa: BLE001 - a bad pass must never kill the loop except Exception: # noqa: BLE001 - a bad pass must never kill the loop
pass pass
_maybe_refresh_homepage() _maybe_refresh_homepage()
_maybe_post_discord()
def start(): def start():

139
tests/test_discord.py Normal file
View file

@ -0,0 +1,139 @@
"""Discord daily-feed post: embed shape, webhook delivery, and the once-a-day guard.
No network httpx.post is stubbed, like the IndexNow tests."""
import datetime
import time
import pytest
import discord
import notify
def _feed(ranked, generated_at=None, date=None):
# Fresh by default so it passes homepage.is_stale(); tests that assert on the
# embed timestamp pin generated_at explicitly.
return {
"generated_at": generated_at if generated_at is not None else time.time(),
"date": date or datetime.date.today().isoformat(),
"ranked": ranked,
}
_CARD_HOT = {
"display": "Tehran, Iran", "name": "Tehran", "metric": "tmax",
"metric_label": "High temp", "value": 104.2, "normal": 96.8,
"percentile": 99.0, "pct_label": "99th percentile", "grade": "Near Record",
"grade_label": "Near Record hot", "tail": "warm", "date_label": None,
}
_CARD_COLD = {
"display": "Oslo, Norway", "name": "Oslo", "metric": "tmin",
"metric_label": "Low temp", "value": 5.0, "normal": 20.0,
"percentile": 3.0, "pct_label": "3rd percentile", "grade": "Near Record",
"grade_label": "Near Record cold", "tail": "cold", "date_label": "Jul 18",
}
def test_build_embed_has_numbers_and_cities():
embed = discord.build_embed(_feed([_CARD_HOT, _CARD_COLD], generated_at=1_700_000_000.0))
assert "unusual weather" in embed["title"].lower()
desc = embed["description"]
assert "Tehran, Iran" in desc and "Oslo, Norway" in desc
# Real readings, in both units, with the normal for context.
assert "104°F (40°C)" in desc
assert "normal 97°F" in desc
# The percentile + grade tag rides along.
assert "99th percentile" in desc and "Near Record hot" in desc
# A non-today card is dated so it can't pass for "right now".
assert "(Jul 18)" in desc
# Warm top card => warm accent; timestamp from the feed's build time.
assert embed["color"] == discord._HOT
assert embed["timestamp"].startswith("2023-11-14")
def test_embed_accent_follows_the_top_cards_tail():
assert discord.build_embed(_feed([_CARD_COLD]))["color"] == discord._COLD
assert discord.build_embed(_feed([_CARD_HOT]))["color"] == discord._HOT
def test_build_embed_rejects_empty_feed():
with pytest.raises(ValueError):
discord.build_embed(_feed([]))
def test_post_disabled_without_webhook(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "")
posted = []
monkeypatch.setattr(discord.httpx, "post", lambda *a, **k: posted.append(a) or None)
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
assert posted == [] # never touched the network
class _Resp:
def __init__(self, code): self.status_code = code
def test_post_sends_embed_to_the_webhook(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
calls = []
monkeypatch.setattr(discord.httpx, "post",
lambda url, json=None, **k: (calls.append((url, json)), _Resp(204))[1])
assert discord.post_daily_feed(_feed([_CARD_HOT])) is True
(url, payload), = calls
assert url == "https://discord.test/webhook/abc"
assert payload["username"] == "Thermograph"
assert payload["embeds"][0]["description"].startswith("**Tehran, Iran**")
def test_post_is_best_effort_on_http_error(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
def _boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(discord.httpx, "post", _boom)
# Never raises; just reports failure.
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
def test_post_skips_stale_or_empty_feed(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
monkeypatch.setattr(discord.httpx, "post", lambda *a, **k: _Resp(204))
# Yesterday's feed is stale -> no post.
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
assert discord.post_daily_feed(_feed([_CARD_HOT], date=yesterday)) is False
# Empty ranked -> no post.
assert discord.post_daily_feed(_feed([])) is False
def test_daily_guard_posts_once_per_day(monkeypatch):
monkeypatch.setattr(notify, "_last_discord_post_day", None)
monkeypatch.setattr(discord, "enabled", lambda: True)
posts = []
monkeypatch.setattr(discord, "post_daily_feed", lambda: posts.append(1) or True)
day1 = datetime.date(2026, 7, 19)
day2 = datetime.date(2026, 7, 20)
class _D(datetime.date):
_t = day1
@classmethod
def today(cls): return cls._t
monkeypatch.setattr(notify.datetime, "date", _D)
notify._maybe_post_discord()
notify._maybe_post_discord() # same day -> still one post
assert posts == [1]
_D._t = day2
notify._maybe_post_discord() # new day -> a second post
assert posts == [1, 1]
def test_daily_guard_retries_after_a_failed_post(monkeypatch):
monkeypatch.setattr(notify, "_last_discord_post_day", None)
monkeypatch.setattr(discord, "enabled", lambda: True)
outcomes = iter([False, True])
monkeypatch.setattr(discord, "post_daily_feed", lambda: next(outcomes))
# First call fails -> day not marked done -> second call retries and succeeds.
notify._maybe_post_discord()
notify._maybe_post_discord()
assert notify._last_discord_post_day == datetime.date.today().isoformat()