Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
"""Discord daily-feed post: embed shape, webhook delivery, and the once-a-day guard.
|
2026-07-23 04:41:19 +00:00
|
|
|
No network — the shared client's .post is stubbed, like the IndexNow tests."""
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
import datetime
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
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 notifications import discord
|
|
|
|
|
from notifications import notify
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _feed(ranked, generated_at=None, date=None):
|
|
|
|
|
# Fresh by default so it passes homepage.is_stale(); tests that assert on the
|
|
|
|
|
# embed timestamp pin generated_at explicitly.
|
|
|
|
|
return {
|
|
|
|
|
"generated_at": generated_at if generated_at is not None else time.time(),
|
|
|
|
|
"date": date or datetime.date.today().isoformat(),
|
|
|
|
|
"ranked": ranked,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CARD_HOT = {
|
|
|
|
|
"display": "Tehran, Iran", "name": "Tehran", "metric": "tmax",
|
|
|
|
|
"metric_label": "High temp", "value": 104.2, "normal": 96.8,
|
|
|
|
|
"percentile": 99.0, "pct_label": "99th percentile", "grade": "Near Record",
|
|
|
|
|
"grade_label": "Near Record hot", "tail": "warm", "date_label": None,
|
|
|
|
|
}
|
|
|
|
|
_CARD_COLD = {
|
|
|
|
|
"display": "Oslo, Norway", "name": "Oslo", "metric": "tmin",
|
|
|
|
|
"metric_label": "Low temp", "value": 5.0, "normal": 20.0,
|
|
|
|
|
"percentile": 3.0, "pct_label": "3rd percentile", "grade": "Near Record",
|
|
|
|
|
"grade_label": "Near Record cold", "tail": "cold", "date_label": "Jul 18",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_embed_has_numbers_and_cities():
|
|
|
|
|
embed = discord.build_embed(_feed([_CARD_HOT, _CARD_COLD], generated_at=1_700_000_000.0))
|
|
|
|
|
assert "unusual weather" in embed["title"].lower()
|
|
|
|
|
desc = embed["description"]
|
|
|
|
|
assert "Tehran, Iran" in desc and "Oslo, Norway" in desc
|
|
|
|
|
# Real readings, in both units, with the normal for context.
|
|
|
|
|
assert "104°F (40°C)" in desc
|
|
|
|
|
assert "normal 97°F" in desc
|
|
|
|
|
# The percentile + grade tag rides along.
|
|
|
|
|
assert "99th percentile" in desc and "Near Record hot" in desc
|
|
|
|
|
# A non-today card is dated so it can't pass for "right now".
|
|
|
|
|
assert "(Jul 18)" in desc
|
|
|
|
|
# Warm top card => warm accent; timestamp from the feed's build time.
|
|
|
|
|
assert embed["color"] == discord._HOT
|
|
|
|
|
assert embed["timestamp"].startswith("2023-11-14")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_embed_accent_follows_the_top_cards_tail():
|
|
|
|
|
assert discord.build_embed(_feed([_CARD_COLD]))["color"] == discord._COLD
|
|
|
|
|
assert discord.build_embed(_feed([_CARD_HOT]))["color"] == discord._HOT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_embed_rejects_empty_feed():
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
discord.build_embed(_feed([]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_disabled_without_webhook(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(discord, "WEBHOOK_URL", "")
|
|
|
|
|
posted = []
|
2026-07-23 04:41:19 +00:00
|
|
|
monkeypatch.setattr(discord._client, "post", lambda *a, **k: posted.append(a) or None)
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
|
|
|
|
|
assert posted == [] # never touched the network
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Resp:
|
|
|
|
|
def __init__(self, code): self.status_code = code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_sends_embed_to_the_webhook(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
|
|
|
|
|
calls = []
|
2026-07-23 04:41:19 +00:00
|
|
|
monkeypatch.setattr(discord._client, "post",
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
lambda url, json=None, **k: (calls.append((url, json)), _Resp(204))[1])
|
|
|
|
|
assert discord.post_daily_feed(_feed([_CARD_HOT])) is True
|
|
|
|
|
(url, payload), = calls
|
|
|
|
|
assert url == "https://discord.test/webhook/abc"
|
|
|
|
|
assert payload["username"] == "Thermograph"
|
|
|
|
|
assert payload["embeds"][0]["description"].startswith("**Tehran, Iran**")
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 19:07:46 +00:00
|
|
|
def test_post_also_broadcasts_to_weather_channel(monkeypatch):
|
|
|
|
|
# No webhook, but a bot + weather channel configured -> digest goes to the
|
|
|
|
|
# channel via the bot REST API.
|
|
|
|
|
monkeypatch.setattr(discord, "WEBHOOK_URL", "")
|
|
|
|
|
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
|
|
|
|
|
monkeypatch.setattr(discord, "WEATHER_CHANNEL_ID", "chan-weather")
|
|
|
|
|
calls = []
|
2026-07-23 04:41:19 +00:00
|
|
|
monkeypatch.setattr(discord._client, "post",
|
2026-07-22 19:07:46 +00:00
|
|
|
lambda url, json=None, **k: (calls.append((url, json)), _Resp(204))[1])
|
|
|
|
|
assert discord.post_daily_feed(_feed([_CARD_HOT])) is True
|
|
|
|
|
(url, body), = calls
|
|
|
|
|
assert url.endswith("/channels/chan-weather/messages")
|
|
|
|
|
assert body["embeds"][0]["description"].startswith("**Tehran, Iran**")
|
|
|
|
|
|
|
|
|
|
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
def test_post_is_best_effort_on_http_error(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
|
|
|
|
|
|
|
|
|
|
def _boom(*a, **k):
|
|
|
|
|
raise RuntimeError("network down")
|
2026-07-23 04:41:19 +00:00
|
|
|
monkeypatch.setattr(discord._client, "post", _boom)
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
# Never raises; just reports failure.
|
|
|
|
|
assert discord.post_daily_feed(_feed([_CARD_HOT])) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_skips_stale_or_empty_feed(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")
|
2026-07-23 04:41:19 +00:00
|
|
|
monkeypatch.setattr(discord._client, "post", lambda *a, **k: _Resp(204))
|
Post the daily "most unusual right now" feed to Discord (#205)
Adds an optional daily Discord post of the day's most anomalous cities, built from
the same data/homepage.json feed the homepage renders — so the post and the site
always agree. Delivery is a single incoming-webhook POST (no bot, no gateway, no
new process): it rides the notifier daemon like the feed refresh does, once per
day after the refresh, leader-only. With no webhook configured it no-ops.
- backend/discord.py: build_embed() renders the top cities as a rich embed (each
line: city, reading in °F and °C, the normal for context, percentile + grade;
non-today readings are dated). post_daily_feed() is best-effort — it skips a
stale/empty feed and never raises, so a Discord failure can't disturb a pass.
- notify.py: a once-a-day guard (_maybe_post_discord) beside the homepage-refresh
guard; only marks the day done once a post lands, so a transient failure retries.
- deploy/thermograph.env.example: THERMOGRAPH_DISCORD_WEBHOOK (the URL is the
credential — env only).
The webhook value is the top anomaly's tail for the accent colour and reuses the
feed's build time as the embed timestamp. httpx (already a dependency) does the POST.
Claude-Session: https://claude.ai/code/session_013dRZmX9D3JEntfMKWMTWZ8
2026-07-20 00:48:22 +00:00
|
|
|
# Yesterday's feed is stale -> no post.
|
|
|
|
|
yesterday = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
|
|
|
|
|
assert discord.post_daily_feed(_feed([_CARD_HOT], date=yesterday)) is False
|
|
|
|
|
# Empty ranked -> no post.
|
|
|
|
|
assert discord.post_daily_feed(_feed([])) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_guard_posts_once_per_day(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(notify, "_last_discord_post_day", None)
|
|
|
|
|
monkeypatch.setattr(discord, "enabled", lambda: True)
|
|
|
|
|
posts = []
|
|
|
|
|
monkeypatch.setattr(discord, "post_daily_feed", lambda: posts.append(1) or True)
|
|
|
|
|
|
|
|
|
|
day1 = datetime.date(2026, 7, 19)
|
|
|
|
|
day2 = datetime.date(2026, 7, 20)
|
|
|
|
|
|
|
|
|
|
class _D(datetime.date):
|
|
|
|
|
_t = day1
|
|
|
|
|
@classmethod
|
|
|
|
|
def today(cls): return cls._t
|
|
|
|
|
monkeypatch.setattr(notify.datetime, "date", _D)
|
|
|
|
|
|
|
|
|
|
notify._maybe_post_discord()
|
|
|
|
|
notify._maybe_post_discord() # same day -> still one post
|
|
|
|
|
assert posts == [1]
|
|
|
|
|
|
|
|
|
|
_D._t = day2
|
|
|
|
|
notify._maybe_post_discord() # new day -> a second post
|
|
|
|
|
assert posts == [1, 1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_daily_guard_retries_after_a_failed_post(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(notify, "_last_discord_post_day", None)
|
|
|
|
|
monkeypatch.setattr(discord, "enabled", lambda: True)
|
|
|
|
|
outcomes = iter([False, True])
|
|
|
|
|
monkeypatch.setattr(discord, "post_daily_feed", lambda: next(outcomes))
|
|
|
|
|
# First call fails -> day not marked done -> second call retries and succeeds.
|
|
|
|
|
notify._maybe_post_discord()
|
|
|
|
|
notify._maybe_post_discord()
|
|
|
|
|
assert notify._last_discord_post_day == datetime.date.today().isoformat()
|