Skip to content
Draft
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
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,19 @@ 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.
# With neither SLACK_WEBHOOK_URL nor SLACK_BOT_TOKEN set they are disabled.
# SLACK_WEBHOOK_URL must start with https://hooks.slack.com/services/.
# 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

# Slack Web API (preferred over the webhook). With a bot token the bot also
# mirrors every human message of a support thread as a reply in the Slack
# thread, which a webhook cannot do: it never returns the message ts.
# Needs scope chat:write and the app invited to the channel (/invite @app).
# SLACK_BOT_TOKEN must be a bot token (xoxb-...) and needs SLACK_CHANNEL_ID;
# otherwise token mode is disabled with one error at startup.
SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=
33 changes: 29 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ ALLOWED_CHANNELS= # Channel IDs donde el bot responde (múltiple
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 (https://hooks.slack.com/services/...); vacío o cualquier otra URL = notificaciones deshabilitadas
SLACK_BOT_TOKEN= # Bot token (xoxb-...) con scope chat:write; habilita el espejo de hilos. Otro prefijo = modo token deshabilitado
SLACK_CHANNEL_ID= # Canal destino cuando se usa SLACK_BOT_TOKEN; obligatorio con él
SUPPORT_CHANNEL_IDS= # Channel IDs cuyos hilos nuevos se avisan en Slack (coma-separados)
SLACK_HTTP_TIMEOUT=10 # Timeout (s) del POST al webhook
SLACK_HTTP_TIMEOUT=10 # Timeout (s) de las peticiones a Slack
```

**Comportamiento**: El bot SOLO responde cuando lo mencionan (`@NaN Builders`). No responde automáticamente en canales de soporte.
Expand All @@ -54,13 +56,36 @@ SLACK_HTTP_TIMEOUT=10 # Timeout (s) del POST al webhook
### 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
publica un mensaje en Slack (vía Web API con bot token o, si no hay token, 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 →
Modo webhook (`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.
Sin Slack configurado o sin `SUPPORT_CHANNEL_IDS` la feature queda inactiva y el bot funciona igual.

**Dos modos, y el bot prefiere el segundo:**

| | Webhook (`SLACK_WEBHOOK_URL`) | Bot token (`SLACK_BOT_TOKEN` + `SLACK_CHANNEL_ID`) |
|---|---|---|
| Avisa de hilos nuevos | Sí | Sí |
| Espeja los mensajes del hilo | **No** | Sí |
| Configuración | Una URL | App con scope `chat:write` e invitada al canal |

El webhook **no puede** espejar hilos: su respuesta no incluye el `ts` del mensaje,
y sin ese `ts` no hay forma de colgar respuestas de él. La Web API sí lo devuelve.

Modo token: app de Slack con scope `chat:write`, instalada en el workspace e invitada al canal
(`/invite @app`). Si falta `SLACK_CHANNEL_ID`, falta el token o el token no empieza por `xoxb-`, el
modo token se deshabilita con un único error al arrancar y el bot usa el webhook si lo hay.

El espejo se ejecuta en segundo plano (no retrasa la respuesta del bot en Discord), usa
`clean_content` (las menciones llegan como `@nombre`) y responde en el canal de Slack guardado
con el hilo. Reintenta `ratelimited`, `internal_error`, `service_unavailable`, errores de red y
respuestas no JSON; cualquier otro error de Slack se descarta sin reintentar.

El mapeo `hilo de Discord -> ts de Slack` se guarda en `vector_db/slack_threads.db`
(dentro del volumen, así sobrevive a los despliegues) y se purga a los 30 días.

### Intents de Discord

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ 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.
- Slack notification on every new thread opened in the channels listed in `SUPPORT_CHANNEL_IDS` (e.g. `#support`), posted through the Slack Web API (`chat.postMessage` with a bot token) or, as a fallback, an Incoming Webhook.
- Mirroring of support threads into Slack: with a bot token (not with a webhook), every human message posted in a tracked thread is echoed as a reply under the Slack announcement.
- Doc-hash optimization: unchanged markdown files are skipped on startup, so embeddings are only recomputed when content actually changes.

## Tech stack
Expand All @@ -35,6 +36,8 @@ Metrics live in `bot/metrics.py`. They hit the LiteLLM proxy `/spend/logs/ui` en

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.

When `SLACK_BOT_TOKEN` (an `xoxb-` bot token) and `SLACK_CHANNEL_ID` are both set, `SlackApiClient` takes over and posts through `chat.postMessage` instead. That is what makes mirroring possible: the Web API returns the message `ts`, which an Incoming Webhook never does, and a threaded reply needs it as `thread_ts`. The `ts` is stored in `vector_db/slack_threads.db` by `bot/thread_map.py` — inside the Docker volume, so a redeploy does not orphan live threads — and purged after 30 days. `NanBot._mirror_to_slack` then echoes each human message of a tracked thread (`clean_content`, so mentions read as `@name`), skipping bots and the forum opening message (already the body of the announcement). It runs as a background task, so a slow Slack never delays the bot's own answer, and replies go to the Slack channel stored with the thread, not the current `SLACK_CHANNEL_ID`. Posts are serialised behind a lock with a ~1 s spacing to respect Slack's per-channel rate limit. The Web API reports errors inside an HTTP 200 body, so `SlackApiClient` reads the `ok`/`error` fields: it retries `ratelimited`, `internal_error`, `service_unavailable`, network errors and non-JSON bodies (e.g. an HTML 502) with the same 0.5/1/2 s backoff, and gives up at once on any other error (`not_in_channel`, `invalid_auth`…). A half-configured token mode (token without channel, channel without token, or a token that is not `xoxb-`) disables it with one error at startup, and the bot falls back to the webhook if one is set.

## Project structure

```
Expand All @@ -47,7 +50,8 @@ 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
│ ├── slack.py # Slack notifier (webhook + Web API) and payload builders
│ ├── thread_map.py # Discord thread -> Slack ts mapping (SQLite)
│ └── docs/
│ └── knowledge/ # Embedded markdown corpus
│ ├── intro.md
Expand All @@ -72,7 +76,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 Slack support-thread notifications: either a Slack app with the `chat:write` bot scope, installed to the workspace and invited to the destination channel (`/invite @app`), which also enables thread mirroring; or a Slack app with **Incoming Webhooks** enabled and a webhook added to the destination channel (announcements only). In both cases, 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 @@ -130,7 +134,9 @@ Auto-response is triggered when the bot is **mentioned** inside a channel listed
| `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. 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. |
| `SLACK_BOT_TOKEN` | no | `""` (webhook mode) | Slack bot token (`xoxb-…`) with `chat:write`. Enables thread mirroring; takes precedence over the webhook. Any other prefix, or a missing `SLACK_CHANNEL_ID`, disables token mode with one error at startup. |
| `SLACK_CHANNEL_ID` | no | `""` | Destination Slack channel ID. Required with `SLACK_BOT_TOKEN`. |
| `SLACK_HTTP_TIMEOUT` | no | `10` | Per-request HTTP timeout (seconds) for Slack requests (webhook POST and `chat.postMessage`). |
| `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
101 changes: 97 additions & 4 deletions bot/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections import defaultdict
from datetime import UTC
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread

import discord
Expand All @@ -17,7 +18,8 @@
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
from bot.slack import SlackApiClient, SlackNotifier, SupportThreadEvent
from bot.thread_map import SlackThreadMap

# Rate limiting: max 3 mentions per user per 60-second window
_RATE_LIMIT = 3
Expand Down Expand Up @@ -69,7 +71,15 @@ def __init__(self) -> None:
)

self.llm = LLMClient()
# The Web API is preferred: only it returns the message ts that thread
# replies need. The webhook stays as a fallback so a deployment without
# a bot token keeps announcing threads, just without mirroring.
self.slack = SlackNotifier()
self.slack_api = SlackApiClient()
# Same directory main.py gives the vector store: it is the Docker
# volume, so the mapping survives redeploys. (config.DB_DIR points at
# bot/vector_db, which is not mounted, and is unused.)
self.thread_map = SlackThreadMap(Path("vector_db"))
self.store: SimpleVectorStore | None = None
self._initialized = False
self._ready = False
Expand All @@ -82,6 +92,9 @@ def __init__(self) -> None:
self._docs_last_sync_ok: bool = False
self._docs_refresh_task: asyncio.Task[None] | None = None
self._docs_refresh_lock = asyncio.Lock()
# Strong references to fire-and-forget tasks (the Slack mirror), so the
# event loop's weak references do not let them be garbage-collected.
self._bg_tasks: set[asyncio.Task[None]] = set()

def _start_health_server(self) -> None:
"""Start a lightweight HTTP health check server in a background thread."""
Expand Down Expand Up @@ -159,6 +172,11 @@ async def on_ready(self) -> None:
)
await self.start_daily_metrics()

try:
self.thread_map.purge_older_than()
except Exception as e:
logger.warning("Could not purge stale Slack thread mappings: %s", type(e).__name__)

if self._docs_refresh_task is None or self._docs_refresh_task.done():
self._docs_refresh_task = asyncio.create_task(self._schedule_docs_refresh())
else:
Expand Down Expand Up @@ -282,8 +300,8 @@ async def on_thread_create(self, thread: discord.Thread) -> None:
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")
if not (self.slack_api.enabled or self.slack.enabled):
logger.warning("New thread in a support channel but Slack is not configured")
return

preview = await self._fetch_starter_text(thread)
Expand All @@ -302,22 +320,97 @@ async def on_thread_create(self, thread: discord.Thread) -> None:
)

try:
sent = await asyncio.wait_for(self.slack.notify_support_thread(event), timeout=30.0)
if self.slack_api.enabled:
ts = await asyncio.wait_for(self.slack_api.notify_support_thread(event), timeout=30.0)
sent = ts is not None
else:
ts = None
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 ts:
# Without this the thread is announced but every later message has
# nothing to hang off. Slack already has the message, so a failure
# here is a storage problem, not a notification one.
try:
self.thread_map.remember(thread.id, ts, self.slack_api.channel)
except Exception as e:
logger.error(
"Thread %s was announced in Slack but could not store the thread mapping: %s",
thread.id,
type(e).__name__,
)

if sent:
logger.info("Notified Slack about thread %s in #%s", thread.id, channel_name)

async def _mirror_to_slack(self, message: discord.Message) -> None:
"""Mirror a message posted in a tracked support thread into Slack."""
# The bot's own answers are long LLM replies; Slack mirrors the human
# conversation only. Other bots are skipped for the same reason.
if message.author.bot:
return

channel = message.channel
if not isinstance(channel, discord.Thread):
return
if channel.parent_id not in settings.support_channel_id_set:
return

# A forum post's opening message arrives here too, but it is already the
# body of the announcement, so mirroring it would duplicate it.
if message.id == channel.id:
return

mapping = self.thread_map.lookup(channel.id)
if mapping is None:
# Threads opened before mirroring existed, or announced while Slack
# was down, have no parent to reply to.
logger.debug("No Slack thread mapped for Discord thread %s", channel.id)
return

thread_ts, slack_channel = mapping
# clean_content turns <@123…> mentions into readable @names; Slack
# cannot resolve Discord IDs.
content = message.clean_content or ""
if message.attachments:
names = ", ".join(a.filename for a in message.attachments[:5])
content = f"{content}\n[attachments: {names}]".strip()
if not content:
return

try:
await asyncio.wait_for(
self.slack_api.reply_in_thread(
thread_ts,
_sanitize_username(message.author.display_name),
content,
channel=slack_channel,
),
timeout=30.0,
)
except TimeoutError:
logger.error("Slack mirror timed out for message %s", message.id)
except Exception as e:
logger.error("Slack mirror failed for message %s: %s", message.id, type(e).__name__)

async def on_message(self, message: discord.Message) -> None:
"""Process incoming messages for auto-responses."""
if message.author == self.user:
return

if self.slack_api.enabled:
# In the background: a slow Slack (30 s timeout plus retries) must
# not delay the bot's own answer in Discord.
task = asyncio.create_task(self._mirror_to_slack(message))
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)

# Delegate to slash commands before processing auto-responses
await self.process_commands(message)

Expand Down
2 changes: 2 additions & 0 deletions bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class Settings(BaseSettings):
docs_http_timeout: int = 10

slack_webhook_url: str = ""
slack_bot_token: str = ""
slack_channel_id: str = ""
slack_http_timeout: int = 10
support_channel_ids: str = ""

Expand Down
Loading