From e11b61e7e6d08ec74816814514d16155cc4e7b88 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Thu, 20 Aug 2026 10:15:17 +0200 Subject: [PATCH 1/2] ci: exclude the mirrored knowledge base from ruff format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruff 0.16 formats Python inside markdown code blocks, and the pin is `ruff>=0.4.0` with no upper bound, so `ruff format --check .` started failing on main as soon as ruff updated. Exclude rather than reformat: bot/docs/knowledge/ mirrors the remote docs API. It is the fallback for DOCS_USE_REMOTE=remote and the baseline the shadow-mode hash diff compares against, so reformatting it would report permanent false drift. The exclusion is narrow — markdown outside the knowledge base still gets formatted, and `ruff check` never looked at markdown to begin with. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 3 +++ pyproject.toml | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 906fd42..5fab213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,9 @@ pytest -x # stop at first failure - Follow the existing style in the codebase. - `ruff` is the linter and formatter (config in `pyproject.toml`, `line-length = 120`, `target-version = "py311"`). +- `ruff format` also formats Python inside markdown code blocks. The knowledge + base under `bot/docs/knowledge/` is excluded because it mirrors the remote docs + API; edit those files to match the remote content, not the formatter. - Use type hints. Target Python 3.11+ syntax (`list[str]`, `str | None`, ...). - Never commit secrets, API keys, Discord tokens, or LiteLLM keys. Use `.env` diff --git a/pyproject.toml b/pyproject.toml index 75e96aa..b166eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,5 +33,12 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +[tool.ruff.format] +# Ruff >= 0.16 formats Python inside markdown code blocks. The knowledge base is +# a mirror of the remote docs API — the fallback for DOCS_USE_REMOTE=remote and +# the baseline the shadow-mode hash diff compares against — so reformatting it +# would report permanent false drift. Markdown outside it is still formatted. +exclude = ["bot/docs/knowledge/*.md"] + [tool.pytest.ini_options] asyncio_mode = "auto" From aade48c1b1be9976bba92c986b3e6263cda31bf4 Mon Sep 17 00:00:00 2001 From: Cristian Gutierrez Date: Thu, 20 Aug 2026 10:15:36 +0200 Subject: [PATCH 2/2] feat(slack): announce new #support threads in Slack Post a Slack message through an Incoming Webhook whenever a thread is created in one of the channels listed in SUPPORT_CHANNEL_IDS. The message carries the thread title as a link, the author, the channel, and a preview of the opening message. Works for both text and forum channels. Design notes: - Incoming Webhook over a bot token: one env var, no OAuth or scopes. The destination channel is fixed in the Slack app. - A new SUPPORT_CHANNEL_IDS rather than reusing ALLOWED_CHANNELS, which answers a different question (where the bot replies, not what it reports). - httpx, not aiohttp: httpx is an explicit dependency; aiohttp only comes in transitively through discord.py. - discord.py only dispatches on_thread_create for newly created threads, so gateway re-joins cannot double-notify and no dedupe is needed. - THREAD_CREATE can outrun a forum post's opening message, which surfaces as NotFound, so that case gets one retry. Forbidden gets none. - User-controlled fields are HTML-escaped so a thread title cannot forge a Slack link, and 5xx/429 are retried while other 4xx are not. Also drop httpx to WARNING: it logs every request at INFO with the full URL, and the webhook secret lives in that URL, so the default would write the secret to the container logs on every notification. With SLACK_WEBHOOK_URL unset the notifier is a no-op and the bot behaves exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 8 ++ AGENTS.md | 14 +++ README.md | 8 ++ bot/base.py | 67 ++++++++++++ bot/config.py | 36 +++++-- bot/slack.py | 144 +++++++++++++++++++++++++ main.py | 6 ++ tests/test_on_thread_create.py | 186 ++++++++++++++++++++++++++++++++ tests/test_slack.py | 188 +++++++++++++++++++++++++++++++++ 9 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 bot/slack.py create mode 100644 tests/test_on_thread_create.py create mode 100644 tests/test_slack.py diff --git a/.env.example b/.env.example index a223daf..cab8fd5 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,11 @@ DOCS_REFRESH_INTERVAL=900 DOCS_USE_REMOTE=local DOCS_CACHE_DIR=vector_db/docs_cache DOCS_HTTP_TIMEOUT=10 + +# Slack notifications for new support threads (optional). +# Leave SLACK_WEBHOOK_URL empty to disable them entirely. +# SUPPORT_CHANNEL_IDS: comma-separated Discord channel IDs (text or forum) whose +# new threads are announced in Slack. +SLACK_WEBHOOK_URL= +SUPPORT_CHANNEL_IDS= +SLACK_HTTP_TIMEOUT=10 diff --git a/AGENTS.md b/AGENTS.md index df763c9..9166bc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,12 +42,26 @@ 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 +SUPPORT_CHANNEL_IDS= # Channel IDs cuyos hilos nuevos se avisan en Slack (coma-separados) +SLACK_HTTP_TIMEOUT=10 # Timeout (s) del POST al webhook ``` **Comportamiento**: El bot SOLO responde cuando lo mencionan (`@NaN Builders`). No responde automáticamente en canales de soporte. **Múltiples canales**: `ALLOWED_CHANNELS` acepta múltiples IDs separados por coma, ej: `123456789,987654321,111222333` +### Notificaciones a Slack + +Cuando se crea un hilo en un canal listado en `SUPPORT_CHANNEL_IDS` (ej. `#support`), el bot +publica un mensaje en Slack vía Incoming Webhook con el título del hilo, el autor, el canal +y un preview del mensaje inicial. Funciona con canales de texto y con canales de foro. + +Requiere `SLACK_WEBHOOK_URL`: en Slack, crear una app → **Incoming Webhooks** → activar → +**Add New Webhook to Workspace** y elegir el canal de destino. El canal se fija ahí, no en el `.env`. + +Sin `SLACK_WEBHOOK_URL` o sin `SUPPORT_CHANNEL_IDS` la feature queda inactiva y el bot funciona igual. + ### Intents de Discord **CRÍTICO**: En [Discord Developer Portal](https://discord.com/developers/applications) para la aplicación del bot, habilitar: diff --git a/README.md b/README.md index f8c228a..e579321 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Community Discord bot for [nan.builders](https://nan.builders). It answers membe - Slash commands (Discord interactions): `/metrics`, `/my-metrics`. - Daily token usage report posted to `STATUS_CHANNEL_ID` at `METRICS_SEND_HOUR` (UTC), pulled from the LiteLLM proxy. - HTTP health endpoint on port `9101` (`GET /health`) consumed by the Docker `HEALTHCHECK`. +- Slack notification on every new thread opened in the channels listed in `SUPPORT_CHANNEL_IDS` (e.g. `#support`), posted through an Incoming Webhook. - Doc-hash optimization: unchanged markdown files are skipped on startup, so embeddings are only recomputed when content actually changes. ## Tech stack @@ -32,6 +33,8 @@ 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. + ## Project structure ``` @@ -44,6 +47,7 @@ discord-bot/ │ ├── knowledge.py # SimpleVectorStore, chunking, doc loader │ ├── llm.py # LLMClient, CircuitBreaker, RAG prompt │ ├── metrics.py # LiteLLM spend log aggregation and reports +│ ├── slack.py # Slack Incoming Webhook notifier and payload builder │ └── docs/ │ └── knowledge/ # Embedded markdown corpus │ ├── intro.md @@ -68,6 +72,7 @@ discord-bot/ - A Discord application with a bot user, a token, and the following privileged intents enabled in the [Discord Developer Portal](https://discord.com/developers/applications): **MESSAGE CONTENT INTENT** and **SERVER MEMBERS INTENT**. Without them the bot fails to connect with `PrivilegedIntentsRequired`. - The bot invited to your guild with permissions to read messages, send messages, embed links, and use slash commands. - A LiteLLM API key. The bot defaults to `https://api.nan.builders/v1`; override with `LITELLM_BASE_URL` if you run your own gateway. +- For the Slack support-thread notifications: a Slack app with **Incoming Webhooks** enabled and a webhook added to the destination channel, plus the Discord bot having *View Channel* and *Read Message History* on the support channel so it can read the thread's opening message for the preview. - For the metrics features: network reachability to the LiteLLM proxy URL (defaults to `http://localhost:4000`, i.e. the bot is expected to run on the same host) and an admin key with read access to `/spend/logs/ui`. ### Local setup (without Docker) @@ -123,6 +128,9 @@ Auto-response is triggered when the bot is **mentioned** inside a channel listed | `ALLOWED_CHANNELS` | no | `""` (all channels) | Comma-separated Discord channel IDs the bot will respond in. Empty means every channel is allowed. | | `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_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`. | | `DOCS_REFRESH_INTERVAL` | no | `900` | Seconds between docs syncs when `DOCS_USE_REMOTE` is not `local`. Aligned with the web `Cache-Control`. | diff --git a/bot/base.py b/bot/base.py index 04e1723..26d4775 100644 --- a/bot/base.py +++ b/bot/base.py @@ -17,6 +17,7 @@ from bot.knowledge import SimpleVectorStore, load_documentation_from_remote from bot.llm import LLMClient from bot.metrics import send_metrics_report, send_user_metrics_report +from bot.slack import SlackNotifier, SupportThreadEvent # Rate limiting: max 3 mentions per user per 60-second window _RATE_LIMIT = 3 @@ -68,6 +69,7 @@ def __init__(self) -> None: ) self.llm = LLMClient() + self.slack = SlackNotifier() self.store: SimpleVectorStore | None = None self._initialized = False self._ready = False @@ -246,6 +248,71 @@ async def start_daily_metrics(self) -> None: else: logger.info("Metrics channel or LiteLLM admin key not configured, skipping daily metrics") + async def _fetch_starter_text(self, thread: discord.Thread) -> str: + """Best-effort text of the message that opened the thread. + + Forum posts carry their opening message inside the thread under the + thread's own ID. Threads started from a message in a text channel keep + that message in the parent channel, so there is nothing to fetch and the + preview is simply omitted. + """ + starter = thread.starter_message + if starter is not None: + return starter.content or "" + + # THREAD_CREATE can outrun the opening message, so a NotFound on the + # first try is worth one retry; Forbidden never is. + for delay in (0, 1.0): + if delay: + await asyncio.sleep(delay) + try: + message = await thread.fetch_message(thread.id) + except discord.Forbidden: + logger.debug("No permission to read the starter message of thread %s", thread.id) + return "" + except discord.HTTPException as e: + logger.debug("Could not fetch starter message for thread %s: %s", thread.id, type(e).__name__) + continue + return message.content or "" + return "" + + async def on_thread_create(self, thread: discord.Thread) -> None: + """Announce new threads in the configured support channels on Slack.""" + support_channels = settings.support_channel_id_set + if not support_channels or thread.parent_id not in support_channels: + return + + if not self.slack.enabled: + logger.warning("New thread in support channel but SLACK_WEBHOOK_URL is not configured") + return + + preview = await self._fetch_starter_text(thread) + + owner = thread.owner + author = owner.display_name if owner is not None else f"user {thread.owner_id}" + parent = thread.parent + channel_name = parent.name if parent is not None else str(thread.parent_id) + + event = SupportThreadEvent( + thread_name=thread.name, + thread_url=thread.jump_url, + channel_name=channel_name, + author=_sanitize_username(author), + preview=preview, + ) + + try: + sent = await asyncio.wait_for(self.slack.notify_support_thread(event), timeout=30.0) + except TimeoutError: + logger.error("Slack notification timed out for thread %s", thread.id) + return + except Exception as e: + logger.error("Slack notification failed for thread %s: %s", thread.id, type(e).__name__) + return + + if sent: + logger.info("Notified Slack about thread %s in #%s", thread.id, channel_name) + async def on_message(self, message: discord.Message) -> None: """Process incoming messages for auto-responses.""" if message.author == self.user: diff --git a/bot/config.py b/bot/config.py index 22199ba..51e9af7 100644 --- a/bot/config.py +++ b/bot/config.py @@ -5,6 +5,18 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +def _parse_channel_ids(raw: str) -> set[int]: + """Parse a comma-separated list of Discord snowflake IDs, ignoring junk.""" + if not raw: + return set() + ids = set() + for x in raw.split(","): + x = x.strip() + if x and x.isdigit() and len(x) < 22: + ids.add(int(x)) + return ids + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False) @@ -30,16 +42,18 @@ class Settings(BaseSettings): docs_cache_dir: str = "vector_db/docs_cache" docs_http_timeout: int = 10 + slack_webhook_url: str = "" + slack_http_timeout: int = 10 + support_channel_ids: str = "" + @property def allowed_channel_ids(self) -> set[int]: - if not self.allowed_channels: - return set() - ids = set() - for x in self.allowed_channels.split(","): - x = x.strip() - if x and x.isdigit() and len(x) < 22: - ids.add(int(x)) - return ids + return _parse_channel_ids(self.allowed_channels) + + @property + def support_channel_id_set(self) -> set[int]: + """Channel IDs whose new threads are announced in Slack.""" + return _parse_channel_ids(self.support_channel_ids) @property def status_channel_id_value(self) -> int | None: @@ -63,4 +77,10 @@ def status_channel_id_value(self) -> int | None: level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) + +# httpx logs every request at INFO with the full URL. SLACK_WEBHOOK_URL carries +# its secret in the path, so inheriting INFO from the root would write that +# secret to the container logs on every notification. +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger("nan-bot") diff --git a/bot/slack.py b/bot/slack.py new file mode 100644 index 0000000..75deabd --- /dev/null +++ b/bot/slack.py @@ -0,0 +1,144 @@ +"""Slack notifications via Incoming Webhook.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +import httpx + +from bot.config import logger, settings + +_BACKOFF_SECONDS = (0.5, 1.0, 2.0) +_MAX_TITLE_LEN = 200 +_MAX_PREVIEW_LEN = 600 + +# Slack mrkdwn requires these three characters to be HTML-escaped so user text +# cannot forge links or entities inside a block. +_ESCAPES = (("&", "&"), ("<", "<"), (">", ">")) + + +def escape_mrkdwn(text: str) -> str: + """Escape user-controlled text for safe inclusion in Slack mrkdwn.""" + for char, replacement in _ESCAPES: + text = text.replace(char, replacement) + return text + + +def _truncate(text: str, limit: int) -> str: + text = text.strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +@dataclass +class SupportThreadEvent: + """The data needed to announce a new Discord support thread in Slack.""" + + thread_name: str + thread_url: str + channel_name: str + author: str + preview: str = "" + + +def build_support_thread_payload(event: SupportThreadEvent) -> dict: + """Build the Slack Block Kit payload for a new support thread.""" + title = escape_mrkdwn(_truncate(event.thread_name, _MAX_TITLE_LEN)) or "(untitled)" + author = escape_mrkdwn(_truncate(event.author, _MAX_TITLE_LEN)) or "unknown" + channel = escape_mrkdwn(_truncate(event.channel_name, _MAX_TITLE_LEN)) + + blocks: list[dict] = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*<{event.thread_url}|{title}>*", + }, + }, + { + "type": "context", + "elements": [ + {"type": "mrkdwn", "text": f"Discord #{channel} · by *{author}*"}, + ], + }, + ] + + preview = _truncate(event.preview, _MAX_PREVIEW_LEN) + if preview: + blocks.insert( + 1, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f">{escape_mrkdwn(preview)}"}, + }, + ) + + return { + # Fallback text for notifications and clients that cannot render blocks. + "text": f"New support thread in Discord #{channel}: {title}", + "blocks": blocks, + } + + +class SlackNotifier: + """Posts messages to a Slack Incoming Webhook. + + Disabled (a no-op) when ``SLACK_WEBHOOK_URL`` is unset, so the bot runs + unchanged in environments without Slack configured. + """ + + def __init__(self, webhook_url: str | None = None, timeout: float | None = 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 + + @property + def enabled(self) -> bool: + return bool(self._webhook_url) + + def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self._timeout) + return self._client + + async def close(self) -> None: + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + self._client = None + + async def post(self, payload: dict) -> bool: + """POST a payload to the webhook. Returns True when Slack accepted it.""" + if not self.enabled: + logger.debug("Slack webhook not configured, skipping notification") + return False + + client = self._get_client() + last_error: str | None = None + + for attempt, backoff in enumerate((*_BACKOFF_SECONDS, None)): + try: + resp = await client.post(self._webhook_url, json=payload) + except httpx.HTTPError as e: + last_error = type(e).__name__ + else: + if resp.status_code < 400: + return True + # 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) + return False + last_error = f"HTTP {resp.status_code}" + + if backoff is None: + break + logger.warning("Slack webhook attempt %d failed (%s), retrying", attempt + 1, last_error) + await asyncio.sleep(backoff) + + logger.error("Slack webhook failed after %d attempts: %s", len(_BACKOFF_SECONDS) + 1, last_error) + return False + + async def notify_support_thread(self, event: SupportThreadEvent) -> bool: + """Announce a new Discord support thread in Slack.""" + return await self.post(build_support_thread_payload(event)) diff --git a/main.py b/main.py index 26c93c7..f2d0405 100644 --- a/main.py +++ b/main.py @@ -134,6 +134,12 @@ async def main() -> None: await bot.llm._embed_client.close() except Exception: pass + # Close Slack client + if bot.slack: + try: + await bot.slack.close() + except Exception: + pass # Stop health check server if hasattr(bot, "_health_server") and bot._health_server: try: diff --git a/tests/test_on_thread_create.py b/tests/test_on_thread_create.py new file mode 100644 index 0000000..4ce2b05 --- /dev/null +++ b/tests/test_on_thread_create.py @@ -0,0 +1,186 @@ +"""Tests for the Slack notification triggered by new support threads.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import discord +import pytest + +from bot.base import NanBot +from bot.config import settings + +SUPPORT_CHANNEL_ID = 111222333 +OTHER_CHANNEL_ID = 999888777 + + +@dataclass +class FakeOwner: + display_name: str = "crstian" + + +@dataclass +class FakeParent: + name: str = "support" + + +@dataclass +class FakeMessage: + content: str = "" + + +@dataclass +class FakeThread: + id: int = 555 + name: str = "El bot no responde" + parent_id: int = SUPPORT_CHANNEL_ID + owner_id: int = 42 + owner: FakeOwner | None = field(default_factory=FakeOwner) + parent: FakeParent | None = field(default_factory=FakeParent) + jump_url: str = "https://discord.com/channels/1/555" + starter_message: FakeMessage | None = None + # One entry per fetch_message call: a FakeMessage to return or an exception + # to raise. Exhausting the list keeps raising the last entry. + fetch_results: list[object] = field(default_factory=list) + fetch_calls: int = 0 + + async def fetch_message(self, message_id: int) -> FakeMessage: + assert message_id == self.id + self.fetch_calls += 1 + if not self.fetch_results: + raise discord.NotFound(_FakeResponse(), "unknown message") + index = min(self.fetch_calls - 1, len(self.fetch_results) - 1) + result = self.fetch_results[index] + if isinstance(result, Exception): + raise result + return result + + +class _FakeResponse: + status = 404 + reason = "Not Found" + + +class RecordingSlack: + def __init__(self, enabled: bool = True, result: bool = True) -> None: + self.enabled = enabled + self._result = result + self.events: list[object] = [] + + async def notify_support_thread(self, event) -> bool: + self.events.append(event) + return self._result + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch): + """Keep the starter-message retry backoff from slowing the suite down.""" + + async def instant(_seconds: float) -> None: + return None + + monkeypatch.setattr("bot.base.asyncio.sleep", instant) + + +@pytest.fixture +def bot(monkeypatch): + monkeypatch.setattr(settings, "support_channel_ids", str(SUPPORT_CHANNEL_ID)) + instance = NanBot() + instance.slack = RecordingSlack() + return instance + + +async def test_notifies_slack_for_a_thread_in_a_support_channel(bot): + thread = FakeThread(starter_message=FakeMessage("El health devuelve starting")) + + await bot.on_thread_create(thread) + + assert len(bot.slack.events) == 1 + event = bot.slack.events[0] + assert event.thread_name == "El bot no responde" + assert event.thread_url == thread.jump_url + assert event.channel_name == "support" + assert event.author == "crstian" + assert event.preview == "El health devuelve starting" + + +async def test_ignores_threads_from_other_channels(bot): + await bot.on_thread_create(FakeThread(parent_id=OTHER_CHANNEL_ID)) + assert bot.slack.events == [] + + +async def test_ignores_every_thread_when_no_support_channel_is_configured(monkeypatch): + monkeypatch.setattr(settings, "support_channel_ids", "") + instance = NanBot() + instance.slack = RecordingSlack() + + await instance.on_thread_create(FakeThread()) + + assert instance.slack.events == [] + + +async def test_does_nothing_when_slack_is_not_configured(bot): + bot.slack = RecordingSlack(enabled=False) + await bot.on_thread_create(FakeThread()) + assert bot.slack.events == [] + + +async def test_falls_back_to_fetching_the_forum_starter_message(bot): + thread = FakeThread(fetch_results=[FakeMessage("Cuerpo del post de foro")]) + + await bot.on_thread_create(thread) + + assert thread.fetch_calls == 1 + assert bot.slack.events[0].preview == "Cuerpo del post de foro" + + +async def test_retries_once_when_the_event_outruns_the_starter_message(bot): + thread = FakeThread( + fetch_results=[ + discord.NotFound(_FakeResponse(), "unknown message"), + FakeMessage("Llegó tarde pero llegó"), + ] + ) + + await bot.on_thread_create(thread) + + assert thread.fetch_calls == 2 + assert bot.slack.events[0].preview == "Llegó tarde pero llegó" + + +async def test_notifies_without_a_preview_when_the_message_never_appears(bot): + thread = FakeThread(fetch_results=[]) + + await bot.on_thread_create(thread) + + assert thread.fetch_calls == 2 + assert bot.slack.events[0].preview == "" + + +async def test_does_not_retry_when_reading_the_starter_message_is_forbidden(bot): + thread = FakeThread(fetch_results=[discord.Forbidden(_FakeResponse(), "missing access")]) + + await bot.on_thread_create(thread) + + assert thread.fetch_calls == 1 + assert bot.slack.events[0].preview == "" + + +async def test_uses_owner_id_and_parent_id_when_the_cache_is_empty(bot): + thread = FakeThread(owner=None, parent=None, starter_message=FakeMessage("hola")) + + await bot.on_thread_create(thread) + + event = bot.slack.events[0] + assert event.author == "user 42" + assert event.channel_name == str(SUPPORT_CHANNEL_ID) + + +async def test_a_slack_failure_does_not_propagate(bot): + class ExplodingSlack(RecordingSlack): + async def notify_support_thread(self, event) -> bool: + raise RuntimeError("slack down") + + bot.slack = ExplodingSlack() + + await bot.on_thread_create(FakeThread(starter_message=FakeMessage("hola"))) diff --git a/tests/test_slack.py b/tests/test_slack.py new file mode 100644 index 0000000..cf84720 --- /dev/null +++ b/tests/test_slack.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import httpx +import pytest + +from bot.config import _parse_channel_ids +from bot.slack import SlackNotifier, SupportThreadEvent, build_support_thread_payload, escape_mrkdwn + +EVENT = SupportThreadEvent( + thread_name="Bot no responde en prod", + thread_url="https://discord.com/channels/1/2", + channel_name="support", + author="crstian", + preview="Levanté el contenedor y el health devuelve starting.", +) + + +def _block_text(payload: dict) -> str: + parts = [] + for block in payload["blocks"]: + if "text" in block: + parts.append(block["text"]["text"]) + for element in block.get("elements", []): + parts.append(element["text"]) + return "\n".join(parts) + + +def test_payload_includes_thread_link_author_and_preview(): + payload = build_support_thread_payload(EVENT) + text = _block_text(payload) + + assert f"<{EVENT.thread_url}|{EVENT.thread_name}>" in text + assert "crstian" in text + assert "support" in text + assert "Levanté el contenedor" in text + # Fallback text is what Slack shows in notifications. + assert EVENT.thread_name in payload["text"] + + +def test_payload_omits_preview_section_when_there_is_no_starter_text(): + payload = build_support_thread_payload( + SupportThreadEvent( + thread_name="Sin cuerpo", + thread_url="https://discord.com/channels/1/2", + channel_name="support", + author="crstian", + ) + ) + assert len(payload["blocks"]) == 2 + + +def test_payload_escapes_mrkdwn_in_user_controlled_fields(): + payload = build_support_thread_payload( + SupportThreadEvent( + thread_name=" & co", + thread_url="https://discord.com/channels/1/2", + channel_name="support", + author="<@here>", + preview="a > b & c < d", + ) + ) + text = _block_text(payload) + + assert "") == "<a>" + + +async def test_notifier_is_disabled_without_a_webhook_url(): + notifier = SlackNotifier(webhook_url="") + assert notifier.enabled is False + assert await notifier.notify_support_thread(EVENT) is False + + +async def test_notifier_posts_the_payload_to_the_webhook(monkeypatch): + sent: list[dict] = [] + + 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._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + assert await notifier.notify_support_thread(EVENT) is True + assert len(sent) == 1 + assert sent[0]["blocks"] + await notifier.close() + + +async def test_notifier_does_not_retry_on_client_error(): + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(404, text="no_service") + + notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + assert await notifier.notify_support_thread(EVENT) is False + assert calls == 1 + await notifier.close() + + +async def test_notifier_retries_on_server_error_then_succeeds(monkeypatch): + monkeypatch.setattr("bot.slack._BACKOFF_SECONDS", (0, 0, 0)) + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls < 3: + return httpx.Response(503) + return httpx.Response(200, text="ok") + + notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + assert await notifier.notify_support_thread(EVENT) is True + assert calls == 3 + await notifier.close() + + +async def test_notifier_gives_up_after_exhausting_retries(monkeypatch): + monkeypatch.setattr("bot.slack._BACKOFF_SECONDS", (0, 0, 0)) + calls = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + raise httpx.ConnectError("boom", request=request) + + notifier = SlackNotifier(webhook_url="https://hooks.slack.test/services/T/B/X") + notifier._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + assert await notifier.notify_support_thread(EVENT) is False + assert calls == 4 + await notifier.close() + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("", set()), + ("123", {123}), + (" 123 , 456 ", {123, 456}), + ("123,,456", {123, 456}), + ("123,abc,456", {123, 456}), + ("1" * 22, set()), + ], +) +def test_parse_channel_ids(raw, expected): + assert _parse_channel_ids(raw) == expected + + +def test_httpx_request_logging_cannot_leak_the_webhook_secret(caplog): + """The webhook secret lives in the URL path, so httpx must not log requests.""" + import logging + + import bot.config # noqa: F401 (importing configures logging) + + assert logging.getLogger("httpx").level >= logging.WARNING