114 lines
4 KiB
Python
114 lines
4 KiB
Python
|
|
"""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
|