2026-07-20 02:19:29 +00:00
|
|
|
"""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
|
|
|
|
|
|
Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module
Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.
Groundwork for moving modules into packages without re-pointing paths.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
* Split the backend into domain packages
Group the flat backend modules into packages that mirror their concerns:
data/ climate, grading, scoring, grid, places, cities,
city_events, store
web/ app, views, homepage, content, schemas
notifications/ notify, digest, push, mailer, discord,
discord_interactions, discord_link
accounts/ models, users, api_accounts, db
core/ metrics, singleton, audit
Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.
Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.
Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
|
|
|
from data import cities
|
|
|
|
|
from notifications import discord
|
|
|
|
|
from web import homepage
|
2026-07-20 02:19:29 +00:00
|
|
|
|
|
|
|
|
# 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}}
|