thermograph/backend/tests/notifications/test_discord_interactions.py
Emi Griffith 119c54604c
All checks were successful
secrets-guard / encrypted (pull_request) Successful in 6s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / changes (pull_request) Successful in 19s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-backend (pull_request) Successful in 40s
PR build (required check) / gate (pull_request) Successful in 4s
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-08-02 01:16:47 +00:00

175 lines
6.9 KiB
Python

"""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
from api import homepage
from web import app as appmod
from notifications import discord_interactions as di
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
# --- 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"]
# --- 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