diff --git a/AGENTS.md b/AGENTS.md index 9166bc2..649fa8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ TOP_K=5 ALLOWED_CHANNELS= # Channel IDs donde el bot responde (múltiples separados por coma) STATUS_CHANNEL_ID= # Canal donde se publica el reporte diario de métricas METRICS_SEND_HOUR=9 # Hora UTC (0-23) del reporte diario -SLACK_WEBHOOK_URL= # Incoming Webhook de Slack; vacío = notificaciones deshabilitadas +SLACK_WEBHOOK_URL= # Incoming Webhook de Slack (https://hooks.slack.com/services/...); vacío o cualquier otra URL = notificaciones deshabilitadas SUPPORT_CHANNEL_IDS= # Channel IDs cuyos hilos nuevos se avisan en Slack (coma-separados) SLACK_HTTP_TIMEOUT=10 # Timeout (s) del POST al webhook ``` diff --git a/README.md b/README.md index e579321..8cfce29 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ On message events, `NanBot.on_message` filters by `ALLOWED_CHANNELS` and mention Metrics live in `bot/metrics.py`. They hit the LiteLLM proxy `/spend/logs/ui` endpoint (configured via `LITELLM_PROXY_URL` and `LITELLM_ADMIN_KEY`) and aggregate token usage per `user_api_key_alias`. The daily scheduler sleeps until `METRICS_SEND_HOUR` UTC, posts the top-10 report to `STATUS_CHANNEL_ID`, and then loops every 24 hours. -Slack notifications live in `bot/slack.py`. `NanBot.on_thread_create` fires on Discord's `THREAD_CREATE` gateway event (only for newly created threads, so re-joins do not re-notify), filters by `SUPPORT_CHANNEL_IDS`, resolves the opening message — `Thread.starter_message` when cached, otherwise `fetch_message(thread.id)` with one retry for the forum race where the event outruns the message — and POSTs a Block Kit payload to `SLACK_WEBHOOK_URL`. The `SlackNotifier` retries 5xx and 429 with a 0.5/1/2 s backoff, never retries other 4xx, and escapes `&`, `<`, `>` in every user-controlled field so thread titles cannot forge Slack links. With `SLACK_WEBHOOK_URL` empty the notifier is a no-op. +Slack notifications live in `bot/slack.py`. `NanBot.on_thread_create` fires on Discord's `THREAD_CREATE` gateway event (only for newly created threads, so re-joins do not re-notify), filters by `SUPPORT_CHANNEL_IDS`, resolves the opening message — `Thread.starter_message` when cached, otherwise `fetch_message(thread.id)` with one retry for the forum race where the event outruns the message — and POSTs a Block Kit payload to `SLACK_WEBHOOK_URL`. The `SlackNotifier` only accepts `https://hooks.slack.com/services/` URLs (anything else disables it with one error at startup), counts a delivery only when Slack answers with the body `ok` (a 2xx with any other body is not delivered), retries 5xx and 429 with a 0.5/1/2 s backoff, never retries other 4xx, and escapes `&`, `<`, `>` in every user-controlled field so thread titles cannot forge Slack links. With `SLACK_WEBHOOK_URL` empty the notifier is a no-op. ## Project structure @@ -129,7 +129,7 @@ Auto-response is triggered when the bot is **mentioned** inside a channel listed | `STATUS_CHANNEL_ID` | no | `""` (disables daily report) | Channel ID where the daily metrics report is posted. Required for the scheduler to run. | | `METRICS_SEND_HOUR` | no | `9` | UTC hour (0–23) at which the daily metrics report is posted. | | `SUPPORT_CHANNEL_IDS` | no | `""` (disables notifications) | Comma-separated Discord channel IDs (text or forum) whose new threads are announced in Slack. | -| `SLACK_WEBHOOK_URL` | no | `""` (disables notifications) | Slack Incoming Webhook URL. The destination channel is fixed in the Slack app, not here. | +| `SLACK_WEBHOOK_URL` | no | `""` (disables notifications) | Slack Incoming Webhook URL. The destination channel is fixed in the Slack app, not here. Must start with `https://hooks.slack.com/services/`; any other value disables notifications with one error at startup. | | `SLACK_HTTP_TIMEOUT` | no | `10` | Per-request HTTP timeout (seconds) for the webhook POST. | | `DOCS_USE_REMOTE` | no | `local` | Source for docs: `local` (`bot/docs/knowledge/`), `remote` (web docs API), or `shadow` (local + warn on remote drift). | | `DOCS_BASE_URL` | no | `https://nan.builders` | Base URL of the web that serves `/api/docs/manifest.json` and `/api/docs/{slug}.md`. | diff --git a/bot/slack.py b/bot/slack.py index 75deabd..ce104d9 100644 --- a/bot/slack.py +++ b/bot/slack.py @@ -10,6 +10,11 @@ from bot.config import logger, settings _BACKOFF_SECONDS = (0.5, 1.0, 2.0) + +# Only this prefix is an Incoming Webhook endpoint. A Slack client URL +# (https://app.slack.com/client/...) answers a POST with the web app and HTTP +# 200, which would otherwise read as a delivered message. +_WEBHOOK_PREFIX = "https://hooks.slack.com/services/" _MAX_TITLE_LEN = 200 _MAX_PREVIEW_LEN = 600 @@ -93,10 +98,24 @@ def __init__(self, webhook_url: str | None = None, timeout: float | None = None) self._webhook_url = (webhook_url if webhook_url is not None else settings.slack_webhook_url).strip() self._timeout = timeout if timeout is not None else float(settings.slack_http_timeout) self._client: httpx.AsyncClient | None = None + # Validated once, at startup, so a misconfigured URL is one loud error + # in the logs instead of one per notification. + self._enabled = self._validate_url() + + def _validate_url(self) -> bool: + if not self._webhook_url: + return False + if not self._webhook_url.startswith(_WEBHOOK_PREFIX): + logger.error( + "SLACK_WEBHOOK_URL is not an Incoming Webhook (must start with %s); notifications are disabled", + _WEBHOOK_PREFIX, + ) + return False + return True @property def enabled(self) -> bool: - return bool(self._webhook_url) + return self._enabled def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: @@ -124,7 +143,20 @@ async def post(self, payload: dict) -> bool: last_error = type(e).__name__ else: if resp.status_code < 400: - return True + # An Incoming Webhook answers "ok". Anything else behind a + # 2xx means the URL is not a webhook, so the message was + # never delivered however healthy the status looks. + body = resp.text.strip() + if body.lower() == "ok": + return True + logger.error( + "Slack returned HTTP %d but not an Incoming Webhook response (body starts with %r); " + "check that SLACK_WEBHOOK_URL is a %s… URL", + resp.status_code, + body[:40], + _WEBHOOK_PREFIX, + ) + return False # 4xx means a bad payload or a revoked webhook: retrying cannot help. if resp.status_code < 500 and resp.status_code != 429: logger.error("Slack webhook rejected the message (HTTP %d)", resp.status_code) diff --git a/tests/test_slack.py b/tests/test_slack.py index cf84720..f631e59 100644 --- a/tests/test_slack.py +++ b/tests/test_slack.py @@ -103,7 +103,7 @@ async def handler(request: httpx.Request) -> httpx.Response: sent.append(__import__("json").loads(request.content)) return httpx.Response(200, text="ok") - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier = SlackNotifier(webhook_url="https://hooks.slack.com/services/T/B/X") notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) assert await notifier.notify_support_thread(EVENT) is True @@ -120,7 +120,7 @@ async def handler(request: httpx.Request) -> httpx.Response: calls += 1 return httpx.Response(404, text="no_service") - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier = SlackNotifier(webhook_url="https://hooks.slack.com/services/T/B/X") notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) assert await notifier.notify_support_thread(EVENT) is False @@ -139,7 +139,7 @@ async def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(503) return httpx.Response(200, text="ok") - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier = SlackNotifier(webhook_url="https://hooks.slack.com/services/T/B/X") notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) assert await notifier.notify_support_thread(EVENT) is True @@ -156,7 +156,7 @@ async def handler(request: httpx.Request) -> httpx.Response: calls += 1 raise httpx.ConnectError("boom", request=request) - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier = SlackNotifier(webhook_url="https://hooks.slack.com/services/T/B/X") notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) assert await notifier.notify_support_thread(EVENT) is False @@ -186,3 +186,45 @@ def test_httpx_request_logging_cannot_leak_the_webhook_secret(caplog): import bot.config # noqa: F401 (importing configures logging) assert logging.getLogger("httpx").level >= logging.WARNING + + +# --- the URL must actually be an Incoming Webhook ------------------------- + + +@pytest.mark.parametrize( + "url", + [ + "https://app.slack.com/client/T00000000/D00000000", # a DM link, not a webhook + "https://slack.com/services/T/B/X", + "http://hooks.slack.com/services/T/B/X", # no TLS + "https://hooks.slack.example/services/T/B/X", + ], +) +async def test_notifier_refuses_urls_that_are_not_incoming_webhooks(url): + notifier = SlackNotifier(webhook_url=url) + assert notifier.enabled is False + assert await notifier.notify_support_thread(EVENT) is False + + +async def test_a_slack_client_url_answering_200_with_html_is_not_a_delivery(): + """The Slack web app answers any POST with HTTP 200 and a page of HTML.""" + + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="