Skip to content

Repository files navigation

Lead-bot — Telegram-бот для сбора заявок

1. Что это

Telegram-бот на aiogram 3.x, который проводит короткий опрос (имя → телефон → удобное время связи), показывает сводную карточку для подтверждения и отправляет заявку асинхронным POST-запросом во внешний Mock-CRM (Beeceptor). Бот переживает временные сбои CRM (500/429/сетевые ошибки) через ретраи с фиксированной паузой и не блокирует обслуживание других пользователей, пока ждёт ответа CRM.

2. Стек и почему

  • Python 3.12 + aiogram 3.x — нативно асинхронный фреймворк с встроенным FSM и роутерами, что даёт модульность (handlers/, states/) без самодельной обвязки.
  • httpx.AsyncClient для запроса к CRM — асинхронный, современный API, один клиент живёт всё время работы процесса и переиспользуется между заявками (пул соединений), а не создаётся заново на каждый запрос.
  • Pydantic v2 — Lead/CrmSuccessResponse/CrmErrorResponse дословно повторяют API_CONTRACT.md, поэтому несоответствие контракту падает валидацией, а не молча проглатывается.
  • pydantic-settings — типизированный Settings из .env, ни один секрет/URL/число ретраев не захардкожен в коде (app/config.py).
  • RedisStorage для FSM — состояние анкеты переживает рестарт/пересборку контейнера бота (Redis — отдельный сервис с именованным томом в docker-compose.yml). Живой прогон показал, к чему приводит потеря состояния на MemoryStorage: сообщения пользователя, отправленные в уже "забытом" шаге анкеты, молча дропались диспетчером — чат выглядел зависшим до /start. Redis с --appendonly yes закрывает это для обычных перезапусков контейнера бота.
  • pytest + pytest-asyncio + respx — retry-логика проверяется детерминированно (respx подставляет фиксированную последовательность ответов), не завязываясь на то, выпадет ли реальному Beeceptor 500/429 именно в момент прогона тестов.
  • Docker + docker compose — единственный поддерживаемый способ финального запуска.

3. Структура проекта

lead-bot/
├── app/
│   ├── main.py                # точка входа: Bot/Dispatcher(RedisStorage), httpx.AsyncClient на всё время жизни
│   ├── config.py               # Settings (pydantic-settings): BOT_TOKEN, CRM_ENDPOINT_URL, retry-параметры, REDIS_URL
│   ├── handlers/
│   │   ├── __init__.py         # сборка всех роутеров в один (fallback — последним)
│   │   ├── start.py            # /start → приветствие, вход в FSM
│   │   ├── survey.py           # шаги опроса: имя → телефон → время (кнопки)
│   │   ├── confirm.py          # сводная карточка, отправка в CRM, ретрай/рестарт
│   │   └── fallback.py         # "поймать всё": вежливый ответ на message()/callback_query()
│   │                           #   без активного состояния — вместо молчаливого дропа апдейта
│   ├── states/lead_form.py     # LeadForm(StatesGroup): name, phone, time, confirm
│   ├── keyboards/
│   │   ├── time_kb.py          # инлайн-клавиатура Утро/День/Вечер + маппинг в PreferredTime
│   │   └── confirm_kb.py       # клавиатуры "Отправить/Заполнить заново" и "Попробовать снова"
│   ├── validators/
│   │   ├── name.py             # validate_name(): 2–50 символов
│   │   └── phone.py            # validate_phone(): нормализация к +7XXXXXXXXXX
│   ├── services/crm_client.py  # send_lead(): async POST + retry-цикл (3 попытки/3 сек)
│   ├── models/lead.py          # Lead, PreferredTime, CrmSuccessResponse, CrmErrorResponse
│   └── texts/messages.py       # все пользовательские тексты одним модулем
├── tests/
│   ├── conftest.py             # тестовые заглушки BOT_TOKEN/CRM_ENDPOINT_URL
│   ├── test_validators.py      # 25 тестов на граничные случаи имени/телефона
│   └── test_crm_client.py      # 6 respx-тестов: успех, 500→500→200, 429×3→сбой, сеть,
│                                #   non-retryable статус, 200 с телом не по контракту
├── docker/Dockerfile           # python:3.12-slim, непривилегированный пользователь
├── docker-compose.yml          # bot + redis (именованный том для персистентности FSM),
│                                #   в корне репозитория — см. DEVLOG.md, почему не в docker/
├── .env.example                # пустые BOT_TOKEN/CRM_ENDPOINT_URL, заполненные retry/redis-дефолты
├── .dockerignore / .gitignore
├── requirements.txt / requirements-dev.txt
├── pyproject.toml              # конфиг pytest-asyncio, ruff, black
├── DEVLOG.md                   # реальные развилки и решения по ходу работы
├── PROGRESS.md                 # снимок прогресса на момент последней правки
├── claude.md / API_CONTRACT.md # исходное ТЗ и API-контракт (эталон, копия в репозитории)
└── файлы ТЗ/                   # скриншоты живых прогонов, prompts.md, экспорт переписки с ИИ

4. Как запустить локально (без Docker)

cd lead-bot
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt   # включает requirements.txt + pytest/ruff/black

cp .env.example .env
# впишите в .env:
#   BOT_TOKEN=<токен от @BotFather>
#   CRM_ENDPOINT_URL=https://mpfbba1a2c2708d21084.free.beeceptor.com/v1/leads
#   REDIS_URL=redis://localhost:6379/0   # дефолт redis://redis:6379/0 рассчитан на docker compose

docker run -d --name lead-bot-redis -p 6379:6379 redis:7-alpine   # или свой локальный Redis
python -m app.main

Если python3 -m venv ругается на отсутствие ensurepip (бывает на некоторых Debian/ Ubuntu без пакета python3-venv) — либо sudo apt install python3-venv, либо: python3 -m venv --without-pip .venv && curl -sS https://bootstrap.pypa.io/get-pip.py | .venv/bin/python.

Тесты и линтеры:

pytest tests/ -v
ruff check .
black --check .

5. Как запустить в Docker

cd lead-bot
cp .env.example .env   # заполнить BOT_TOKEN и CRM_ENDPOINT_URL, как в пункте 4
docker compose up --build

Обязательные переменные окружения (см. .env.example): BOT_TOKEN, CRM_ENDPOINT_URL. RETRY_ATTEMPTS/RETRY_DELAY_SECONDS/ CRM_REQUEST_TIMEOUT_SECONDS/REDIS_URL необязательны — в app/config.py уже стоят дефолты, совпадающие с ТЗ (3 попытки, 3 секунды) и с топологией docker-compose.yml (REDIS_URL=redis://redis:6379/0 — redis здесь имя сервиса, не хост).

docker-compose.yml поднимает два сервиса: bot (запускает python -m app.main от непривилегированного пользователя botuser, не root) и redis (redis:7-alpine с --appendonly yes, данные — в именованном томе redis-data). FSM-состояние анкеты переживает docker compose restart bot и docker compose up --build — том не пересоздаётся при обычной пересборке. Полностью сбросить состояние (например, для чистого прогона тестового сценария) — docker compose down -v, это осознанно удаляет том вместе с контейнерами.

6. Настройка Mock-API

Mock-CRM уже настроен и подтверждён рабочим владельцем задачи (Beeceptor, POST /v1/leads, взвешенное правило 70% 200 / 20% 500 / 10% 429, задержка ~1.5–2 сек на успехе) — регистрировать и настраивать его заново не нужно, URL уже готов:

CRM_ENDPOINT_URL=https://mpfbba1a2c2708d21084.free.beeceptor.com/v1/leads

Полная схема запроса/ответа и точный конфиг Beeceptor-правила — в API_CONTRACT.md. Проверка вручную (тот же curl, что и в API_CONTRACT.md, раздел 8):

curl -i -X POST "https://mpfbba1a2c2708d21084.free.beeceptor.com/v1/leads" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: b7e6a1b0-6f2e-4a3f-9c1e-8e6a2d9f2a41" \
  -d '{
    "request_id": "b7e6a1b0-6f2e-4a3f-9c1e-8e6a2d9f2a41",
    "name": "Иван Петров",
    "phone": "+79123456789",
    "preferred_time": "morning",
    "source": "telegram_bot",
    "submitted_at": "2026-07-02T18:45:03+03:00"
  }'

Прогнав это 8–10 раз подряд, при весах 70/20/10 почти наверняка увидите вперемешку 200, 500 и 429. В этой разработке эндпоинт проверялся так несколько раз — живой на момент последнего коммита.

Свободный тариф Beeceptor удаляет неактивные эндпоинты через 90 дней, логи чистит через 15 — если бот долго не обращался к нему, проверьте доступность заново перед использованием.

Итоговая рабочая конфигурация правила. Черновик шаблонов из API_CONTRACT.md (раздел 7, {{request.body.request_id}}/{{random.uuid}}) не заработал "из коробки" — это не стандартный Mustache, а специфичный для Beeceptor синтаксис (детали и источники — в DEVLOG.md). Рабочие варианты для всех трёх ответов — на скриншотах (файлы ТЗ/):

Success (70%) Internal error (20%) Rate limited (10%)
Success Internal error Rate limited

Ключевое отличие от черновика: {{faker 'string.uuid'}} вместо {{random.uuid}}, {{body 'request_id'}} вместо {{request.body.request_id}}, {{now 'iso'}} вместо голого {{now}} (без 'iso' Beeceptor возвращает нестандартный формат даты, который pydantic не парсит) — и обязательно включённый чекбокс "Enable dynamic mock responses".

7. Как проверялась отказоустойчивость

Юнит-тесты (tests/test_crm_client.py, respx-моки, детерминированно):

  • успех с первой попытки (200 сразу);
  • 500 → 500 → 200 — две ретрая, третья попытка успешна;
  • 429 × 3 — все три попытки исчерпаны, поднимается CrmDeliveryFailed;
  • сетевой таймаут (httpx.ConnectTimeout) на первой попытке ведёт себя как 500 — ретраится и добивается успеха на второй;
  • 422 (non-retryable) — падает сразу, без единой повторной попытки;
  • 200, но тело не соответствует контракту (пойманный вживую случай — до исправления синтаксиса шаблонов в правиле Beeceptor, см. раздел 6, request_id/received_at приходили пустыми/нешаблонизированными) — тоже падает сразу, без единой повторной попытки, а не молотит тот же битый ответ 3 раза подряд.

Запуск: pytest tests/test_crm_client.py -v — все 6 зелёные.

Живая проверка на реальном Beeceptor-эндпоинте и в Docker. Владелец репозитория прогнал полный флоу через docker compose up --build на реальной машине и реальном Telegram-клиенте. Скриншоты — в файлы ТЗ/:

  • Реальный чат в Telegram, включая ретрай и успех — /start → имя → телефон → ретрай ("Сервер CRM временно занят, пытаюсь отправить ещё раз..." сработал дважды подряд) → финальное "Спасибо, олег! Заявка отправлена, с Вами свяжутся в выбранное время.": Успешная отправка в Telegram после ретраев

  • Реальный успешный 200 и 429 от Mock-API — Beeceptor честно распарсил и подставил request_id/received_at в тело ответа (после исправления конфигурации правила, см. раздел 6 выше), запрос ниже отдал корректный CrmSuccessResponse, а соседний — 429 rate_limited с телом ошибки ровно по API_CONTRACT.md: Успешный 200 и 429 от Beeceptor

  • docker compose up --build разворачивает Redis и переживает пересборку — лог показывает Image redis:7-alpine Pulled, Volume ..._redis-data Created, Container ..._bot-1 Recreated, а следом — реальные 500/429 от CRM, ретрай с тем же текстом, что в texts/messages.py, и финальный чистый 200 без ошибок парсинга (ретрай доехал до успеха): docker compose up --build с Redis

  • История запросов на Beeceptor — вперемешку 200/500/429 за сессию, подтверждает веса 70/20/10 на практике: История запросов Beeceptor

  • Первый живой прогон (до перехода на Redis) — тот самый лог, на котором был обнаружен баг с MemoryStorage/ретраем на невалидном 200 (см. DEVLOG.md, разделы "Фаза 6"): полный ретрай-цикл с точным текстом ТЗ, критический сбой без утечки технических деталей: Первый живой прогон в Docker

Что этими скриншотами подтверждено: docker compose up --build реально поднимает бота и Redis с нуля; полный флоу /start → анкета → ретрай → успех виден в самом Telegram-клиенте с точным текстом из ТЗ; Mock-API реально отдаёт 200/500/429 и бот их различает. Единственное, что осталось не проверено вживую (см. раздел 8) — асинхронность на двух параллельных чатах одновременно.

8. Известные ограничения

  • docker compose down -v стирает состояние FSM. Redis хранит данные в именованном томе, который переживает обычный рестарт/пересборку, но -v — это осознанное удаление тома, и после него все незавершённые анкеты пользователей теряются. Для мульти-инстанс продакшена дополнительно стоит задать state_ttl/ data_ttl в RedisStorage (сейчас без TTL — заявки висят в Redis, пока не завершатся или пока их не сотрёт -v), чтобы забытые на середине опроса чаты не копились бесконечно.
  • Fallback-хендлер (handlers/fallback.py) — по тексту, а не по восстановлению состояния. Если сообщение/колбэк не попали ни в один активный шаг анкеты (после docker compose down -v, устаревшей кнопки на карточке или редкого рассинхрона), бот вежливо просит набрать /start, а не пытается угадать, что имел в виду пользователь, и не восстанавливает потерянные данные анкеты.
  • Двойная отправка одной карточки не дебаунсится. Если пользователь очень быстро дважды нажмёт "Отправить заявку", уйдут два независимых запроса (с одним и тем же request_id, так что CRM с настоящей идемпотентностью не создаст дубль — но Mock-CRM это не гарантирует). Не реализовано намеренно — это отдельная защита сверх объёма ТЗ, добавлять её "на всякий случай" — то самое переусложнение, которое прямо запрещено разделом 8 CLAUDE.md.
  • Регэксп телефона принимает только явно перечисленные в ТЗ форматы (+7/8 + 10 цифр в допустимой группировке). Голый 10-значный номер без префикса (9123456789) сознательно не принимается — в ТЗ такой вариант не упомянут.
  • Живой прогон выполнен, кроме одного пункта. docker compose up --build, полный флоу /start → карточка → отправка, ретрай на реальных 500/429 и реальный успешный 200 (включая сам текст "Спасибо, ..." в интерфейсе Telegram) — всё пройдено владельцем на реальном Docker и реальном Telegram-клиенте, скриншоты в разделе 7 выше и в файлы ТЗ/ (среда, где писался код, не имела Docker-демона — подробности в DEVLOG.md, фазы 4–6). Единственное, что не проверено вживую — асинхронность на двух параллельных чатах одновременно.

9. DEVLOG

Процесс работы с ИИ, реальные развилки и решения (в том числе почему docker-compose.yml лежит в корне, а не в docker/, и что именно не удалось проверить руками в среде разработки) — задокументированы в DEVLOG.md.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages