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("/") PUBLIC_URL = os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org").rstrip("/")
_API = "https://discord.com/api/v10" _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. # One 429 retry, capped so a rate-limit can't stall the notifier pass for long.
_MAX_BACKOFF_S = 5.0 _MAX_BACKOFF_S = 5.0
@ -42,7 +49,12 @@ _TIMEOUT = httpx.Timeout(10.0)
def enabled() -> bool: 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: def _f_c(value) -> str:
@ -111,16 +123,23 @@ def post_daily_feed(feed: dict | None = None) -> bool:
embed = build_embed(feed) embed = build_embed(feed)
except ValueError: except ValueError:
return False return False
payload = { ok = False
"username": "Thermograph", if WEBHOOK_URL:
"avatar_url": "https://thermograph.org/logo.png?v=3", payload = {
"embeds": [embed], "username": "Thermograph",
} "avatar_url": "https://thermograph.org/logo.png?v=3",
try: "embeds": [embed],
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT) }
return 200 <= resp.status_code < 300 try:
except Exception: # noqa: BLE001 - best-effort side channel resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
return False 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 --------------------------------------------------------- # --- direct messages ---------------------------------------------------------
@ -149,6 +168,40 @@ def _bot_post(path: str, json_body: dict) -> httpx.Response | None:
return 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: 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 """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 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 return False
if not channel_id: if not channel_id:
return False return False
embed = { embed = _alert_embed(
"title": title, title, body, path,
"description": body or "", "thermograph.org · mute Discord alerts anytime in your account",
"url": f"{PUBLIC_URL}{path}" if path else PUBLIC_URL, )
"color": _HOT, return _post_channel_embed(channel_id, embed)
"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

View file

@ -319,6 +319,13 @@ def _process_cell(session, cell_id, subs, today, now, archive_budget):
discord_jobs.append(job) discord_jobs.append(job)
except Exception: # noqa: BLE001 - gathering must not break the DB write except Exception: # noqa: BLE001 - gathering must not break the DB write
pass 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() session.commit()
# Deliver every push/Discord job gathered above concurrently, only now that # 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**") 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): def test_post_is_best_effort_on_http_error(monkeypatch):
monkeypatch.setattr(discord, "WEBHOOK_URL", "https://discord.test/webhook/abc") 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 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): def test_bot_post_retries_once_on_429(monkeypatch):
monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc") monkeypatch.setattr(discord, "BOT_TOKEN", "bot-abc")
monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait monkeypatch.setattr(discord.time, "sleep", lambda s: None) # don't actually wait