diff --git a/notifications/discord.py b/notifications/discord.py index 85c706b..776fa2f 100644 --- a/notifications/discord.py +++ b/notifications/discord.py @@ -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) diff --git a/notifications/notify.py b/notifications/notify.py index 509e691..d7c82d9 100644 --- a/notifications/notify.py +++ b/notifications/notify.py @@ -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 diff --git a/tests/notifications/test_discord.py b/tests/notifications/test_discord.py index 52c1fcb..f62763e 100644 --- a/tests/notifications/test_discord.py +++ b/tests/notifications/test_discord.py @@ -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") diff --git a/tests/notifications/test_discord_dm.py b/tests/notifications/test_discord_dm.py index f3e97ba..1e1b34e 100644 --- a/tests/notifications/test_discord_dm.py +++ b/tests/notifications/test_discord_dm.py @@ -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