diff --git a/.env.bot.example b/.env.bot.example new file mode 100644 index 0000000..d299fe2 --- /dev/null +++ b/.env.bot.example @@ -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= diff --git a/.env.example b/.env.example index eda2323..b91d126 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/Makefile b/Makefile index a871d62..8be2a9a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index fd431b7..98f3d9d 100644 --- a/README.md +++ b/README.md @@ -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. ## Структура репозитория diff --git a/docker-compose.bot.yml b/docker-compose.bot.yml new file mode 100644 index 0000000..8287459 --- /dev/null +++ b/docker-compose.bot.yml @@ -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 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index b19aa7e..2d6e0c5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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 --- diff --git a/src/contract_check/bot/__main__.py b/src/contract_check/bot/__main__.py index 60b6a61..5959167 100644 --- a/src/contract_check/bot/__main__.py +++ b/src/contract_check/bot/__main__.py @@ -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) diff --git a/src/contract_check/bot/rate_limit.py b/src/contract_check/bot/rate_limit.py index 5db24a1..6868027 100644 --- a/src/contract_check/bot/rate_limit.py +++ b/src/contract_check/bot/rate_limit.py @@ -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() diff --git a/tests/unit/test_bot_rate_limiter.py b/tests/unit/test_bot_rate_limiter.py new file mode 100644 index 0000000..a1da82b --- /dev/null +++ b/tests/unit/test_bot_rate_limiter.py @@ -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)