Port 871ab9c Route notifier output to Discord channels via the bot from monorepo (drift reconciliation)

Note: this commit's deploy/thermograph.env.example changes live at the
monorepo root (outside backend/) and are out of scope for this split repo.

Claude-Session: https://claude.ai/code/session_01RdARHDJaYC1wSQRTYM6t3Z
This commit is contained in:
Emi Griffith 2026-07-22 12:07:46 -07:00
parent cd760f7e44
commit f830bce4d9
4 changed files with 115 additions and 20 deletions

View file

@ -28,6 +28,13 @@ BOT_TOKEN = os.environ.get("THERMOGRAPH_DISCORD_BOT_TOKEN", "").strip()
PUBLIC_URL = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
_API = "https://discord.com/api/v10"
# Channel IDs the bot posts into (numeric snowflakes, from env; empty => that feed
# is off). SUBSCRIPTION is this deployment's dev/uat/prod test feed — it mirrors
# every notification the notifier creates so a run can be watched live. WEATHER is
# the prod "notable weather" channel the daily digest broadcasts into.
SUBSCRIPTION_CHANNEL_ID = os.environ.get("THERMOGRAPH_DISCORD_SUBSCRIPTION_CHANNEL", "").strip()
WEATHER_CHANNEL_ID = os.environ.get("THERMOGRAPH_DISCORD_WEATHER_CHANNEL", "").strip()
# One 429 retry, capped so a rate-limit can't stall the notifier pass for long.
_MAX_BACKOFF_S = 5.0
@ -42,7 +49,12 @@ _TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool:
return bool(WEBHOOK_URL)
return bool(WEBHOOK_URL) or weather_enabled()
def weather_enabled() -> bool:
"""The daily digest has a bot channel (prod's notable-weather feed) to post into."""
return bool(BOT_TOKEN and WEATHER_CHANNEL_ID)
def _f_c(value) -> str:
@ -111,16 +123,23 @@ def post_daily_feed(feed: dict | None = None) -> bool:
embed = build_embed(feed)
except ValueError:
return False
payload = {
"username": "Thermograph",
"avatar_url": "https://thermograph.org/logo.png?v=3",
"embeds": [embed],
}
try:
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
return 200 <= resp.status_code < 300
except Exception: # noqa: BLE001 - best-effort side channel
return False
ok = False
if WEBHOOK_URL:
payload = {
"username": "Thermograph",
"avatar_url": "https://thermograph.org/logo.png?v=3",
"embeds": [embed],
}
try:
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
ok = 200 <= resp.status_code < 300
except Exception: # noqa: BLE001 - best-effort side channel
pass
if weather_enabled():
# Broadcast the same digest into the prod notable-weather channel via the
# bot — independent of the webhook, so either destination can run alone.
ok = _post_channel_embed(WEATHER_CHANNEL_ID, embed) or ok
return ok
# --- direct messages ---------------------------------------------------------
@ -149,6 +168,40 @@ def _bot_post(path: str, json_body: dict) -> httpx.Response | None:
return None
def _alert_embed(title: str, body: str | None, path: str, footer: str) -> dict:
"""A single-alert embed (DM or channel): title, body text, a deep link back to
the site, warm accent, and a channel-appropriate footer."""
return {
"title": title,
"description": body or "",
"url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL,
"color": _HOT,
"footer": {"text": footer},
}
def _post_channel_embed(channel_id: str, embed: dict) -> bool:
"""POST an embed into a channel by id via the bot. False on any failure."""
msg = _bot_post(f"/channels/{channel_id}/messages", {"embeds": [embed]})
return msg is not None and 200 <= msg.status_code < 300
def subscription_enabled() -> bool:
"""The notifier has a bot channel (this env's dev/uat/prod test feed) to post into."""
return bool(BOT_TOKEN and SUBSCRIPTION_CHANNEL_ID)
def post_subscription_alert(title: str, body: str | None, path: str = "") -> bool:
"""Mirror one just-created notification into this deployment's subscription
channel (dev/uat/prod), so a notifier run can be watched live. Best-effort:
returns True on a 2xx, False otherwise (unconfigured or any HTTP error), and
never raises the in-app row and per-user DM are unaffected either way."""
if not subscription_enabled():
return False
embed = _alert_embed(title, body, path, "thermograph.org · subscription notifier")
return _post_channel_embed(SUBSCRIPTION_CHANNEL_ID, embed)
def send_dm(discord_id: str, title: str, body: str | None, path: str = "") -> bool:
"""DM one linked user an alert. Opens (or reuses) the DM channel, then posts an
embed. Best-effort: returns True on success, False on anything else, never
@ -165,12 +218,8 @@ def send_dm(discord_id: str, title: str, body: str | None, path: str = "") -> bo
return False
if not channel_id:
return False
embed = {
"title": title,
"description": body or "",
"url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL,
"color": _HOT,
"footer": {"text": "thermograph.org · mute Discord alerts anytime in your account"},
}
msg = _bot_post(f"/channels/{channel_id}/messages", {"embeds": [embed]})
return msg is not None and 200 <= msg.status_code < 300
embed = _alert_embed(
title, body, path,
"thermograph.org · mute Discord alerts anytime in your account",
)
return _post_channel_embed(channel_id, embed)

View file

@ -319,6 +319,13 @@ def _process_cell(session, cell_id, subs, today, now, archive_budget):
discord_jobs.append(job)
except Exception: # noqa: BLE001 - gathering must not break the DB write
pass
# Mirror into this deployment's subscription channel (dev/uat/prod), if one
# is configured — a shared live feed of the notifier, independent of any
# single user's DM opt-in. Best-effort, isolated like the channels above.
try:
discord.post_subscription_alert(title, body, _deep_link(sub, event_date))
except Exception: # noqa: BLE001 - channel post stays isolated too
pass
session.commit()
# Deliver every push/Discord job gathered above concurrently, only now that

View file

@ -84,6 +84,21 @@ def test_post_sends_embed_to_the_webhook(monkeypatch):
assert payload["embeds"][0]["description"].startswith("**Tehran, Iran**")
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 = []
monkeypatch.setattr(discord.httpx, "post",
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**")
def test_post_is_best_effort_on_http_error(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc")

View file

@ -69,6 +69,30 @@ def test_send_dm_gives_up_if_channel_open_fails(monkeypatch):
assert len(calls) == 1 # never tried to post a message
def test_post_subscription_alert_posts_to_channel(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {"/channels/chan-sub/messages": _Resp(204)})
ok = discord.post_subscription_alert("Seattle: unusually hot day", "97th pct", "/day#x")
assert ok is True
(url, body), = calls
assert url.endswith("/channels/chan-sub/messages")
embed = body["embeds"][0]
assert embed["title"].startswith("Seattle") and embed["url"].endswith("/day#x")
def test_post_subscription_alert_noop_without_config(monkeypatch):
# Channel set but no bot token, and bot token set but no channel: both no-op.
monkeypatch.setattr(discord, "BOT_TOKEN", "")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "chan-sub")
calls = _mock_posts(monkeypatch, {})
assert discord.post_subscription_alert("t", "b") is False
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord, "SUBSCRIPTION_CHANNEL_ID", "")
assert discord.post_subscription_alert("t", "b") is False
assert calls == [] # never touched the network
def test_bot_post_retries_once_on_429(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait