Legan Studio
Все статьи
~ 15 мин чтения

Топ-10 ошибок при разработке бота для MAX

Десять самых частых ошибок при разработке бота в MAX — от технических до продуктовых. Как их избежать и где они стоят особенно дорого.

  • MAX
  • разработка
  • процесс

За пару лет работы с ботами в MAX накапливаются типичные грабли, которые в той или иной форме встречаются почти в каждом первом проекте. Перечень полезен и заказчику, чтобы понимать, на что обращать внимание у подрядчика, и команде разработки — чтобы не повторять чужих ошибок и не наступать на них в третий раз. Ниже — расширенный разбор: симптомы, последствия, как правильно делать с примерами кода.

Мы намеренно перемешали технические ошибки (FSM, webhook, БД) и операционные (бэкапы, мониторинг, 152-ФЗ) — на практике именно их сочетание решает, упадёт ли бот через неделю после релиза или будет тихо работать годами.

1. FSM-состояние в памяти процесса

Описание. Состояние пользователя хранится в Python-словаре или in-memory кеше внутри процесса бота.

Симптом. После рестарта или деплоя все активные сессии пропадают: пользователи, которые заполняли форму или проходили воронку, начинают заново и в массе своей просто уходят.

Последствия. Конверсия многошаговых сценариев падает на 20-40%. При нескольких инстансах бота вообще ничего не работает: пользователь попадает на разные процессы и каждый видит "пустое" состояние.

Как правильно. FSM-состояние пишется в Redis с TTL 24-48 часов или в Postgres. Это даёт устойчивость к рестартам и горизонтальное масштабирование.

# Плохо: in-memory FSM
user_states = {}
def handler(update):
    state = user_states.get(update.user_id, "start")
    # ...при рестарте всё пропало

# Хорошо: Redis FSM с TTL
async def handler(update):
    state = await redis.get(f"fsm:{update.user_id}") or "start"
    await redis.setex(f"fsm:{update.user_id}", 86400, "next_state")

2. Polling в проде с несколькими инстансами

Описание. Бот использует long polling и при этом запущен в нескольких репликах для отказоустойчивости.

Симптом. Часть апдейтов теряется, на одно сообщение пользователя бот отвечает дважды, в логах ошибки Conflict: terminated by other getUpdates request.

Последствия. Дубли заказов, дубли уведомлений, нестабильное поведение. Поддержка тонет в жалобах "почему мне дважды списали".

Как правильно. Для прода — webhook (только один источник правды), а polling оставить для локальной разработки. Если очень нужен polling в проде — лидер-выбор через Redis lock, чтобы getUpdates делал только один инстанс.

3. Webhook без secret_token

Описание. Эндпоинт /webhook принимает любые входящие POST без проверки подписи.

Симптом. В логах появляются "апдейты", которые никто не отправлял, бот реагирует на фантомные команды, создаются заказы от несуществующих пользователей.

Последствия. Спуфинг — атакующий узнаёт URL и шлёт фейковые обновления. Можно подменить user_id, инициировать оплату, повлиять на бизнес-логику.

Как правильно. При установке webhook передавать secret_token, а на сервере сверять X-Bot-Api-Secret-Token с заголовком запроса. Если не совпадает — 401.

# Хорошо
SECRET = os.getenv("WEBHOOK_SECRET")

@app.post("/webhook")
async def webhook(request):
    if request.headers.get("X-Bot-Api-Secret-Token") != SECRET:
        return Response(status=401)
    # ...

4. Не обработан 429 (rate limit)

Описание. Код шлёт сообщения через Bot API без обработки 429 и Retry-After.

Симптом. Часть рассылки не доходит, в логах 429, в худшем случае — временный бан бота на отправку.

Последствия. Маркетинг считает, что рассылка ушла всем, на деле — половине. Жалобы "почему мне не пришло".

Как правильно. Перехватывать 429, читать Retry-After (или fallback 1-2 секунды), ждать и ретраить. На уровне рассылок — лимит менее 30 сообщений в секунду через токен-бакет.

# Хорошо
async def safe_send(chat_id, text, retries=3):
    for _ in range(retries):
        try:
            return await bot.send_message(chat_id, text)
        except FloodWait as e:
            await asyncio.sleep(e.retry_after)
    raise RuntimeError("send failed after retries")

5. Тяжёлые синхронные вызовы в handler

Описание. Внутри обработчика апдейта делается блокирующий вызов: запрос к БД на 5 секунд, поход в LLM на 10 секунд, тяжёлая выгрузка из CRM.

Симптом. MAX отваливает webhook по таймауту, пользователю приходит "что-то пошло не так", а потом ещё дубль от ретрая.

Последствия. Потеря части апдейтов, дубли действий, плохой UX, рост latency у всех пользователей сразу.

Как правильно. Webhook принимает апдейт, кладёт задачу в Redis или RabbitMQ и отвечает менее чем за 1 секунду. Тяжёлая логика — в воркере, ответ пользователю — через send_message асинхронно.

# Плохо
@app.post("/webhook")
async def webhook(update):
    answer = await llm.generate(update.text)  # 10 секунд
    await bot.send_message(update.chat_id, answer)
    return {"ok": True}

# Хорошо
@app.post("/webhook")
async def webhook(update):
    await queue.enqueue("process_message", update.dict())
    return {"ok": True}  # ответ за миллисекунды

6. Не обрабатывается 403 (бот заблокирован)

Описание. При рассылке бот отправляет сообщения всем, не проверяя, кто его уже заблокировал.

Симптом. Половина запросов возвращает 403, бот тратит RPS впустую, антиспам начинает подозрительно смотреть на источник.

Последствия. Рассылки идут медленнее, лишний трафик, неактуальная база подписчиков, неправильная статистика конверсии.

Как правильно. Перехватывать 403 (Forbidden), помечать пользователя inactive=true в БД и исключать его из последующих рассылок. Раз в месяц — дайджест "сколько отписалось".

7. callback_data длиннее 64 байт

Описание. В кнопках инлайн-клавиатуры передают JSON с кучей полей: ID товара, цена, имя категории, action.

Симптом. Часть кнопок не работает, в логах ошибки от API, при попытке обновить сообщение — MessageNotModified.

Последствия. Пользователь нажимает — ничего не происходит. Возникают сложно воспроизводимые баги.

Как правильно. В callback_data — короткий ID (UUID или номер), а само состояние класть в Redis с TTL. Парсер видит cb:42 и идёт в Redis за деталями.

# Плохо
button = InlineButton(text="Купить", callback_data=json.dumps({
    "action": "buy", "product_id": 12345, "price": 199, "category": "books"
}))  # >64 байт

# Хорошо
key = f"cb:{uuid4().hex[:12]}"
await redis.setex(key, 3600, json.dumps(payload))
button = InlineButton(text="Купить", callback_data=key)

8. Параллельная обработка одного update_id (нет idempotency)

Описание. При ретраях со стороны MAX или дублях в очереди один и тот же update_id обрабатывается несколько раз.

Симптом. Один заказ создаётся дважды, оплата списывается дважды, уведомление приходит дважды.

Последствия. Финансовые потери, разборки с поддержкой, возвраты, репутационный ущерб.

Как правильно. Idempotency-ключ на update_id: перед обработкой — SET NX в Redis с TTL. Если ключ уже есть — пропускаем апдейт.

async def handle(update):
    ok = await redis.set(f"upd:{update.update_id}", "1", nx=True, ex=86400)
    if not ok:
        return  # уже обрабатывали
    await process(update)

9. Токен бота в коде или git

Описание. Токен лежит прямо в config.py, в .env, который закоммичен, или хуже — в публичном репозитории.

Симптом. Утечка через GitHub-форк или старую ветку. Атакующий перехватывает управление ботом.

Последствия. Отзыв токена, ротация, аудит всех рассылок и операций, потеря доверия.

Как правильно. Секреты — только в Vault, Yandex Lockbox или переменных окружения CI/CD. В git — .env.example без реальных значений. На репозиторий — git-secrets или trufflehog в pre-commit.

10. Нет rate limit per user

Описание. Один пользователь может слать команды без ограничений.

Симптом. Один тролль или скрипт в секунду шлёт /start — бот лежит, остальные пользователи не могут пользоваться.

Последствия. DoS изнутри. Особенно опасно, если каждый апдейт триггерит запрос к LLM или внешнему API с биллингом.

Как правильно. Token bucket по user_id: например, 5 действий в 10 секунд, потом сообщение "не так часто".

11. WebApp initData не валидируется

Описание. Mini App MAX передаёт initData с подписью, но бекенд её не проверяет.

Симптом. Атакующий открывает DevTools, подменяет user_id на чужой и видит/меняет данные другого пользователя.

Последствия. Полная компрометация Mini App: можно подделать любого пользователя, увидеть его заказы, изменить корзину.

Как правильно. На сервере проверять HMAC-SHA256 от initData с ключом из токена бота. Только потом доверять user.id оттуда.

def verify_init_data(init_data: str, bot_token: str) -> dict:
    parsed = dict(parse_qsl(init_data))
    received_hash = parsed.pop("hash")
    data_check = "\n".join(f"{k}={v}" for k, v in sorted(parsed.items()))
    secret = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
    expected = hmac.new(secret, data_check.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received_hash):
        raise ValueError("invalid initData")
    return parsed

12. Жёстко закодированные тексты вместо локализации

Описание. Все формулировки в Python-файлах: await bot.send("Здравствуйте! Нажмите...").

Симптом. Менеджер хочет поправить опечатку — нужен релиз. Хочется добавить английский — нереально без переписывания.

Последствия. Скорость продуктовых правок падает в 10 раз. Команда не успевает за маркетингом.

Как правильно. Тексты в YAML/JSON или в Postgres. Шаблоны через Jinja или плейсхолдеры. Локали — ru.yaml, en.yaml, kz.yaml рядом.

13. Нет fallback на отправку

Описание. Бот шлёт сообщение и не проверяет результат — а в групповом чате у него отозвали права на отправку.

Симптом. Падает с исключением, поток обработки умирает, остальные апдейты в этом чате тоже не идут.

Последствия. Бот молча "выпадает" из группы, никто не понимает почему.

Как правильно. Каждый send_message в try/except с конкретными типами ошибок (Forbidden, BadRequest); при Forbidden — пометить чат inactive, не пытаться больше.

14. Глобальный try/except, который проглатывает ошибки

Описание. Где-то на верхнем уровне except Exception: pass без логирования.

Симптом. Бот вроде работает, но половина действий молча не выполняется. Найти, где именно, невозможно.

Последствия. Дни и недели на отладку призраков. Потеря денег и доверия пользователей.

Как правильно. Логировать всё: traceback, user_id, update_id, контекст. Sentry или Loki + Grafana. Никаких немых except: pass.

# Плохо
try:
    await process(update)
except Exception:
    pass

# Хорошо
try:
    await process(update)
except Exception as e:
    logger.exception("handler failed", extra={
        "user_id": update.user_id, "update_id": update.update_id
    })
    sentry_sdk.capture_exception(e)

15. Прямые SQL-запросы вместо ORM или параметризации

Описание. В коде встречается cursor.execute(f"SELECT * FROM users WHERE name='{name}'").

Симптом. Пока никто не догадался — никаких. Потом — слив базы, дроп таблиц, поломанный бот.

Последствия. Классический SQL injection. Утечка PII, штрафы по 152-ФЗ, репутационный ущерб.

Как правильно. Только параметризованные запросы или ORM (SQLAlchemy, Tortoise). Линтер ловит f-string внутри SQL.

# Плохо
await db.execute(f"SELECT * FROM users WHERE phone='{phone}'")

# Хорошо
await db.execute("SELECT * FROM users WHERE phone = $1", phone)

16. Не настроены индексы в БД

Описание. Таблица messages на 5 миллионов строк, индексов нет, каждый запрос — full scan.

Симптом. Бот работал нормально первый месяц, потом latency вырос с 50 мс до 5 секунд, BD на 100% CPU.

Последствия. Webhook начинает отваливаться по таймауту, дубли апдейтов, общая деградация.

Как правильно. На каждый WHERE/ORDER BY — индекс. EXPLAIN ANALYZE на ключевых запросах. Регулярный VACUUM в Postgres.

17. Нет graceful shutdown

Описание. При SIGTERM бот мгновенно умирает.

Симптом. Во время деплоя несколько последних сообщений теряются, у части пользователей висит "печатает...", оплата осталась в подвешенном состоянии.

Последствия. Каждый деплой — потерянные конверсии и звонки в поддержку.

Как правильно. На SIGTERM перестать принимать новые апдейты, дождаться завершения текущих с таймаутом 30-60 секунд, закрыть пул БД, выйти. K8s/docker даёт достаточно времени, если выставлен terminationGracePeriodSeconds.

18. Сразу в прод без staging

Описание. Все правки катятся напрямую на прод.

Симптом. Любой баг — сразу пользователям, hotfix посреди ночи становится нормой.

Последствия. Команда боится релизить, релизы редкие и большие, баги копятся.

Как правильно. dev → staging → prod. На staging — полный prod-like стек, на нём гоняются smoke-тесты и регрессионные сценарии. CI/CD блокирует релиз без зелёных тестов.

19. Нет мониторинга и алертов

Описание. Ни Prometheus, ни Sentry, ни алертов в чат — только логи в файле.

Симптом. Бот лежит четыре часа, узнаёт первый клиент, пишет руководству.

Последствия. Время реакции — часы вместо минут. Потерянные деньги и нервы.

Как правильно. Метрики ответов webhook в Prometheus, дашборд в Grafana, алерты в чат на 5xx, рост latency, рост ошибок, падение RPS. Sentry на исключения. Health-check эндпоинт для оркестратора.

20. Нет бэкапов или они не проверены

Описание. Бэкап делается, но никто никогда не пробовал из него восстановиться.

Симптом. В нужный момент оказывается, что бэкап повреждён, неполный или формат сменился полгода назад.

Последствия. Потеря всех заказов, истории общения, FSM-состояний. Восстановление с нуля — недели.

Как правильно. Ежедневный бэкап Postgres + Redis snapshot, ежемесячная учебная тревога: разворачиваем из бэкапа на staging, проверяем целостность.

21. Игнорирование 152-ФЗ

Описание. Бот собирает имя, телефон, email — нигде ни слова про политику и согласие.

Симптом. До первой проверки РКН — никаких.

Последствия. Штраф до 700 тысяч рублей за каждое нарушение, требование удалить данные, неприятная огласка.

Как правильно. Согласие до сбора данных, политика на сайте, уведомление в РКН подано, серверы в РФ. Подробнее — в нашей статье про 152-ФЗ.

22. Webhook на нестандартном пути без аутентификации

Описание. Эндпоинт /webhook-secret-path-12345 без проверки подписи — security through obscurity.

Симптом. Сканеры находят URL за неделю и начинают долбить его POST-запросами.

Последствия. DDoS, лишние затраты на трафик, риск спуфинга.

Как правильно. secret_token в заголовке + WAF/rate-limit на nginx. Путь может быть любым — главное, чтобы за ним стояла проверка.

23. Слишком короткие или длинные TTL FSM

Описание. TTL на FSM — 5 минут или, наоборот, неделя без выхода.

Симптом. Пользователь ушёл за документом, вернулся — состояние пропало (или, наоборот, через сутки бот ждёт от него ответа на старый шаг и игнорирует команды).

Последствия. Падение конверсии, "застревание" в воронке.

Как правильно. Базовый TTL — 24 часа, с явной кнопкой "начать заново". Для оплаты — 30 минут с напоминанием.

24. Миграции БД без инструмента

Описание. Изменения схемы делаются psql напрямую на проде, без alembic/goose.

Симптом. Drift между dev/staging/prod: на проде колонка есть, на staging нет. Деплой ломается.

Последствия. Часы отладки, страх перед любыми изменениями схемы.

Как правильно. Только миграции через инструмент: alembic для Python, golang-migrate/goose для Go. Каждое изменение — миграция в репозитории, прогон автоматический в CI.

25. Сообщения в нерабочее время

Описание. Рассылка стартует в три часа ночи, потому что "так удобно крон поставили".

Симптом. Десятки жалоб "разбудили", "удалите меня".

Последствия. Отписки, ухудшение open rate, плохие отзывы.

Как правильно. Рассылки только 9:00-21:00 по таймзоне получателя (сохранять её при регистрации). Транзакционные сообщения — без ограничений.

26. В Mini App не учитывается themeParams

Описание. Мини-приложение использует фиксированные цвета — белый фон и чёрный текст.

Симптом. В тёмной теме MAX интерфейс нечитаем — белое на белом или чёрное на чёрном.

Последствия. Часть пользователей просто закрывает Mini App.

Как правильно. Читать themeParams из MAX WebApp SDK, прокидывать как CSS-переменные, поддерживать light/dark, тестировать обе.

Бонус: продуктовые ошибки

Технические ошибки чинятся за день — продуктовые стоят месяцы:

  • Бот не решает реальную задачу. Сделали красиво, никто не пользуется. Лекарство — пилот на маленькой аудитории до полного запуска.
  • Сложный онбординг. Пользователь не дошёл до первой ценности — закрыл бот. Лекарство — 3-4 шага до первой выгоды.
  • Нет аналитики с первого дня. Через месяц никто не знает, что работает. Лекарство — события в Postgres + дашборд в Metabase сразу.
  • Запуск без поддержки. Пользователи пишут, никто не отвечает. Лекарство — оператор и SLA с первого дня.

Чек-лист перед запуском

Прогоните проект по этому списку:

  • FSM в Redis/Postgres, не в памяти.
  • Webhook с secret_token, ответ менее 1 секунды, тяжёлое — в очередь.
  • Обработка 429 с Retry-After, обработка 403 с пометкой inactive.
  • callback_data — короткий ID, состояние в Redis.
  • Idempotency на update_id.
  • WebApp initData валидируется на сервере.
  • Секреты в Vault или env CI/CD, никогда в git.
  • Rate limit per user, антиспам на рассылках.
  • Параметризованные запросы, индексы, миграции через alembic.
  • Graceful shutdown, health-check, мониторинг и алерты.
  • Бэкапы и учебные восстановления раз в месяц.
  • 152-ФЗ закрыт: согласие, политика, серверы РФ.
  • Тёмная тема в Mini App работает.

Итого

Большинство ошибок при разработке бота MAX — повторяющиеся и дешёвые в предотвращении. Стоит один раз заложить правильную архитектуру (webhook + очередь, FSM в Redis, idempotency, мониторинг, секреты вне кода, параметризованный SQL) — и весь дальнейший проект становится дешевле и предсказуемее. Продуктовые ошибки страшнее технических — закрывайте их через пилот, аналитику и поддержку с первого дня.

Частые вопросы

Какие самые частые ошибки при разработке бота в MAX?

Топ грабель: FSM в памяти процесса (теряется при рестарте), polling в проде с несколькими инстансами, webhook без secret_token, отсутствие обработки 429 и 403, синхронные тяжёлые вызовы прямо в handler, callback_data длиннее 64 байт, отсутствие idempotency на update_id, токен в git, прямые SQL-запросы, отсутствие индексов, отсутствие graceful shutdown, мониторинга и бэкапов. Все они дешёвые в предотвращении и дорогие в починке после релиза.

Почему нельзя обрабатывать webhook бота MAX синхронно?

Потому что MAX отваливает запрос по таймауту, если ответ не пришёл за несколько секунд. Если бот синхронно ходит в CRM, LLM или эквайринг и обработка занимает 10-30 секунд — апдейт теряется, MAX ретраит, появляются дубликаты. Правильная архитектура: webhook принимает апдейт, кладёт задачу в Redis или RabbitMQ через await queue.enqueue(...) и отвечает 200 за время менее 1 секунды. Тяжёлая бизнес-логика обрабатывается воркером асинхронно.

Почему FSM-состояния нельзя хранить в памяти процесса?

При рестарте бота все активные сессии теряются — каждый, кто заполнял форму или проходил воронку, начинает заново. Это ломает UX и убивает конверсию. Правильно — состояние FSM в Redis (с TTL 24-48 часов) или в PostgreSQL: await redis.setex(f"fsm:{user_id}", 86400, state). Это даёт устойчивость к перезапускам и горизонтальное масштабирование (несколько инстансов читают одно общее состояние).

Зачем нужна idempotency на update_id?

MAX иногда ретраит апдейты при таймаутах, очередь может задублировать сообщение, воркер может перезапуститься в середине обработки. Без idempotency-ключа один и тот же update_id обрабатывается несколько раз — создаётся два заказа, списывается двойная оплата. Правильно: перед обработкой await redis.set(f"upd:{update_id}", "1", nx=True, ex=86400) — если вернуло False, значит уже обрабатывали, выходим.

Как валидировать initData в Mini App MAX?

Это критично — без проверки атакующий подменяет user_id через DevTools. На сервере: разбираем initData как query string, вынимаем hash, остальные поля сортируем и склеиваем через \n, считаем HMAC-SHA256(secret, data_check), где secret = HMAC-SHA256("WebAppData", bot_token). Сравниваем с присланным хешем через hmac.compare_digest. Только если совпало — доверяем user.id. Без этой проверки Mini App дырявое.

Как правильно работать с секретами в боте MAX?

Никогда не хранить в репозитории — даже в приватном. Утечка через GitHub реальна, особенно через форки и старые ветки. Правильная схема: секреты в Vault, Yandex Lockbox или переменных окружения CI/CD (GitHub Actions Secrets, GitLab CI variables). В git — только .env.example без реальных значений. Если токен попал в коммит хоть на 5 минут — немедленный отзыв и ротация. В pre-commit — git-secrets или trufflehog для блокировки случайных утечек.

Что должно быть в чек-листе перед запуском бота MAX?

Минимум: FSM в Redis или Postgres, webhook с secret_token и ответом менее 1 секунды, обработка 429 и 403, idempotency на update_id, валидация initData в Mini App, секреты в env CI/CD, rate limit per user, параметризованные SQL-запросы, индексы в БД, миграции через alembic, graceful shutdown, мониторинг и алерты в чат, бэкапы с проверкой восстановления, 152-ФЗ закрыт (согласие, политика, серверы РФ), staging-окружение и базовые тесты. Без этого в прод выходить страшно.