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:
parent
d4bcf01612
commit
6726544b56
3 changed files with 273 additions and 0 deletions
113
discord.py
Normal file
113
discord.py
Normal 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
|
||||||
21
notify.py
21
notify.py
|
|
@ -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
139
tests/test_discord.py
Normal 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()
|
||||||
Loading…
Reference in a new issue