M1: bot webhook delivery - secret-token aiohttp server, /healthz, mode switch, TLS-edge routing (#001, #002)

This commit is contained in:
febux 2026-09-06 14:53:22 +03:00
parent e73430a078
commit a1e119ce6e
15 changed files with 698 additions and 31 deletions

View file

@ -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/<secret-derived-path> 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

View file

@ -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`

View file

@ -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

View file

@ -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/ {

View file

@ -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

View file

@ -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

View file

@ -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 15 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/<HMAC-SHA256(secret, key=token)[:32]>`),
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.

View file

@ -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/<digest>` и
проверяет заголовок `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=<openssl rand -hex 32>
```
#### 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://<edge>/tg-webhook/<digest> # → 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-порт не опубликован в интернет
---

View file

@ -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 = [

View file

@ -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,16 +61,44 @@ 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:
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()
if runner is not None:
await runner.cleanup()
else:
await dp.stop_polling()
await bot.session.close()
await api.aclose()

View file

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

View file

@ -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

View file

@ -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)

View file

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

4
uv.lock generated
View file

@ -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" },