From 6726544b56fc968433c28274fee72a99d986d0c0 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 19 Jul 2026 17:48:22 -0700 Subject: [PATCH] Post the daily "most unusual right now" feed to Discord (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- discord.py | 113 ++++++++++++++++++++++++++++++++++ notify.py | 21 +++++++ tests/test_discord.py | 139 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 discord.py create mode 100644 tests/test_discord.py diff --git a/discord.py b/discord.py new file mode 100644 index 0000000..7e08ece --- /dev/null +++ b/discord.py @@ -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 diff --git a/notify.py b/notify.py index 78bdae1..001a8f2 100644 --- a/notify.py +++ b/notify.py @@ -30,6 +30,7 @@ import polars as pl from sqlalchemy import delete, select import climate +import discord import grading import grid import homepage @@ -312,6 +313,25 @@ def _maybe_refresh_homepage() -> None: 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(): # 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: @@ -326,6 +346,7 @@ def run_loop(): except Exception: # noqa: BLE001 - a bad pass must never kill the loop pass _maybe_refresh_homepage() + _maybe_post_discord() def start(): diff --git a/tests/test_discord.py b/tests/test_discord.py new file mode 100644 index 0000000..184ddde --- /dev/null +++ b/tests/test_discord.py @@ -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()