diff --git a/app.py b/app.py index 95f6605..fac6317 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,7 @@ import audit import climate import content import digest +import discord_interactions as discord_interactions_mod import db import grid import metrics @@ -679,6 +680,22 @@ app.include_router(v1, prefix=f"{BASE}/api") # legacy unversioned == v1 ( app.include_router(v1, prefix=f"{BASE}/api/v1") app.include_router(v2, prefix=f"{BASE}/api/v2") + +# --- Discord slash commands (HTTP interactions) ---------------------------- +async def discord_interactions(request: Request) -> Response: + """Discord posts each slash-command interaction here. Verification runs over the + RAW body, so this must read bytes, not request.json().""" + raw = await request.body() + status, payload = discord_interactions_mod.handle( + raw, + request.headers.get("x-signature-ed25519", ""), + request.headers.get("x-signature-timestamp", ""), + ) + return Response(json.dumps(payload), status_code=status, media_type="application/json") + +app.add_api_route(f"{BASE}/discord/interactions", discord_interactions, + methods=["POST"], include_in_schema=False) + # --- accounts (fastapi-users) ---------------------------------------------- # Library-provided routers: /auth/login + /auth/logout (cookie session), # /auth/register (signup), and /users/me (session check / profile). All under the diff --git a/discord_interactions.py b/discord_interactions.py new file mode 100644 index 0000000..1f0f951 --- /dev/null +++ b/discord_interactions.py @@ -0,0 +1,124 @@ +"""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 + +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 : 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}} diff --git a/requirements.txt b/requirements.txt index 1f14143..7cb9a77 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,7 @@ aiosqlite==0.22.1 # Web Push (VAPID) delivery of notifications (see backend/push.py). Pulls in # py-vapid, cryptography, and http-ece. pywebpush==2.0.0 +# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py). +PyNaCl==1.5.0 # Server-rendered SEO content pages (see backend/content.py, templates/). jinja2==3.1.6 diff --git a/tests/test_discord_interactions.py b/tests/test_discord_interactions.py new file mode 100644 index 0000000..3d475ed --- /dev/null +++ b/tests/test_discord_interactions.py @@ -0,0 +1,116 @@ +"""Discord HTTP-interactions: Ed25519 verification, PING/PONG, /grade dispatch, +and the wired FastAPI route. No live Discord — we hold a test signing key and sign +requests ourselves.""" +import datetime +import json + +import pytest +from fastapi.testclient import TestClient +from nacl.signing import SigningKey + +import app as appmod +import discord_interactions as di +import homepage + +B = "/thermograph" + +_SK = SigningKey.generate() +_PUBKEY = _SK.verify_key.encode().hex() + + +def _sign(body: bytes, ts: str = "1700000000") -> tuple[str, str]: + return _SK.sign(ts.encode() + body).signature.hex(), ts + + +_CARD = { + "display": "Tokyo, Japan", "name": "Tokyo", "slug": "tokyo-jp", + "metric_label": "High temp", "value": 96.0, "normal": 84.0, + "pct_label": "97th percentile", "grade_label": "Very High", + "tail": "warm", "date_label": None, +} + + +# --- signature verification -------------------------------------------------- + +def test_verify_accepts_a_good_signature(): + body = b'{"type":1}' + sig, ts = _sign(body) + assert di.verify(sig, ts, body, _PUBKEY) is True + + +def test_verify_rejects_tampering_and_bad_inputs(): + body = b'{"type":1}' + sig, ts = _sign(body) + assert di.verify(sig, ts, body + b" ", _PUBKEY) is False # body changed + assert di.verify(sig, "1700000001", body, _PUBKEY) is False # timestamp changed + assert di.verify("00" * 64, ts, body, _PUBKEY) is False # wrong signature + assert di.verify("nothex", ts, body, _PUBKEY) is False # malformed + assert di.verify(sig, ts, body, "") is False # no key configured + assert di.verify("", ts, body, _PUBKEY) is False # no signature + + +# --- handle(): routing ------------------------------------------------------- + +def test_handle_ping_pongs(monkeypatch): + monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY) + body = b'{"type":1}' + sig, ts = _sign(body) + status, payload = di.handle(body, sig, ts) + assert status == 200 and payload == {"type": 1} + + +def test_handle_rejects_bad_signature(monkeypatch): + monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY) + body = b'{"type":1}' + status, payload = di.handle(body, "00" * 64, "1700000000") + assert status == 401 + + +def test_grade_command_returns_the_citys_grade(monkeypatch): + monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY) + monkeypatch.setattr(homepage, "_grade_city", lambda city, today: dict(_CARD)) + body = json.dumps({"type": 2, "data": { + "name": "grade", "options": [{"name": "city", "value": "Tokyo"}]}}).encode() + sig, ts = _sign(body) + status, payload = di.handle(body, sig, ts) + assert status == 200 and payload["type"] == 4 + embed = payload["data"]["embeds"][0] + assert "Tokyo, Japan" in embed["title"] + assert "96°F" in embed["description"] and "97th percentile" in embed["description"] + assert embed["url"].endswith("/climate/tokyo-jp") + + +def test_grade_unknown_city_is_ephemeral(monkeypatch): + monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY) + data = di._grade_message("Nowherecityville") + assert data["flags"] == di._EPHEMERAL and "don't track" in data["content"] + + +def test_grade_city_not_yet_warm_is_ephemeral(monkeypatch): + monkeypatch.setattr(homepage, "_grade_city", lambda city, today: None) + data = di._grade_message("Tokyo") # a real curated city, but no warm cache + assert data["flags"] == di._EPHEMERAL and "No recent reading" in data["content"] + + +def test_resolve_city_prefers_exact_then_population(): + # Real cities.json: an exact name resolves, and it's a real curated entry. + tokyo = di._resolve_city("tokyo") + assert tokyo is not None and tokyo["name"] == "Tokyo" + assert di._resolve_city(" ") is None + assert di._resolve_city("Zzznotacity") is None + + +# --- the wired FastAPI route ------------------------------------------------- + +def test_interactions_route_pings_and_rejects(monkeypatch): + monkeypatch.setattr(di, "PUBLIC_KEY", _PUBKEY) + client = TestClient(appmod.app) + body = b'{"type":1}' + sig, ts = _sign(body) + # Signed PING -> PONG. Send raw bytes so the signature matches byte-for-byte. + r = client.post(f"{B}/discord/interactions", content=body, + headers={"x-signature-ed25519": sig, "x-signature-timestamp": ts}) + assert r.status_code == 200 and r.json() == {"type": 1} + # Unsigned -> 401 (Discord's security probe). + r = client.post(f"{B}/discord/interactions", content=body) + assert r.status_code == 401