Source code for constellation_utils.notifications

"""Slack notifications via ``chat.postMessage``.

Stdlib-only. One public function :func:`notify` plus :class:`SlackNotifyError`
for the Slack-API quirk: ``chat.postMessage`` returns HTTP 200 even for
API-level failures, with ``{"ok": false, "error": "..."}`` in the body —
we parse and raise.

Auth: bot user OAuth token (``xoxb-...``) retrieved via
:func:`constellation_utils.secrets.slack_bot_token`. Profile is required;
no default at this layer (matches the secrets-module convention).
"""

from __future__ import annotations

import json
import urllib.request
from typing import Literal

from constellation_utils import secrets

_SLACK_POST_MESSAGE = "https://slack.com/api/chat.postMessage"


[docs] class SlackNotifyError(RuntimeError): """Raised when ``chat.postMessage`` returns ``ok=false`` or a body that isn't valid UTF-8 JSON (e.g. an HTML error page from a WAF or transparent proxy in front of ``slack.com``). Non-2xx HTTP responses surface as :class:`urllib.error.HTTPError` unchanged — they're handled separately because they represent transport/auth problems rather than Slack API errors. """
[docs] def notify( message: str, *, level: Literal["info", "warning", "error"], channel: str, profile: str, ) -> None: """POST ``f"[{level}] {message}"`` to *channel* via Slack ``chat.postMessage``. Blocks the caller for up to 10s on the HTTP POST. No retries and no exponential backoff — a 5xx surfaces as ``urllib.error.HTTPError``; a Slack ``ok=false`` response surfaces as :class:`SlackNotifyError`. *level*, *channel*, and *profile* are keyword-only with no default — matches the secrets-module convention. Per-package channel defaults (e.g. Virgo → ``#virgo-status``) are the caller's responsibility. """ token = secrets.slack_bot_token(profile=profile) body = json.dumps({"channel": channel, "text": f"[{level}] {message}"}).encode("utf-8") req = urllib.request.Request( _SLACK_POST_MESSAGE, data=body, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", }, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: raw = resp.read() try: payload = json.loads(raw.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError) as exc: # HTTP 200 with a non-JSON body — typically an HTML error page from a # WAF or transparent proxy in front of slack.com. Surface as the # documented SlackNotifyError so callers don't see a raw JSONDecodeError. snippet = raw[:120].decode("utf-8", errors="replace") raise SlackNotifyError( f"chat.postMessage returned a non-JSON body (channel={channel!r}): {snippet!r}" ) from exc if not payload.get("ok"): raise SlackNotifyError( f"chat.postMessage failed: {payload.get('error', 'unknown error')!r} " f"(channel={channel!r})" )