Telegram-бот на aiogram 3.x, который проводит короткий опрос (имя → телефон → удобное время связи), показывает сводную карточку для подтверждения и отправляет заявку асинхронным POST-запросом во внешний Mock-CRM (Beeceptor). Бот переживает временные сбои CRM (500/429/сетевые ошибки) через ретраи с фиксированной паузой и не блокирует обслуживание других пользователей, пока ждёт ответа CRM.
- 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 — единственный поддерживаемый способ финального запуска.
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, экспорт переписки с ИИ
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 .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, это осознанно удаляет
том вместе с контейнерами.
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%) |
|---|---|---|
![]() |
![]() |
![]() |
Ключевое отличие от черновика: {{faker 'string.uuid'}} вместо {{random.uuid}},
{{body 'request_id'}} вместо {{request.body.request_id}}, {{now 'iso'}} вместо
голого {{now}} (без 'iso' Beeceptor возвращает нестандартный формат даты, который
pydantic не парсит) — и обязательно включённый чекбокс "Enable dynamic mock
responses".
Юнит-тесты (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 временно занят, пытаюсь отправить ещё раз..."сработал дважды подряд) → финальное"Спасибо, олег! Заявка отправлена, с Вами свяжутся в выбранное время.":
-
Реальный успешный
200и429от Mock-API — Beeceptor честно распарсил и подставилrequest_id/received_atв тело ответа (после исправления конфигурации правила, см. раздел 6 выше), запрос ниже отдал корректныйCrmSuccessResponse, а соседний —429 rate_limitedс телом ошибки ровно поAPI_CONTRACT.md:
-
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без ошибок парсинга (ретрай доехал до успеха):
-
История запросов на Beeceptor — вперемешку
200/500/429за сессию, подтверждает веса 70/20/10 на практике:
-
Первый живой прогон (до перехода на Redis) — тот самый лог, на котором был обнаружен баг с
MemoryStorage/ретраем на невалидном200(см.DEVLOG.md, разделы "Фаза 6"): полный ретрай-цикл с точным текстом ТЗ, критический сбой без утечки технических деталей:
Что этими скриншотами подтверждено: docker compose up --build реально поднимает
бота и Redis с нуля; полный флоу /start → анкета → ретрай → успех виден в самом
Telegram-клиенте с точным текстом из ТЗ; Mock-API реально отдаёт 200/500/429 и
бот их различает. Единственное, что осталось не проверено вживую (см. раздел 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). Единственное, что не проверено вживую — асинхронность на двух параллельных чатах одновременно.
Процесс работы с ИИ, реальные развилки и решения (в том числе почему
docker-compose.yml лежит в корне, а не в docker/, и что именно не удалось
проверить руками в среде разработки) — задокументированы в DEVLOG.md.


