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:
parent
cd760f7e44
commit
f830bce4d9
4 changed files with 115 additions and 20 deletions
|
|
@ -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,6 +123,8 @@ def post_daily_feed(feed: dict | None = None) -> bool:
|
|||
embed = build_embed(feed)
|
||||
except ValueError:
|
||||
return False
|
||||
ok = False
|
||||
if WEBHOOK_URL:
|
||||
payload = {
|
||||
"username": "Thermograph",
|
||||
"avatar_url": "https://thermograph.org/logo.png?v=3",
|
||||
|
|
@ -118,9 +132,14 @@ def post_daily_feed(feed: dict | None = None) -> bool:
|
|||
}
|
||||
try:
|
||||
resp = httpx.post(WEBHOOK_URL, json=payload, timeout=_TIMEOUT)
|
||||
return 200 <= resp.status_code < 300
|
||||
ok = 200 <= resp.status_code < 300
|
||||
except Exception: # noqa: BLE001 - best-effort side channel
|
||||
return False
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue