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/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/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/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" 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