From a1e119ce6e3934bb7dcd841c3758755b38e811a4 Mon Sep 17 00:00:00 2001 From: febux Date: Sun, 6 Sep 2026 14:53:22 +0300 Subject: [PATCH] M1: bot webhook delivery - secret-token aiohttp server, /healthz, mode switch, TLS-edge routing (#001, #002) --- .env.bot.example | 24 ++ .env.example | 8 + Makefile | 7 +- .../templates/contract-check.conf.template | 23 ++ docker-compose.bot.yml | 26 +- docker-compose.yml | 11 + docs/ARCHITECTURE.md | 23 +- docs/DEPLOY.md | 124 +++++++-- pyproject.toml | 3 + src/contract_check/bot/__main__.py | 53 +++- src/contract_check/bot/config.py | 53 +++- src/contract_check/bot/webhook.py | 113 ++++++++ tests/unit/test_bot_boundary.py | 1 + tests/unit/test_bot_webhook.py | 256 ++++++++++++++++++ uv.lock | 4 + 15 files changed, 698 insertions(+), 31 deletions(-) create mode 100644 src/contract_check/bot/webhook.py create mode 100644 tests/unit/test_bot_webhook.py diff --git a/.env.bot.example b/.env.bot.example index db29601..4580e9c 100644 --- a/.env.bot.example +++ b/.env.bot.example @@ -26,3 +26,27 @@ API_URL= # If you point this at an external Redis, it must be reachable over a private # network/VPN; never expose Redis to the public internet. REDIS_URL=redis://redis:6379/0 + +# ── Update delivery mode ───────────────────────────────────────────────────── +# polling (default) or webhook. In webhook mode the bot receives Telegram +# updates at a secret-derived path on the webhook port and serves GET /healthz. +# See docs/DEPLOY.md §14.4 for the full setup (edge routing, path derivation). +BOT_UPDATE_MODE=polling + +# Required when BOT_UPDATE_MODE=webhook: +# BOT_WEBHOOK_PUBLIC_BASE_URL — public https base URL of the TLS edge that +# routes /tg-webhook/ to this server, no trailing +# slash (e.g. https://contract-check.example.com). +# BOT_WEBHOOK_SECRET_TOKEN — random string Telegram echoes back in the +# X-Telegram-Bot-Api-Secret-Token header (chars: A-Z a-z 0-9 _ -, max +# 256). Generate with: openssl rand -hex 32 +# It also keys the secret-derived webhook path (`make bot-webhook-path`). +BOT_WEBHOOK_PUBLIC_BASE_URL= +BOT_WEBHOOK_SECRET_TOKEN= + +# Host port publishing for the webhook server (webhook mode only). +# The compose file binds it to 127.0.0.1 by default — route it through your +# TLS edge on the same host, or set BOT_WEBHOOK_BIND_HOST to a private/VPN +# interface. Never expose the webhook port to the public internet directly. +BOT_WEBHOOK_BIND_HOST=127.0.0.1 +BOT_WEBHOOK_PORT=8080 diff --git a/.env.example b/.env.example index 42c1b42..f0766a1 100644 --- a/.env.example +++ b/.env.example @@ -194,3 +194,11 @@ BOT_SERVICE_TOKEN= # bearer looked up against service_tokens.name="bo API_URL=http://api:8000 # base URL of the api service. In compose this is the api container. # For a bot running on a separate server point this at the public # API endpoint, e.g. https://contract-check.example.com (no trailing slash). + +# Update delivery mode: polling (default) or webhook. Webhook mode serves +# updates at a secret-derived path + GET /healthz on the bot container; the +# edge profile proxies BOT_WEBHOOK_PATH to it. Full guide: docs/DEPLOY.md §14.4. +BOT_UPDATE_MODE=polling +BOT_WEBHOOK_PUBLIC_BASE_URL= # required when BOT_UPDATE_MODE=webhook, e.g. https://contract-check.example.com +BOT_WEBHOOK_SECRET_TOKEN= # required when BOT_UPDATE_MODE=webhook; openssl rand -hex 32 (chars: A-Za-z0-9_-) +BOT_WEBHOOK_PATH=/tg-webhook/change-me # derived path for the nginx edge; print the real one with `make bot-webhook-path` diff --git a/Makefile b/Makefile index dc5ac71..9be4457 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ observer-up observer-down observer-logs \ nginx-up nginx-down nginx-logs nginx-ps \ api api-logs bot bot-up bot-down bot-logs bot-ps worker-extract worker-analyze worker-notify \ - bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps \ + bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps bot-webhook-path \ seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \ shell-db admin-promote admin-list clean dev dev-obs stop stop-obs @@ -198,6 +198,11 @@ bot-remote-logs: ## Tail remote bot logs bot-remote-ps: ## Show remote bot container status docker compose -f docker-compose.bot.yml ps +bot-webhook-path: ## Print the secret-derived Telegram webhook path (reads BOT_TOKEN + BOT_WEBHOOK_SECRET_TOKEN from .env) + @uv run python -c "import os; from dotenv import load_dotenv; load_dotenv(); \ + from src.contract_check.bot.webhook import derive_webhook_path; \ + print(derive_webhook_path(os.environ['BOT_TOKEN'], os.environ['BOT_WEBHOOK_SECRET_TOKEN']))" + worker-extract: ## Start/restart extract worker docker compose --profile services up -d --build --remove-orphans worker-extract diff --git a/deploy/nginx/templates/contract-check.conf.template b/deploy/nginx/templates/contract-check.conf.template index e62475a..ad11065 100644 --- a/deploy/nginx/templates/contract-check.conf.template +++ b/deploy/nginx/templates/contract-check.conf.template @@ -108,6 +108,29 @@ server { proxy_read_timeout 60s; } + # Telegram bot webhook (bot service, profile `bot`, webhook update mode). + # The path is secret-derived (HMAC of BOT_WEBHOOK_SECRET_TOKEN keyed by + # BOT_TOKEN — print it with `make bot-webhook-path`) and served ONLY + # through this TLS edge. The bot itself additionally validates the + # X-Telegram-Bot-Api-Secret-Token header and answers 403 on mismatch, so + # the path alone never grants access. Default keeps the location valid + # while the bot runs in polling mode. + location ${BOT_WEBHOOK_PATH} { + # Variable forces dynamic DNS resolution for `bot`. + set $bot http://bot:8080; + proxy_pass $bot; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 60s; + } + # Grafana / Loki: exposed under /grafana so everything lives on one domain. # In production restrict access here or via firewall (basic auth / VPN / SSO). location /grafana/ { diff --git a/docker-compose.bot.yml b/docker-compose.bot.yml index dd7135f..4589b1b 100644 --- a/docker-compose.bot.yml +++ b/docker-compose.bot.yml @@ -1,9 +1,15 @@ # Telegram bot adapter — run on a separate server. # -# The bot is deliberately stateless: it polls Telegram and talks to the central +# The bot is deliberately stateless: it talks to Telegram and the central # API over HTTP(S). It needs NO DB/MQ/S3 credentials, only BOT_TOKEN, # BOT_SERVICE_TOKEN and API_URL. Keep this server lean and firewall-hardened. # +# Update mode: polling by default; set BOT_UPDATE_MODE=webhook to serve +# updates at a secret-derived path (+ GET /healthz) on the webhook port. +# The port is published on loopback only — route it through your TLS edge +# (or a VPN interface via BOT_WEBHOOK_BIND_HOST), never the public internet. +# See docs/DEPLOY.md §14.4. +# # Usage on the bot server: # cp .env.bot.example .env # minimal bot-only environment # docker compose -f docker-compose.bot.yml up -d --build @@ -27,17 +33,33 @@ services: BOT_TOKEN: ${BOT_TOKEN} API_URL: ${API_URL} BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN} + # Update delivery mode + webhook settings (BOT_UPDATE_MODE=polling|webhook). + BOT_UPDATE_MODE: ${BOT_UPDATE_MODE:-polling} + BOT_WEBHOOK_PUBLIC_BASE_URL: ${BOT_WEBHOOK_PUBLIC_BASE_URL:-} + BOT_WEBHOOK_SECRET_TOKEN: ${BOT_WEBHOOK_SECRET_TOKEN:-} # Local Redis for rate-limit state. Set to empty to use the in-memory # backend (fine for a single bot instance). The Redis container below is # not exposed outside the host; only the bot container can reach it. REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + ports: + # Webhook/healthz server, only meaningful in webhook mode. Published on + # loopback by default so only a local TLS edge / tunnel can reach it; + # use BOT_WEBHOOK_BIND_HOST to point it at a private interface instead. + - "${BOT_WEBHOOK_BIND_HOST:-127.0.0.1}:${BOT_WEBHOOK_PORT:-8080}:8080" depends_on: redis: condition: service_healthy healthcheck: + # Webhook mode: probe the bot's own /healthz (the process under + # supervision). Polling mode: keep probing the central API as before. test: - CMD-SHELL - - "python -c \"import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)\"" + - >- + if [ "$$BOT_UPDATE_MODE" = "webhook" ]; then + python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=5)"; + else + python -c "import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)"; + fi interval: 30s timeout: 5s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 54f967b..4199d3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -316,6 +316,13 @@ services: BOT_TOKEN: ${BOT_TOKEN:-} API_URL: ${API_URL:-http://api:8000} BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN:-} + # Update delivery: polling (default) or webhook. In webhook mode the bot + # serves updates + GET /healthz on port 8080 inside the container; the + # edge profile proxies the secret path (BOT_WEBHOOK_PATH) here. + # See docs/DEPLOY.md §14.4. + BOT_UPDATE_MODE: ${BOT_UPDATE_MODE:-polling} + BOT_WEBHOOK_PUBLIC_BASE_URL: ${BOT_WEBHOOK_PUBLIC_BASE_URL:-} + BOT_WEBHOOK_SECRET_TOKEN: ${BOT_WEBHOOK_SECRET_TOKEN:-} # ── EDGE (profile: edge) ────────────────────────────────────────────────── # Reverse proxy + TLS terminator. Listens on 80/443 and forwards @@ -347,6 +354,10 @@ services: NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template # Passed to the template so the OpenObserve subpath can be changed in one place. OPENOBSERVE_BASE_URI: ${OPENOBSERVE_BASE_URI:-/openobserve} + # Secret-derived Telegram bot webhook path (print with `make + # bot-webhook-path`). Proxied to the bot service inside the TLS edge; + # the placeholder default keeps the config valid in polling mode. + BOT_WEBHOOK_PATH: ${BOT_WEBHOOK_PATH:-/tg-webhook/change-me} healthcheck: test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"] interval: 10s diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 63ea2db..02493ac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -262,7 +262,7 @@ DealDocumentScreening/ │ └── bot/ (new — aiogram adapter, HTTP-only) │ ├── __init__.py │ ├── __main__.py -│ ├── config.py (BotSettings: BOT_TOKEN, API_URL, BOT_SERVICE_TOKEN, poll tuning) +│ ├── config.py (BotSettings: BOT_TOKEN, API_URL, BOT_SERVICE_TOKEN, poll tuning, update-mode/webhook settings) │ ├── client.py (ApiClient: typed httpx wrapper; ApiError/NoCreditsError/...) │ └── handlers.py (/start, doc upload→POST api, poll→send report) └── tests/ @@ -294,10 +294,11 @@ DealDocumentScreening/ - `core/*` may import anything. - `api/*`, `worker_extract/*`, `worker_prescreen/*`, `worker_analyze/*` import only `core/*`. -- `bot/*` imports only `httpx` + its own modules. **It must NOT import - `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It speaks to - the api over HTTP only. (Add a ruff/flake8 import-forbidden rule or a unit - test that asserts this.) +- `bot/*` imports only `httpx` (outbound to the api), `aiohttp` (inbound + webhook-mode transport, aiogram's HTTP engine) + its own modules. **It must + NOT import `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`.** It + speaks to the api over HTTP only. (Add a ruff/flake8 import-forbidden rule + or a unit test that asserts this.) --- @@ -1170,6 +1171,10 @@ None of 1–5 requires touching `core/` application code — only compose/infra. | `BOT_TOKEN` | (required) | | `API_URL` | `http://api:8000` | | `BOT_SERVICE_TOKEN` | (required — bearer for adapter auth to `/api/v1/auth/telegram/bot`, looked up against `service_tokens`) | +| `BOT_UPDATE_MODE` | `polling` — `webhook` runs the aiohttp server from `bot/webhook.py`: secret-path updates (403 on `X-Telegram-Bot-Api-Secret-Token` mismatch) + `GET /healthz`; registers/deregisters the Telegram webhook on start/stop | +| `BOT_WEBHOOK_PUBLIC_BASE_URL` | (empty; required in webhook mode) public https base URL of the TLS edge, no trailing slash | +| `BOT_WEBHOOK_SECRET_TOKEN` | (empty; required in webhook mode) `A-Za-z0-9_-`, max 256; keys both the header check and the HMAC-derived path | +| `BOT_WEBHOOK_LISTEN_HOST` / `BOT_WEBHOOK_LISTEN_PORT` | `0.0.0.0` / `8080` — webhook server bind (inside the container) | ### Prototype (standalone benchmark image) @@ -1619,6 +1624,14 @@ aiogram 3. Pure HTTP client to the api. **Forbidden imports**: `core.db`, `core.s3`, `core.llm`, `core.mq`, `core.credits`, `sqlalchemy`, `minio`, `aio_pika`, `pymupdf`. Enforced by a unit test (§14). +Update delivery is config-driven (`BOT_UPDATE_MODE`, §11): long-polling by +default; in webhook mode `bot/webhook.py` runs a small aiohttp server — +secret-derived path (`/tg-webhook/`), +403 on `X-Telegram-Bot-Api-Secret-Token` mismatch before the dispatcher, +`GET /healthz`, `set_webhook(drop_pending_updates)` on startup and +`delete_webhook` on graceful shutdown. Transport only; the api boundary is +unchanged (docs/DEPLOY.md §14.4). + Flow: - `/start` → `GET /api/v1/me` → greeting + credit balance. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index f21c14a..47ddfca 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -356,11 +356,10 @@ docker compose exec postgres psql -U contract_check -d contract_check \ 1. Напишите `@BotFather` → `/newbot` 2. Скопируйте токен (`BOT_TOKEN`) в `.env` -3. Установите webhook (опционально, polling работает по умолчанию): - ```bash - curl -F "url=https://your-domain.com/webhook" \ - https://api.telegram.org/bot$BOT_TOKEN/setWebhook - ``` +3. Режим доставки updates выбирается переменной `BOT_UPDATE_MODE`: + по умолчанию `polling` (ничего настраивать не нужно). Для webhook-режима + смотрите §14.4 — вручную вызывать `setWebhook` через curl не нужно, бот + сам регистрирует webhook на старте и снимает его при остановке. ### 7.2 Запуск @@ -569,9 +568,11 @@ docker compose --profile bot logs -f bot # Если бот на отдельном сервере — смотрите логи там: # docker compose -f docker-compose.bot.yml logs -f -# Проверка polling: -# Бот использует polling по умолчанию (aiogram). Если webhook установлен — -# убедитесь, что Nginx проксирует /webhook к api:8000. +# Проверка режима доставки: +# Бот использует polling по умолчанию (BOT_UPDATE_MODE не задан или polling). +# В webhook-режиме проверьте: /healthz на ботовом сервере отвечает 200, +# edge маршрутизирует BOT_WEBHOOK_PATH на бот, секретный заголовок совпадает +# (см. §14.4.3). ``` ## 13. Edge proxy (Nginx + certbot) @@ -631,6 +632,7 @@ curl https://contract-check.example.com/healthz - `/healthz`, `/readyz` - `/metrics` (открыт наружу; закройте файрволом или уберите приватный скрейпинг) - `/webhook/*` → `/api/v1/webhooks/` +- `${BOT_WEBHOOK_PATH}` → `bot:8080` (Telegram-бот в webhook-режиме, профиль `bot`; см. §14.4) - `/grafana/*` → `grafana:3000` (когда поднят профиль `obs`) Grafana под путём `/grafana`: @@ -699,11 +701,15 @@ docker compose down -v | Направление | Протокол | Порт | Комментарий | |---|---|---|---| -| bot → Telegram | outbound HTTPS | 443 | Polling updates от `api.telegram.org` | +| bot → Telegram | outbound HTTPS | 443 | Polling updates / API-вызовы `api.telegram.org`; в webhook-режиме — только API-вызовы | | bot → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` | | bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым | +| edge → bot (webhook-режим) | HTTP | 8080 | Только с TLS-edge (nginx) на приватном интерфейсе/loopback; наружу порт не публикуется | -На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH). Входящих портов бот не требует. +На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH). +В polling-режиме входящих портов бот не требует. В webhook-режиме порт 8080 +публикуется compose-файлом только на `127.0.0.1` (см. `BOT_WEBHOOK_BIND_HOST`) +— наружу бот доступен исключительно через TLS-edge. ### 14.2 Подготовка центрального API @@ -734,6 +740,7 @@ docker compose --profile services exec api python -m src.contract_check.api seed BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod' API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash REDIS_URL=redis://redis:6379/0 # локальный Redis из docker-compose.bot.yml + BOT_UPDATE_MODE=polling # webhook-режим: см. §14.4 ``` `docker-compose.bot.yml` поднимает собственный Redis-контейнер (только для @@ -767,15 +774,99 @@ docker compose --profile services exec api python -m src.contract_check.api seed "import urllib.request, os; print(urllib.request.urlopen(os.environ['API_URL'] + '/healthz').read())" ``` -### 14.4 Webhook вместо polling (опционально, для HA) +### 14.4 Webhook вместо polling (встроенный режим) -По умолчанию бот использует **polling**. Два и более инстанса с одним токеном в polling-режиме будут конфликтовать (Telegram отдаёт updates только одному соединению). Для высокой доступности: +Бот поддерживает два режима доставки updates — переключение одной переменной: -1. Переведите бота на webhook: укажите публичный URL, за который отвечает Nginx, и проксируйте запросы на ботовый сервер. -2. В боте реализуйте webhook-эндпоинт (не входит в текущую версию; требуется небольшая доработка `bot/__main__.py` и добавление HTTP-сервера, например aiohttp/uvicorn). -3. Используйте `docker-compose.bot.yml` с healthcheck и `deploy.replicas`, но за балансировщиком, который направляет Telegram-запросы на одну активную реплику (либо на все, если webhook-эндпоинт идемпотентен и дедуплицирует updates по `update_id`). +```bash +BOT_UPDATE_MODE=polling # по умолчанию: long-polling (локальная разработка) +BOT_UPDATE_MODE=webhook # продакшн: HTTP-сервер + регистрация webhook в Telegram +``` -> Для большинства production-развёртываний polling на выделенном ботовом сервере достаточен. +В webhook-режиме бот поднимает маленький aiohttp-сервер, который: + +- принимает Telegram updates по секретному пути `/tg-webhook/` и + проверяет заголовок `X-Telegram-Bot-Api-Secret-Token` (сравнение в + constant-time); неправильный или отсутствующий токен → **403** до + диспетчеризации, поэтому подделанные updates не могут тратить кредиты + пользователей; +- отвечает `GET /healthz` (200) — compose healthcheck и оркестратор + перезапускают умерший процесс; +- на старте регистрирует webhook (`set_webhook` с `drop_pending_updates`), + при graceful shutdown снимает его (`delete_webhook`) — стейджинг и прод + никогда не дерутся за один токен, и на ботовом сервере не остаётся + устаревший webhook. + +#### 14.4.1 Переменные окружения webhook-режима + +| Переменная | Обязательна | Описание | +|---|---|---| +| `BOT_UPDATE_MODE` | — | `polling` (default) / `webhook` | +| `BOT_WEBHOOK_PUBLIC_BASE_URL` | в webhook-режиме | Публичный https-базовый URL TLS-edge без trailing slash, напр. `https://contract-check.example.com` | +| `BOT_WEBHOOK_SECRET_TOKEN` | в webhook-режиме | Секрет для `X-Telegram-Bot-Api-Secret-Token` и деривации пути. Только `A-Z a-z 0-9 _ -`, до 256 символов; генерируйте `openssl rand -hex 32` | +| `BOT_WEBHOOK_BIND_HOST` | — | Куда публиковать порт в `docker-compose.bot.yml` (default `127.0.0.1`); используйте адрес приватного/VPN-интерфейса, не публичный | +| `BOT_WEBHOOK_PORT` | — | Хостовый порт webhook-сервера в `docker-compose.bot.yml` (default `8080`; внутри контейнера всегда 8080) | + +Polling-режим не требует ни одной из них и остаётся ровно тем же кодом, что и +до появления webhook-режима. + +#### 14.4.2 Секретный путь и маршрутизация через edge + +Путь вебхука вычисляется детерминированно из секретов — +HMAC-SHA256(секрет, ключ = токен бота), первые 32 hex-символа: + +```bash +make bot-webhook-path +# → /tg-webhook/81440d4113c56ead9fa711fce29bc371 +``` + +Знание домена (или чтение шаблона nginx в репозитории) не раскрывает путь. +Telegram шлёт updates на `BOT_WEBHOOK_PUBLIC_BASE_URL` + этот путь, и даже +угадавший путь получает 403 без правильного секретного заголовка. + +Настройка edge: + +- **Бот на том же хосте, что и стек** (профили `bot` + `edge`): задайте в + `.env` `BOT_WEBHOOK_PATH` (значение из `make bot-webhook-path`) — nginx уже + умеет проксировать этот путь на `bot:8080` внутри Docker-сети. Порт наружу + не публикуется: только 443 у edge. +- **Бот на отдельном сервере**: `docker-compose.bot.yml` публикует webhook-порт + на `127.0.0.1:${BOT_WEBHOOK_PORT:-8080}`. Поставьте TLS-edge (nginx/caddy) + на ботовом серверере, проксируйте секретный путь на `127.0.0.1:8080` и + укажите этот домен в `BOT_WEBHOOK_PUBLIC_BASE_URL`. Альтернатива — + `BOT_WEBHOOK_BIND_HOST` с адресом интерфейса VPN. + +Минимальный `.env` webhook-режима на ботовом сервере: + +```bash +BOT_TOKEN=123456789:ABCDEF... +BOT_SERVICE_TOKEN=bot-prod-secret-xxx +API_URL=https://contract-check.example.com +BOT_UPDATE_MODE=webhook +BOT_WEBHOOK_PUBLIC_BASE_URL=https://bot.example.com +BOT_WEBHOOK_SECRET_TOKEN= +``` + +#### 14.4.3 Проверка + +```bash +# Healthcheck бота (webhook-режим): compose проверяет сам процесс бота +docker compose -f docker-compose.bot.yml ps # STATUS: Up (healthy) + +# Ручная проверка /healthz +curl http://127.0.0.1:8080/healthz # → {"status": "ok"} + +# Секретный путь без токена → 403 +curl -i -X POST https:///tg-webhook/ # → HTTP/1.1 403 Forbidden +``` + +Для HA за балансировщиком помните: webhook-эндпоинт обрабатывает update до +ответа; при нескольких репликах балансировщик должен направлять запросы +Telegram на активную реплику (Telegram сам ретраит неотвеченные updates). + +> Для большинства production-развёртываний polling на выделенном ботовом +> сервере достаточен; webhook-режим нужен при ограничительных сетях (где +> outbound long-polling нестабилен) и для healthcheck-надзора за процессом. ### 14.5 Управление и обновление @@ -807,6 +898,7 @@ make bot-remote-ps - [ ] Backup cron настроен - [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP) - [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS +- [ ] Если бот в webhook-режиме: `BOT_UPDATE_MODE=webhook`, `BOT_WEBHOOK_PUBLIC_BASE_URL`/`BOT_WEBHOOK_SECRET_TOKEN` заданы, edge маршрутизирует `BOT_WEBHOOK_PATH` на бота, webhook-порт не опубликован в интернет --- diff --git a/pyproject.toml b/pyproject.toml index ab9b301..cde3150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,9 @@ prescreen = [ ] bot = [ "aiogram>=3.4", + # The webhook server imports aiohttp directly (aiogram's HTTP engine); + # declared here so the lean bot image stays correct if aiogram ever swaps. + "aiohttp>=3.9", "redis>=5.0", ] notify = [ diff --git a/src/contract_check/bot/__main__.py b/src/contract_check/bot/__main__.py index 5959167..6baf1a2 100644 --- a/src/contract_check/bot/__main__.py +++ b/src/contract_check/bot/__main__.py @@ -1,4 +1,10 @@ -"""Bot entrypoint: configure logging, wire the api client, start aiogram polling.""" +"""Bot entrypoint: configure logging, wire the api client, run the update loop. + +Update mode is configuration-driven: `polling` (default, local development) +long-polls Telegram; `webhook` (production) runs the aiohttp server from +`webhook.py` — secret-token-authenticated updates plus `GET /healthz` — and +registers/deregisters the Telegram webhook around its lifetime. +""" from __future__ import annotations @@ -6,11 +12,13 @@ import asyncio import signal from aiogram import Bot, Dispatcher +from aiohttp import web from src.contract_check.bot.client import ApiClient from src.contract_check.bot.config import get_bot_settings from src.contract_check.bot.handlers import router as bot_router from src.contract_check.bot.rate_limit import get_rate_limiter +from src.contract_check.bot.webhook import build_webhook_app from src.contract_check.core.logging import bind_context, configure_logging, get_logger log = get_logger(__name__) @@ -38,7 +46,12 @@ async def main() -> None: dp.include_router(bot_router) me = await bot.get_me() - log.info("bot_started", username=me.username, api_url=settings.api_url) + log.info( + "bot_started", + username=me.username, + api_url=settings.api_url, + update_mode=settings.update_mode, + ) loop = asyncio.get_running_loop() stop_event = asyncio.Event() @@ -48,17 +61,45 @@ async def main() -> None: except NotImplementedError: pass - polling = asyncio.create_task(dp.start_polling(bot, handle_signals=False, polling_timeout=30)) + # Webhook mode: the runner owns registration on startup and deregistration + # on cleanup (delete_webhook); polling mode stays exactly as before. + runner: web.AppRunner | None = None + polling: asyncio.Task | None = None + if settings.update_mode == "webhook": + runner = web.AppRunner(build_webhook_app(dp, bot, settings)) + await runner.setup() + site = web.TCPSite( + runner, + host=settings.webhook_listen_host, + port=settings.webhook_listen_port, + ) + await site.start() + log.info( + "webhook_listening", + host=settings.webhook_listen_host, + port=settings.webhook_listen_port, + ) + else: + polling = asyncio.create_task( + dp.start_polling(bot, handle_signals=False, polling_timeout=30) + ) + stopper = asyncio.create_task(stop_event.wait()) try: - await asyncio.wait({polling, stopper}, return_when=asyncio.FIRST_COMPLETED) + if polling is not None: + await asyncio.wait({polling, stopper}, return_when=asyncio.FIRST_COMPLETED) + else: + await stopper finally: - if not polling.done(): + if polling is not None and not polling.done(): polling.cancel() if not stopper.done(): stopper.cancel() - await dp.stop_polling() + if runner is not None: + await runner.cleanup() + else: + await dp.stop_polling() await bot.session.close() await api.aclose() try: diff --git a/src/contract_check/bot/config.py b/src/contract_check/bot/config.py index 57edcc9..11d9cbb 100644 --- a/src/contract_check/bot/config.py +++ b/src/contract_check/bot/config.py @@ -9,11 +9,16 @@ the api. from __future__ import annotations +import re from functools import lru_cache +from typing import Literal -from pydantic import Field +from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Telegram restricts webhook secret tokens to 1-256 chars of [A-Za-z0-9_-]. +_WEBHOOK_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{1,256}$") + class BotSettings(BaseSettings): """Telegram-bot adapter settings. See docs/ARCHITECTURE.md §11 (Bot).""" @@ -61,6 +66,52 @@ class BotSettings(BaseSettings): description="Suggest /plans command on 402 if billing is enabled in the api.", ) + # ── Update delivery mode (webhook-report-payments spec, milestone 1) ────── + update_mode: Literal["polling", "webhook"] = Field( + default="polling", + description="How the bot receives Telegram updates: 'polling' (default, " + "local development) or 'webhook' (production behind the TLS edge).", + ) + webhook_public_base_url: str = Field( + default="", + description="Public https base URL the webhook is served under, e.g. " + "https://contract-check.example.com (no trailing slash).", + ) + webhook_secret_token: str = Field( + default="", + description="Value Telegram must echo in X-Telegram-Bot-Api-Secret-Token; " + "also keys the secret-derived webhook path. Chars: A-Za-z0-9_- (max 256).", + ) + webhook_listen_host: str = Field( + default="0.0.0.0", + description="Listen host for the webhook/healthz HTTP server (webhook mode).", + ) + webhook_listen_port: int = Field( + default=8080, + description="Listen port for the webhook/healthz HTTP server (webhook mode).", + ) + + @field_validator("webhook_secret_token") + @classmethod + def _validate_secret_token_charset(cls, value: str) -> str: + if value and not _WEBHOOK_SECRET_RE.match(value): + raise ValueError( + "webhook_secret_token may only contain A-Z, a-z, 0-9, '_' and '-' " + f"(1-256 chars), got {value!r}" + ) + return value + + @model_validator(mode="after") + def _require_webhook_settings(self) -> BotSettings: + if self.update_mode == "webhook": + if not self.webhook_public_base_url: + raise ValueError("webhook_public_base_url is required when update_mode=webhook") + if not self.webhook_public_base_url.startswith(("http://", "https://")): + raise ValueError("webhook_public_base_url must start with http:// or https://") + if not self.webhook_secret_token: + raise ValueError("webhook_secret_token is required when update_mode=webhook") + return self + @property def json_logs(self) -> bool: return self.env != "dev" diff --git a/src/contract_check/bot/webhook.py b/src/contract_check/bot/webhook.py new file mode 100644 index 0000000..c50d724 --- /dev/null +++ b/src/contract_check/bot/webhook.py @@ -0,0 +1,113 @@ +"""Webhook-mode transport for the bot (webhook-report-payments spec, ticket 01+02). + +The bot's update mode is selected by configuration (`polling` default for +local development, `webhook` for production behind the TLS edge). In webhook +mode this module provides a small aiohttp application that: + +- receives Telegram updates at a secret-derived path (HMAC-SHA256 of the + secret token keyed by the bot token — not guessable from the public domain + or the committed nginx template); +- authenticates every request by the `X-Telegram-Bot-Api-Secret-Token` header + with a constant-time compare; a wrong or missing token is rejected with 403 + *before* the update reaches the dispatcher, so forged updates cannot trigger + bot actions or spend users' Credits; +- answers `GET /healthz` so orchestrators and compose healthchecks can probe + the bot process and restart it when dead; +- registers the webhook on startup (`set_webhook` with + `drop_pending_updates`) and deregisters it on graceful shutdown + (`delete_webhook`), so staging and production never fight over one bot + token and separate-host deployments never leave a stale webhook behind. + +The hexagonal boundary is preserved: this is transport only — no core +persistence/storage/MQ/LLM imports (enforced by tests/unit/test_bot_boundary.py). +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets + +from aiogram import Bot, Dispatcher +from aiohttp import web + +from src.contract_check.bot.config import BotSettings +from src.contract_check.core.logging import get_logger + +log = get_logger(__name__) + +SECRET_HEADER = "X-Telegram-Bot-Api-Secret-Token" +PATH_PREFIX = "/tg-webhook" + + +def derive_webhook_path(bot_token: str, webhook_secret_token: str) -> str: + """Deterministic secret-derived URL path for the Telegram webhook. + + HMAC-SHA256 of the webhook secret token keyed by the bot token; the first + 32 hex chars (128 bits) are used. Neither input is public, so knowing the + domain or reading the nginx template does not reveal the path. The same + derivation runs on the bot and in `make bot-webhook-path` for the edge + config. + """ + digest = hmac.new( + bot_token.encode("utf-8"), + webhook_secret_token.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return f"{PATH_PREFIX}/{digest[:32]}" + + +def webhook_public_url(settings: BotSettings) -> str: + """Full URL registered with Telegram: public base URL + secret-derived path.""" + base = settings.webhook_public_base_url.rstrip("/") + return base + derive_webhook_path(settings.bot_token, settings.webhook_secret_token) + + +async def handle_healthz(request: web.Request) -> web.Response: + return web.json_response({"status": "ok"}) + + +def build_webhook_app(dp: Dispatcher, bot: Bot, settings: BotSettings) -> web.Application: + """Build the webhook-mode aiohttp application. + + Wires three routes/callbacks: the secret-path update handler (403 on + secret-token mismatch), `GET /healthz`, and the registration lifecycle + (`set_webhook` with `drop_pending_updates` on startup, `delete_webhook` + on graceful shutdown — i.e. when the AppRunner is cleaned up). + """ + path = derive_webhook_path(settings.bot_token, settings.webhook_secret_token) + expected_secret = settings.webhook_secret_token + + async def handle_update(request: web.Request) -> web.Response: + presented = request.headers.get(SECRET_HEADER, "") + # Bytes compare: compare_digest raises on non-ASCII str, and a forged + # header must get 403, never a 500. + if not secrets.compare_digest(presented.encode(), expected_secret.encode()): + log.warning("webhook_forbidden", path=path) + return web.Response(status=403, text="Forbidden") + try: + update = await request.json() + except ValueError: + # Not JSON (or not a valid update shape): Telegram always sends + # well-formed updates, so this is a probe — reject, don't 500. + return web.Response(status=400, text="Bad Request") + # Respond only after the update is processed (handle_in_background=False + # semantics): Telegram gets the ack exactly when the bot is done. + await dp.feed_webhook_update(bot, update) + return web.json_response({}) + + async def register_webhook(app: web.Application) -> None: + url = webhook_public_url(settings) + await bot.set_webhook(url=url, secret_token=expected_secret, drop_pending_updates=True) + log.info("webhook_registered", url=url) + + async def deregister_webhook(app: web.Application) -> None: + await bot.delete_webhook() + log.info("webhook_deleted") + + app = web.Application() + app.router.add_get("/healthz", handle_healthz) + app.router.add_post(path, handle_update) + app.on_startup.append(register_webhook) + app.on_shutdown.append(deregister_webhook) + return app diff --git a/tests/unit/test_bot_boundary.py b/tests/unit/test_bot_boundary.py index 92788e4..2559901 100644 --- a/tests/unit/test_bot_boundary.py +++ b/tests/unit/test_bot_boundary.py @@ -155,5 +155,6 @@ def test_bot_imports_resolve() -> None: "contract_check.bot.config", "contract_check.bot.client", "contract_check.bot.handlers", + "contract_check.bot.webhook", ): importlib.import_module(mod) diff --git a/tests/unit/test_bot_webhook.py b/tests/unit/test_bot_webhook.py new file mode 100644 index 0000000..05dbd69 --- /dev/null +++ b/tests/unit/test_bot_webhook.py @@ -0,0 +1,256 @@ +"""Webhook-mode transport tests: the bot's HTTP boundary (ticket 01) and the +webhook registration lifecycle (ticket 02). + +The webhook server is the single new seam introduced by the +webhook-report-payments spec, so it is exercised in-process over real HTTP +against the aiohttp application (TestServer/TestClient). The dispatcher is +observed through an outer update middleware — forged requests must never +reach it. The Telegram Bot API side of the lifecycle (set/delete webhook) is +observed through a recording stand-in bot. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from aiogram import Dispatcher +from aiogram.types import Update +from aiohttp.test_utils import TestClient, TestServer +from pydantic import ValidationError + +from src.contract_check.bot.config import BotSettings +from src.contract_check.bot.webhook import ( + build_webhook_app, + derive_webhook_path, + webhook_public_url, +) + +BOT_TOKEN = "123456:webhook-unit-test-token" +SECRET = "unit-test-secret_token-01" +PATH = derive_webhook_path(BOT_TOKEN, SECRET) + +UPDATE_PAYLOAD: dict[str, Any] = { + "update_id": 42, + "message": { + "message_id": 1, + "date": 1735689600, + "text": "привет", + "chat": {"id": 4242, "type": "private"}, + "from": {"id": 4242, "is_bot": False, "first_name": "Tester"}, + }, +} + + +class RecordingBot: + """Duck-typed Bot: records lifecycle calls, never touches the network.""" + + def __init__(self, token: str = BOT_TOKEN) -> None: + self.id = int(token.split(":")[0]) + self.calls: list[tuple[str, dict[str, Any]]] = [] + + async def set_webhook(self, **kwargs: Any) -> None: + self.calls.append(("set_webhook", kwargs)) + + async def delete_webhook(self, **kwargs: Any) -> None: + self.calls.append(("delete_webhook", kwargs)) + + +def make_settings(**overrides: Any) -> BotSettings: + base: dict[str, Any] = { + "env": "test", + "bot_token": BOT_TOKEN, + "bot_service_token": "svc-token", + "update_mode": "webhook", + "webhook_public_base_url": "https://edge.example.com", + "webhook_secret_token": SECRET, + } + base.update(overrides) + return BotSettings(**base) # type: ignore[call-arg] + + +def make_dispatcher(seen: list[Update]) -> Dispatcher: + """Dispatcher that records every fed update and runs no handlers.""" + + async def capture(handler: Any, event: Update, data: dict[str, Any]) -> None: + seen.append(event) + return None + + dp = Dispatcher() + dp.update.outer_middleware(capture) + return dp + + +@pytest.fixture +async def webhook_client() -> Any: + """Started webhook app behind a real in-process HTTP server.""" + + seen: list[Update] = [] + app = build_webhook_app(make_dispatcher(seen), RecordingBot(), make_settings()) + async with TestClient(TestServer(app)) as client: + yield client, seen + + +# ── Ticket 01: HTTP boundary ───────────────────────────────────────────────── + + +class TestWebhookHttpBoundary: + async def test_valid_update_with_correct_secret_is_accepted(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post( + PATH, + json=UPDATE_PAYLOAD, + headers={"X-Telegram-Bot-Api-Secret-Token": SECRET}, + ) + assert resp.status == 200 + assert len(seen) == 1 + assert seen[0].update_id == 42 + assert seen[0].message is not None + assert seen[0].message.text == "привет" + + async def test_wrong_secret_token_rejected_403(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post( + PATH, + json=UPDATE_PAYLOAD, + headers={"X-Telegram-Bot-Api-Secret-Token": "wrong-secret"}, + ) + assert resp.status == 403 + assert seen == [] + + async def test_missing_secret_token_rejected_403(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post(PATH, json=UPDATE_PAYLOAD) + assert resp.status == 403 + assert seen == [] + + async def test_empty_secret_token_rejected_403(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post( + PATH, + json=UPDATE_PAYLOAD, + headers={"X-Telegram-Bot-Api-Secret-Token": ""}, + ) + assert resp.status == 403 + assert seen == [] + + async def test_non_ascii_secret_token_rejected_403(self, webhook_client: Any) -> None: + """A forged header must get 403, never a 500 from compare_digest.""" + client, seen = webhook_client + resp = await client.post( + PATH, + json=UPDATE_PAYLOAD, + headers={"X-Telegram-Bot-Api-Secret-Token": "не-секрет"}, + ) + assert resp.status == 403 + assert seen == [] + + async def test_malformed_json_with_valid_secret_rejected_400(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post( + PATH, + data=b"not-json", + headers={"X-Telegram-Bot-Api-Secret-Token": SECRET}, + ) + assert resp.status == 400 + assert seen == [] + + async def test_healthz_returns_200(self, webhook_client: Any) -> None: + client, _ = webhook_client + resp = await client.get("/healthz") + assert resp.status == 200 + + async def test_unknown_path_returns_404(self, webhook_client: Any) -> None: + client, seen = webhook_client + resp = await client.post( + "/tg-webhook/not-the-derived-path", + json=UPDATE_PAYLOAD, + headers={"X-Telegram-Bot-Api-Secret-Token": SECRET}, + ) + assert resp.status == 404 + assert seen == [] + + +# ── Ticket 01: mode-switch configuration ───────────────────────────────────── + + +class TestUpdateModeConfig: + def test_default_mode_is_polling(self) -> None: + settings = BotSettings(env="test", bot_token=BOT_TOKEN, bot_service_token="svc") # type: ignore[call-arg] + assert settings.update_mode == "polling" + + def test_invalid_mode_rejected(self) -> None: + with pytest.raises(ValidationError): + make_settings(update_mode="banana") + + def test_webhook_mode_requires_public_base_url(self) -> None: + with pytest.raises(ValidationError, match="webhook_public_base_url"): + make_settings(webhook_public_base_url="") + + def test_webhook_base_url_scheme_validated(self) -> None: + with pytest.raises(ValidationError, match="http"): + make_settings(webhook_public_base_url="ftp://edge.example.com") + + def test_webhook_mode_requires_secret_token(self) -> None: + with pytest.raises(ValidationError, match="webhook_secret_token"): + make_settings(webhook_secret_token="") + + def test_webhook_secret_token_charset_validated(self) -> None: + with pytest.raises(ValidationError): + make_settings(webhook_secret_token="bad secret!") + + def test_polling_mode_tolerates_empty_webhook_settings(self) -> None: + settings = make_settings( + update_mode="polling", + webhook_public_base_url="", + webhook_secret_token="", + ) + assert settings.update_mode == "polling" + + +# ── Secret-derived path ────────────────────────────────────────────────────── + + +class TestWebhookPathDerivation: + def test_path_is_deterministic_and_prefixed(self) -> None: + assert PATH == derive_webhook_path(BOT_TOKEN, SECRET) + assert PATH.startswith("/tg-webhook/") + assert len(PATH) > len("/tg-webhook/") + + def test_path_depends_on_secret_and_bot_token(self) -> None: + assert PATH != derive_webhook_path(BOT_TOKEN, "another-secret-token") + assert PATH != derive_webhook_path("999999:other-bot", SECRET) + + def test_public_url_joins_base_and_path(self) -> None: + assert webhook_public_url(make_settings()) == f"https://edge.example.com{PATH}" + + def test_public_url_tolerates_trailing_slash(self) -> None: + settings = make_settings(webhook_public_base_url="https://edge.example.com/") + assert webhook_public_url(settings) == f"https://edge.example.com{PATH}" + + +# ── Ticket 02: registration lifecycle ──────────────────────────────────────── + + +class TestWebhookLifecycle: + async def test_startup_registers_webhook_with_drop_pending_updates(self) -> None: + + bot = RecordingBot() + app = build_webhook_app(make_dispatcher([]), bot, make_settings()) + async with TestServer(app): + pass + + assert bot.calls[0][0] == "set_webhook" + kwargs = bot.calls[0][1] + assert kwargs["url"] == f"https://edge.example.com{PATH}" + assert kwargs["secret_token"] == SECRET + assert kwargs["drop_pending_updates"] is True + + async def test_shutdown_deregisters_webhook(self) -> None: + + bot = RecordingBot() + app = build_webhook_app(make_dispatcher([]), bot, make_settings()) + async with TestServer(app): + pass + + assert [name for name, _ in bot.calls][-1] == "delete_webhook" diff --git a/uv.lock b/uv.lock index 0ba2bee..c17928d 100644 --- a/uv.lock +++ b/uv.lock @@ -692,6 +692,7 @@ billing = [ ] bot = [ { name = "aiogram" }, + { name = "aiohttp" }, { name = "redis" }, ] db = [ @@ -702,6 +703,7 @@ db = [ dev = [ { name = "aio-pika" }, { name = "aiogram" }, + { name = "aiohttp" }, { name = "aiosmtplib" }, { name = "aiosqlite" }, { name = "alembic" }, @@ -857,6 +859,7 @@ billing = [ ] bot = [ { name = "aiogram", specifier = ">=3.4" }, + { name = "aiohttp", specifier = ">=3.9" }, { name = "redis", specifier = ">=5.0" }, ] db = [ @@ -867,6 +870,7 @@ db = [ dev = [ { name = "aio-pika", specifier = ">=9.4" }, { name = "aiogram", specifier = ">=3.4" }, + { name = "aiohttp", specifier = ">=3.9" }, { name = "aiosmtplib", specifier = ">=3.0" }, { name = "aiosqlite", specifier = ">=0.20" }, { name = "alembic", specifier = ">=1.13" },