2026-07-20 02:19:29 +00:00
|
|
|
"""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
|
|
|
|
|
|
2026-07-21 16:09:35 +00:00
|
|
|
from api import homepage
|
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 web import app as appmod
|
|
|
|
|
from notifications import discord_interactions as di
|
2026-07-20 02:19:29 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
discord: resolve the city people actually typed
Reported from #general: "@Thermograph Vilnius, Lithuania" came back "I don't
track a city called 'Vilnius, Lithuania' yet." Vilnius has been in cities.json
the whole time, with country "Lithuania" sitting right there in the record. The
resolver matched the query against `name` alone, exact-or-prefix, so the comma
form could never match anything and the country field was never consulted.
The same message also showed the two neighbouring failures: "Vilinus" (one
transposition) fell through to the same refusal, which reads as "we've never
heard of Vilnius" rather than "you typed it wrong"; and "Tell me about weather
in lithuania" was quoted back verbatim as a city we don't track, which is what
made it look broken rather than merely unmatched.
So the resolver now goes, in descending order of confidence: exact name
honouring a "City, Country/Region" qualifier; the same ignoring an unrecognised
qualifier (better to grade Paris and name it in the reply than refuse over
"Paris, Wherever"); prefix; close-enough name for typos; and finally a city or
country named somewhere inside a sentence, so "weather in lithuania" lands on
Vilnius. Accents fold both ways, since people type Zurich and Sao Paulo far more
often than Zürich and São Paulo.
Free-text matching is whole-word and ignores names under four characters, so
ordinary chatter doesn't turn into a weather report -- "how are you today"
still resolves to nothing. And the unmatched reply no longer reads a whole
sentence back as a place name.
This fixes /grade identically; it shared the resolver and the same bug.
Claude-Session: https://claude.ai/code/session_015Z1ebLbhUxeZ9ozpNrVTCP
2026-07-24 21:47:19 +00:00
|
|
|
# --- what people actually type -----------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# Every case below came back "I don't track a city called …" in #general.
|
|
|
|
|
# Vilnius was in cities.json the whole time; the resolver never looked past
|
|
|
|
|
# `name`.
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_accepts_city_comma_country():
|
|
|
|
|
# The reported bug: the country sat in the record and was never consulted,
|
|
|
|
|
# so the most natural way to disambiguate a city always failed.
|
|
|
|
|
for query in ("Vilnius, Lithuania", "Vilnius, LT", "vilnius,lithuania"):
|
|
|
|
|
city = di._resolve_city(query)
|
|
|
|
|
assert city is not None and city["name"] == "Vilnius", query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_tolerates_a_typo():
|
|
|
|
|
# "Vilinus" is one transposition from Vilnius. Refusing it reads as "we've
|
|
|
|
|
# never heard of Vilnius", which is both wrong and off-putting.
|
|
|
|
|
for query in ("Vilinus", "Vilinus, Lithuania"):
|
|
|
|
|
city = di._resolve_city(query)
|
|
|
|
|
assert city is not None and city["name"] == "Vilnius", query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_finds_a_place_inside_a_sentence():
|
|
|
|
|
# Mentioning the bot is conversational by nature, so the whole message
|
|
|
|
|
# arrives as the "city name".
|
|
|
|
|
city = di._resolve_city("Tell me about weather in lithuania")
|
|
|
|
|
assert city is not None and city["country"] == "Lithuania"
|
|
|
|
|
city = di._resolve_city("what's it looking like in Tokyo today")
|
|
|
|
|
assert city is not None and city["name"] == "Tokyo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_ignores_diacritics_either_way():
|
|
|
|
|
for query in ("Zurich", "Sao Paulo"):
|
|
|
|
|
assert di._resolve_city(query) is not None, query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_falls_back_when_the_qualifier_is_unknown():
|
|
|
|
|
# Better to grade Paris and name it in the reply than refuse over a
|
|
|
|
|
# qualifier we don't carry.
|
|
|
|
|
city = di._resolve_city("Paris, Wherever")
|
|
|
|
|
assert city is not None and city["name"] == "Paris"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_city_does_not_match_ordinary_chatter():
|
|
|
|
|
# The free-text pass must not turn small talk into a weather report.
|
|
|
|
|
for query in ("how are you today", "what is the weather", "thanks!"):
|
|
|
|
|
assert di._resolve_city(query) is None, query
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_grade_does_not_quote_a_whole_sentence_back():
|
|
|
|
|
# Reading a full sentence back as a city we don't track is what made this
|
|
|
|
|
# look broken rather than merely unmatched.
|
|
|
|
|
sentence = "please tell me something about the climate of Atlantis the lost place"
|
|
|
|
|
data = di._grade_message(sentence)
|
|
|
|
|
assert data["flags"] == di._EPHEMERAL
|
|
|
|
|
assert sentence not in data["content"]
|
|
|
|
|
assert "couldn't find a city" in data["content"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 02:19:29 +00:00
|
|
|
# --- 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
|