"""Discord slash commands over the HTTP Interactions endpoint. Discord POSTs each interaction (a slash command, a button, etc.) to a URL we register in the developer portal, so commands are just an ordinary FastAPI route (app.py wires POST {BASE}/discord/interactions) with no gateway connection and no persistent bot process. Every request is signed; we verify the Ed25519 signature over the raw body before trusting anything (Discord actively probes this with bad signatures and disables the endpoint if we don't reject them). Commands so far: /grade -> today's grade for a curated city, from the warm cache. Register the command definitions with scripts/register_discord_commands.py. """ from __future__ import annotations import datetime import json import os from nacl.exceptions import BadSignatureError from nacl.signing import VerifyKey from api import homepage from data import cities from notifications import discord # App's Ed25519 public key (Developer Portal -> General Information). Unset => # every request fails verification, which is the safe default for an unconfigured # deployment. PUBLIC_KEY = os.environ.get("THERMOGRAPH_DISCORD_PUBLIC_KEY", "").strip() # Interaction types (Discord). _PING = 1 _APPLICATION_COMMAND = 2 # Response types. _PONG = 1 _CHANNEL_MESSAGE = 4 # Message flags: 64 = ephemeral (only the caller sees it). _EPHEMERAL = 64 def verify(signature: str, timestamp: str, body: bytes, public_key: str | None = None) -> bool: """True iff `signature` is a valid Ed25519 signature of timestamp+body under the app public key. Any malformed input is a verification failure, never an error.""" key = public_key if public_key is not None else PUBLIC_KEY if not key or not signature or not timestamp: return False try: VerifyKey(bytes.fromhex(key)).verify(timestamp.encode() + body, bytes.fromhex(signature)) return True except (BadSignatureError, ValueError): return False def _resolve_city(query: str) -> dict | None: """Best curated-city match for a free-text name: exact name first, then a prefix match, preferring the largest (cities.json is population-sorted).""" q = (query or "").strip().casefold() if not q: return None exact, prefix = None, None for c in cities.all_cities(): # already sorted by population, desc name = c["name"].casefold() if name == q: exact = exact or c elif prefix is None and name.startswith(q): prefix = c return exact or prefix def _grade_message(query: str) -> dict: """The message data for /grade : an embed with today's grade line, or a plain ephemeral note when the city is unknown or not yet warmed.""" city = _resolve_city(query) if city is None: return {"content": f"I don't track a city called '{query}' yet. " "Try a major city name.", "flags": _EPHEMERAL} card = homepage._grade_city(city, datetime.date.today()) if card is None: return {"content": f"No recent reading is cached for {city['name']} yet. " "It should appear once the data warms up.", "flags": _EPHEMERAL} color = discord._COLD if card.get("tail") == "cold" else discord._HOT return {"embeds": [{ "title": f"{card.get('display') or city['name']} — today", "description": discord._line(card), "url": f"https://thermograph.org/climate/{city['slug']}", "color": color, "footer": {"text": "thermograph.org · graded against ~45 years of local history"}, }]} def _command_response(data: dict) -> dict: """Dispatch an application-command interaction to its handler.""" name = (data.get("name") or "").lower() opts = {o.get("name"): o.get("value") for o in (data.get("options") or [])} if name == "grade": return {"type": _CHANNEL_MESSAGE, "data": _grade_message(opts.get("city", ""))} # Unknown command: acknowledge rather than error, so a stale registration can't # look broken to the user. return {"type": _CHANNEL_MESSAGE, "data": {"content": "Unknown command.", "flags": _EPHEMERAL}} def handle(body: bytes, signature: str, timestamp: str) -> tuple[int, dict]: """Verify and route one interaction. Returns (http_status, json_body). 401 on a bad/missing signature (what Discord's security probe expects); a PING is answered with a PONG; an application command is dispatched. The caller reads the RAW body for verification and passes it here unparsed.""" if not verify(signature, timestamp, body): return 401, {"error": "bad signature"} try: payload = json.loads(body) except ValueError: return 400, {"error": "bad json"} itype = payload.get("type") if itype == _PING: return 200, {"type": _PONG} if itype == _APPLICATION_COMMAND: return 200, _command_response(payload.get("data") or {}) # Components/modals/etc. aren't used yet; a PONG-shaped 200 is harmless. return 200, {"type": _CHANNEL_MESSAGE, "data": {"content": "Unsupported interaction.", "flags": _EPHEMERAL}}