"""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 time import httpx from api import homepage # The webhook URL IS the credential — env only, never the repo. Unset => disabled. WEBHOOK_URL = os.environ.get("THERMOGRAPH_DISCORD_WEBHOOK", "").strip() # 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" # One 429 retry, capped so a rate-limit can't stall the notifier pass for long. _MAX_BACKOFF_S = 5.0 # 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 # --- 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: resp = httpx.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT) if resp.status_code == 429: time.sleep(_retry_after(resp)) resp = httpx.post(f"{_API}{path}", json=json_body, headers=headers, timeout=_TIMEOUT) return resp except Exception: # noqa: BLE001 - best-effort side channel return None 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 embed = { "title": title, "description": body or "", "url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL, "color": _HOT, "footer": {"text": "thermograph.org · mute Discord alerts anytime in your account"}, } msg = _bot_post(f"/channels/{channel_id}/messages", {"embeds": [embed]}) return msg is not None and 200 <= msg.status_code < 300