thermograph/discord_interactions.py
Emi Griffith c3b9b8bddb Serve a /grade Discord slash command over HTTP interactions (#207)
Adds Discord slash commands with no bot process and no gateway connection: Discord
POSTs each interaction to a FastAPI route, and the app answers it. First command is
/grade <city>, returning today's grade for a curated city from the warm cache
(reusing homepage._grade_city, so it answers well within the 3-second deadline and
costs no upstream quota).

- backend/discord_interactions.py: Ed25519 verification (PyNaCl) over the RAW
  request body — Discord probes the endpoint with bad signatures and disables it if
  they aren't rejected with 401. Routes PING to PONG and application-commands to
  their handler; unknown/unsupported interactions are acknowledged, not errored.
  City lookup is exact-name-then-prefix over the population-sorted city set; unknown
  or not-yet-warm cities get an ephemeral note.
- app.py: POST {BASE}/discord/interactions, reading request.body() (not json()) so
  the bytes match the signature.
- scripts/register_discord_commands.py: one-off upsert of the command definitions
  via Discord REST (app id + bot token).
- PyNaCl added to requirements; Discord public-key / app-id / bot-token documented
  in the env example. Endpoint URL: https://thermograph.org/discord/interactions.

Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 02:19:29 +00:00

124 lines
5 KiB
Python

"""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 <city> -> 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
import cities
import discord
import homepage
# 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 <city>: 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}}