Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

```
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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`. |
Expand Down
67 changes: 67 additions & 0 deletions bot/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 28 additions & 8 deletions bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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:
Expand All @@ -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")
144 changes: 144 additions & 0 deletions bot/slack.py
Original file line number Diff line number Diff line change
@@ -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 = (("&", "&amp;"), ("<", "&lt;"), (">", "&gt;"))


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))
6 changes: 6 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading