TG bot was separated as service with envs.

This commit is contained in:
febux 2026-09-05 00:08:26 +03:00
parent 4b71fe4291
commit b45274e01b
9 changed files with 231 additions and 12 deletions

27
.env.bot.example Normal file
View file

@ -0,0 +1,27 @@
# Minimal environment for running the Telegram bot on a separate server.
# Copy to `.env` on the bot host and fill in the secrets.
#
# The bot is stateless: it polls Telegram and calls the central API over HTTP(S).
# It does NOT need DB/RabbitMQ/MinIO/LLM credentials. Keep this file minimal.
ENV=prod
LOG_LEVEL=INFO
LOG_FORMAT=json
# Telegram bot token from @BotFather.
BOT_TOKEN=
# Bearer token the bot uses to authenticate against the API on
# POST /api/v1/auth/telegram/bot. Must match the hash stored in
# service_tokens.name='bot-prod' on the central API server.
BOT_SERVICE_TOKEN=
# Public URL of the central API, without trailing slash.
# Example: https://contract-check.example.com
API_URL=
# Optional: Redis URL for shared rate-limit state.
# Leave empty for the in-memory backend (recommended for a single bot instance).
# If set, Redis must be reachable from this host over a private network/VPN;
# never expose Redis to the public internet.
REDIS_URL=

View file

@ -185,4 +185,6 @@ NGINX_SERVER_NAME=contract-check.example.com
# --- Telegram bot (adapter, HTTP-only to api) ---
BOT_TOKEN= # same value as TELEGRAM_BOT_TOKEN (kept for the bot image)
BOT_SERVICE_TOKEN= # bearer looked up against service_tokens.name="bot-prod"
API_URL=http://api:8000 # base URL of the api service (container DNS in compose)
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).

View file

@ -146,12 +146,27 @@ api: ## Start/restart API service
api-logs: ## Tail API logs
docker compose logs -f api
bot: ## Start/restart Telegram bot
bot: ## Start/restart Telegram bot (same host as the api stack)
docker compose --profile services up -d --build --remove-orphans bot
bot-logs: ## Tail bot logs
bot-logs: ## Tail bot logs (same host as the api stack)
docker compose logs -f bot
# ─────────────────────────────────────────────────────────────────────────────
# Docker: bot on a separate server (HTTP-only adapter to central API)
# ─────────────────────────────────────────────────────────────────────────────
bot-remote-up: ## Start bot on a separate server (set API_URL to central API)
docker compose -f docker-compose.bot.yml up -d --build --remove-orphans
bot-remote-down: ## Stop remote bot
docker compose -f docker-compose.bot.yml down
bot-remote-logs: ## Tail remote bot logs
docker compose -f docker-compose.bot.yml logs -f
bot-remote-ps: ## Show remote bot container status
docker compose -f docker-compose.bot.yml ps
worker-extract: ## Start/restart extract worker
docker compose --profile services up -d --build --remove-orphans worker-extract

View file

@ -46,7 +46,7 @@ Telegram ──► bot (aiogram, HTTP-only) ──HTTP──► api (FastAPI)
- **worker-analyze** — I/O: LLM-анализ по чек-листу, валидация/repair, сохраняет `Report`, `status=done`.
- **worker-notify** — доставка email-уведомлений (восстановление пароля и др.) через SMTP; без `SMTP_HOST` — dev-логгер.
- **worker-billing** — таймерный планировщик биллинга (без MQ): продления подписок (счета за 3 дня до конца периода), expiry/past_due-переходы, сверка pending-счетов старше 15 минут через ЮKassa API.
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`).
- **bot** — адаптер: HTTP-клиент к api, **не импортирует** core.db/s3/llm/mq (граница проверяется тестом `tests/unit/test_bot_boundary.py`). Может работать на отдельном сервере — см. `docs/DEPLOY.md` §14.
## Структура репозитория

42
docker-compose.bot.yml Normal file
View file

@ -0,0 +1,42 @@
# Telegram bot adapter — run on a separate server.
#
# The bot is deliberately stateless: it polls Telegram and talks to 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.
#
# Usage on the bot server:
# cp .env.bot.example .env # minimal bot-only environment
# docker compose -f docker-compose.bot.yml up -d --build
#
# See docs/DEPLOY.md §14 for the full separate-server guide.
services:
bot:
build:
context: .
dockerfile: srv/bot/Dockerfile
container_name: contract_check-bot
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
ENV: ${ENV:-prod}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_FORMAT: ${LOG_FORMAT:-json}
BOT_TOKEN: ${BOT_TOKEN}
API_URL: ${API_URL}
BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN}
# Optional Redis for shared rate-limit state across bot replicas.
# Leave empty (or omit) to use the in-memory backend; safe for a single
# bot instance. If set, Redis must be reachable from this host (private
# network / VPN — do NOT expose Redis to the public internet).
REDIS_URL: ${REDIS_URL:-}
healthcheck:
test:
- CMD-SHELL
- "python -c \"import urllib.request, os; urllib.request.urlopen(os.environ['API_URL'] + '/healthz', timeout=5)\""
interval: 30s
timeout: 5s
retries: 3
start_period: 15s

View file

@ -20,6 +20,8 @@
10. [Обновление (zero-downtime-ish)](#10-обновление)
11. [Масштабирование](#11-масштабирование)
12. [Troubleshooting](#12-troubleshooting)
13. [Edge proxy (Nginx + certbot)](#13-edge-proxy-nginx--certbot)
14. [Telegram-бот на отдельном сервере](#14-telegram-бот-на-отдельном-сервере)
---
@ -655,6 +657,109 @@ docker compose down -v
---
## 14. Telegram-бот на отдельном сервере
Бот изначально спроектирован как тонкий HTTP-адаптер: он не импортирует `core.db`/`core.s3`/`core.mq`/`core.llm` и не хранит состояние. Поэтому его можно вынести на отдельный хост без изменения кода — только настроив `API_URL` на центральный API.
Типичные причины вынести бота:
- **Безопасность**: на ботовом сервере нет секретов БД/MinIO/RabbitMQ/LLM.
- **Сетевая изоляция**: боту нужен только outbound HTTPS к Telegram и к API.
- **Масштабирование**: можно запустить несколько ботов за одним токеном (aiogram polling не допускает несколько активных polling-инстансов с одним токеном; для HA используйте webhook + балансировку, см. §14.4).
### 14.1 Сетевая связность
| Направление | Протокол | Порт | Комментарий |
|---|---|---|---|
| bot → Telegram | outbound HTTPS | 443 | Polling updates от `api.telegram.org` |
| bot → API | HTTPS (рекомендуется) | 443 / 8000 | `API_URL` без trailing slash, например `https://contract-check.example.com` |
| bot → Redis (опц.) | Redis | 6379 | Только внутри приватной сети/VPN; иначе оставьте `REDIS_URL` пустым |
На firewall ботового сервера открывайте только **outbound** 443 (и 22 для SSH). Входящих портов бот не требует.
### 14.2 Подготовка центрального API
Убедитесь, что API доступен ботовому серверу по домену/IP и что `service_tokens` содержит запись для бота (§5.2):
```bash
# На сервере с API — убедитесь, что токен засеян:
docker compose --profile services exec api python -m src.contract_check.api seed-token bot-prod bot
```
Если API закрыт файрволом по IP, разрешите входящие от IP ботового сервера на порт API/Nginx.
### 14.3 Развёртывание бота на отдельном сервере
1. Склонируйте репозиторий на ботовый сервер (нужны только `srv/bot/Dockerfile`, `docker-compose.bot.yml`, `pyproject.toml`, `uv.lock`, `src/`, `.env.bot.example`).
2. Создайте `.env` из `.env.bot.example` с минимальным набором переменных:
```bash
cp .env.bot.example .env
# отредактируйте .env
```
```bash
ENV=prod
LOG_LEVEL=INFO
LOG_FORMAT=json
BOT_TOKEN=123456789:ABCDEF... # тот же токен из @BotFather
BOT_SERVICE_TOKEN=bot-prod-secret-xxx # должен совпадать с service_tokens.name='bot-prod'
API_URL=https://contract-check.example.com # публичный адрес центрального API, без trailing slash
REDIS_URL= # пусто → in-memory rate limiter; достаточно для одного инстанса
```
3. Запустите:
```bash
make bot-remote-up
# или напрямую:
docker compose -f docker-compose.bot.yml up -d --build
```
4. Проверьте логи:
```bash
make bot-remote-logs
```
Ожидаемое сообщение:
```
bot_started username=<...> api_url=https://contract-check.example.com
```
5. Проверьте healthcheck контейнера:
```bash
docker compose -f docker-compose.bot.yml ps
docker compose -f docker-compose.bot.yml exec bot python -c \
"import urllib.request, os; print(urllib.request.urlopen(os.environ['API_URL'] + '/healthz').read())"
```
### 14.4 Webhook вместо polling (опционально, для HA)
По умолчанию бот использует **polling**. Два и более инстанса с одним токеном в polling-режиме будут конфликтовать (Telegram отдаёт 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`).
> Для большинства production-развёртываний polling на выделенном ботовом сервере достаточен.
### 14.5 Управление и обновление
```bash
# Пересобрать и перезапустить бота после git pull:
make bot-remote-up
# Остановить:
make bot-remote-down
# Посмотреть статус:
make bot-remote-ps
```
---
## Чек-лист перед production
- [ ] `.env` заполнен, `.env.example` не содержит реальных секретов
@ -669,6 +774,7 @@ docker compose down -v
- [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT
- [ ] Backup cron настроен
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
---

View file

@ -30,8 +30,8 @@ async def main() -> None:
await api.start()
# Rate limiter for /start and other bot-level guards. Redis in prod,
# in-memory fallback in dev/tests.
rate_limiter = await get_rate_limiter(settings.redis_url or "redis://redis:6379/0")
# in-memory fallback in dev/tests or when REDIS_URL is empty.
rate_limiter = await get_rate_limiter(settings.redis_url)
bot = Bot(token=settings.bot_token)
dp = Dispatcher(api=api, settings=settings, rate_limiter=rate_limiter)

View file

@ -21,9 +21,17 @@ def _redis_client(url: str) -> Any:
async def get_rate_limiter(redis_url: str) -> RateLimiter:
"""Build a Redis rate limiter or fall back to memory on failure."""
redis_client = _redis_client(redis_url)
"""Build a Redis rate limiter or fall back to memory on failure.
An empty/None URL selects the in-memory backend immediately. This lets a
bot running on a separate server disable Redis by setting ``REDIS_URL=``.
"""
if not redis_url:
log.info("rate_limiter_memory_selected")
return MemoryRateLimiter()
redis_client = None
try:
redis_client = _redis_client(redis_url)
await redis_client.ping()
from src.contract_check.core.rate_limit import RedisRateLimiter
@ -31,8 +39,9 @@ async def get_rate_limiter(redis_url: str) -> RateLimiter:
return RedisRateLimiter(redis_client)
except Exception as exc:
log.warning("rate_limiter_redis_unavailable", error=str(exc))
try:
await redis_client.aclose()
except Exception:
pass
if redis_client is not None:
try:
await redis_client.aclose()
except Exception:
pass
return MemoryRateLimiter()

View file

@ -0,0 +1,18 @@
"""Tests for the bot-specific rate limiter factory."""
from __future__ import annotations
from contract_check.bot.rate_limit import get_rate_limiter
from contract_check.core.rate_limit import MemoryRateLimiter
async def test_get_rate_limiter_empty_url_uses_memory() -> None:
limiter = await get_rate_limiter("")
assert isinstance(limiter, MemoryRateLimiter)
async def test_get_rate_limiter_invalid_url_falls_back_to_memory() -> None:
# An unparseable/malformed URL should not crash the bot; it should fall
# back to the in-memory backend so the adapter stays up.
limiter = await get_rate_limiter("not-a-valid-redis-url")
assert isinstance(limiter, MemoryRateLimiter)