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

Топ ошибок при разработке Telegram-бота

Частые ошибки при разработке Telegram-бота: от долгого webhook и утечек памяти до плохого UX и непродуманных платежей. Как их избежать на старте.

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

Большинство ботов умирают не из-за плохой идеи, а из-за технических и продуктовых ошибок, которых легко было избежать. Собрали топ проблем, с которыми регулярно встречаемся в чужих проектах при аудите и поддержке. По каждой ошибке — симптом, последствия и как сделать правильно. Большинство этих проблем стоит копейки на этапе проектирования и десятки часов разбора, когда вылезают на пользователях.

Состояние FSM в памяти процесса

Самая частая архитектурная ошибка — хранить состояние диалога в обычном Python-словаре или JS-объекте.

# Плохо
user_states = {}

@dp.message()
async def handler(message):
    user_states[message.from_user.id] = "waiting_phone"

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

Последствия: каждый деплой = массовый сброс активных сессий. Невозможно горизонтально масштабироваться. Утечка памяти при росте аудитории.

Как правильно — внешнее хранилище FSM (Redis, Postgres):

# Хорошо (aiogram 3)
from aiogram.fsm.storage.redis import RedisStorage

storage = RedisStorage.from_url("redis://localhost:6379/0")
dp = Dispatcher(storage=storage)

У grammY — @grammyjs/storage-redis, у teloxide — RedisStorage. Это базовый минимум для прода.

Polling в production с несколькими инстансами

getUpdates (long polling) предполагает ровно один потребитель. Если запустить два инстанса бота с одним токеном — оба получают разные апдейты, между ними начинаются конфликты.

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

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

Как правильно: для прода — webhook (Telegram сам шлёт апдейты на ваш HTTPS-эндпоинт, балансировка через nginx или k8s service). Polling оставьте для локальной разработки.

Webhook без secret_token

Webhook, доступный по URL без проверки подписи — открытая дверь. Любой, кто узнал ваш URL (а узнать его несложно — он часто светится в логах CDN), может слать поддельные апдейты.

# Хорошо
await bot.set_webhook(
    url="https://example.com/tg/webhook",
    secret_token=os.environ["TG_WEBHOOK_SECRET"],
)

# В обработчике
async def webhook(request):
    if request.headers.get("X-Telegram-Bot-Api-Secret-Token") != SECRET:
        return web.Response(status=403)

Симптом: всплески странных апдейтов с несуществующими chat_id, попытки эксплуатировать команды от имени админа.

Последствия: спуфинг команд, обход бизнес-логики, в худшем случае — несанкционированные действия в БД.

Как правильно: всегда задавать secret_token в setWebhook и проверять заголовок X-Telegram-Bot-Api-Secret-Token на каждом запросе.

Не обработан FloodWait и 429

Telegram ограничивает: примерно 30 сообщений в секунду на бота, 1 сообщение в секунду на чат, 20 сообщений в минуту на группу. Если ломиться сильнее — приходит 429 с полем retry_after.

Симптом: в логах массовые Too Many Requests: retry after N, рассылка частично теряется.

Последствия: при упорном игноре — временный бан бота на стороне Telegram (несколько часов), часть сообщений не доходит до пользователей.

Как правильно — exponential backoff и очередь с лимитером:

# Хорошо
async def send_with_retry(chat_id, text, max_retries=5):
    for attempt in range(max_retries):
        try:
            return await bot.send_message(chat_id, text)
        except TelegramRetryAfter as e:
            await asyncio.sleep(e.retry_after + 1)
        except TelegramForbiddenError:
            await mark_user_blocked(chat_id)
            return

Для рассылок — отдельный воркер с rate limit 25 msg/sec, не пытаться слать всё параллельно.

Synchronous тяжёлые вызовы прямо в handler

LLM-запрос на 10 секунд, SQL на 5 секунд, генерация PDF на 8 секунд — внутри обработчика апдейта.

Симптом: webhook отвечает медленно, очередь апдейтов растёт, Telegram повторяет запросы (он ждёт ответ до 60 секунд), пользователь получает дубли.

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

Как правильно: webhook отвечает 200 за 1–2 секунды, тяжёлая работа уходит в очередь (Celery/RQ для Python, BullMQ для Node, NATS/Asynq для Go). Воркер обрабатывает задачу и сам шлёт результат через sendMessage.

Не обработан 403 «бот заблокирован»

Когда пользователь нажал «Заблокировать бота», любой sendMessage на его chat_id возвращает 403 Forbidden: bot was blocked by the user.

Симптом: при рассылках — массовые ошибки 403, забитые логи, лишний трафик к Bot API.

Последствия: рассылка тормозит, метрики «доставлено» врут, в Postgres растёт мёртвый сегмент пользователей.

Как правильно: ловим TelegramForbiddenError, помечаем пользователя is_blocked=true и blocked_at=now(), исключаем из последующих рассылок. Раз в неделю можно пытаться писать снова — пользователь мог разблокировать.

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

Telegram ограничивает callback_data 64 байтами. Если пытаться запихать туда JSON с десятком полей — кнопка просто не создастся, либо обрежется.

# Плохо — может не влезть
callback_data = json.dumps({"action": "buy", "product_id": 123, "user": 456, "promo": "BLACK_FRIDAY_2025"})

# Хорошо — короткий ID, остальное в Redis
ticket_id = secrets.token_urlsafe(8)
await redis.setex(f"cb:{ticket_id}", 3600, json.dumps(payload))
button = InlineKeyboardButton(text="Купить", callback_data=f"buy:{ticket_id}")

Симптом: BUTTON_DATA_INVALID или MESSAGE_NOT_MODIFIED, кнопки не работают для длинных payload.

Как правильно: класть в callback_data короткий ID, полные данные хранить в Redis с TTL.

Параллельная обработка одного update_id

Если webhook вызвался дважды (Telegram дублировал из-за таймаута), оба вызова идут в обработку — заказ создаётся дважды.

Симптом: жалобы «у меня списали два раза», в БД дубликаты заказов с разницей в секунду.

Последствия: финансовые потери, ручные возвраты, испорченная репутация.

Как правильно — идемпотентность по update_id:

# Хорошо
async def handle_update(update):
    acquired = await redis.set(
        f"update:{update.update_id}", "1",
        ex=600, nx=True,
    )
    if not acquired:
        return  # уже обработано
    await process_update(update)

Для платежей — отдельный idempotency_key на уровне pre_checkout_query и successful_payment.

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

Токен закоммичен в config.py, в .env.example или в Dockerfile. Спустя месяц репозиторий стал публичным, или один из разработчиков унёс ноут.

Симптом: внезапно бот шлёт сообщения, которых вы не писали, либо удалён через deleteWebhook.

Последствия: компрометация всех пользователей, кража FSM-состояний, спам от вашего имени, отзыв токена через BotFather и потеря username (если повезёт — вернуть успеете).

Как правильно: токен только в переменных окружения или в секретах (Vault, AWS Secrets Manager, Doppler). В git — только .env.example с пустыми значениями. Pre-commit хук с gitleaks или trufflehog.

Нет rate limit per user

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

Симптом: пиковые нагрузки от одного user_id, остальные жалуются на таймауты.

Как правильно — лимит на уровне приложения:

# Хорошо (aiogram middleware)
async def __call__(self, handler, event, data):
    user_id = event.from_user.id
    key = f"rl:{user_id}"
    count = await redis.incr(key)
    if count == 1:
        await redis.expire(key, 60)
    if count > 30:
        return  # тихо игнорируем
    return await handler(event, data)

Стандарт — 20–30 сообщений в минуту на пользователя, для админов — выше.

Не валидируется WebApp initData

Mini App шлёт initData на бэкенд, бэкенд берёт user_id без проверки HMAC.

Симптом: внешне всё работает, но любой может через DevTools подменить initDataUnsafe.user.id и зайти от имени другого.

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

Как правильно: проверять HMAC-SHA256 по алгоритму из доков Telegram, плюс auth_date не старше нескольких часов. Подробно — в нашем посте про initData.

Жёстко зашитые тексты вместо локализации

Все строки прямо в коде: "Здравствуйте, выберите товар". Через полгода нужно добавить английский — приходится перекапывать всё кодовую базу.

Как правильно: с первого дня — словарь локализаций (gettext, i18next, fluent). Даже если язык один, ключи проще менять централизованно.

# Хорошо
await message.answer(_("greeting.welcome"))

Бонус: маркетинг сможет править копирайт без релиза, если вынести строки в Redis или в админку.

Нет fallback при отправке в группу

Бот добавлен в группу без прав на отправку, либо администратор временно лишил его прав. Любой sendMessage падает с Bad Request: not enough rights to send messages.

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

Как правильно: ловить TelegramBadRequest и TelegramForbiddenError, помечать чат как «нет прав», уведомлять администратора группы в личке (если он давал согласие) или отключать функционал для этого чата.

Глобальный try/except, который проглатывает всё

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

Симптом: бот «работает», но молча роняет половину запросов. Узнаёте об этом из жалоб через неделю.

Как правильно: логировать всё с трейсбэком, отправлять в Sentry, разделять «ожидаемые» исключения (TelegramRetryAfter, TelegramForbiddenError) и «неожиданные» (баги — должны приводить к алерту).

# Хорошо
try:
    await process(update)
except TelegramRetryAfter as e:
    await asyncio.sleep(e.retry_after)
except TelegramForbiddenError:
    await mark_blocked(update)
except Exception:
    logger.exception("unexpected handler error")
    sentry_sdk.capture_exception()

Прямые SQL-запросы без параметризации

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

Если user_id пришёл из callback_data или текста — SQL injection.

Как правильно: параметризованные запросы ($1, ?, named params) или ORM (SQLAlchemy, Tortoise, Prisma).

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

Нет индексов в БД

На старте 100 пользователей, всё летает. Через полгода — 200 тысяч, и SELECT WHERE telegram_id = ? идёт за 800 мс через seq scan.

Симптом: латентность handler'ов плавно растёт, SQL-логи показывают долгие запросы.

Как правильно: индексы на telegram_id, chat_id, created_at (если фильтруете по дате), составные индексы для популярных WHERE-комбинаций. EXPLAIN ANALYZE на топовых запросах раз в квартал.

Нет graceful shutdown

При деплое контейнер получает SIGTERM, процесс умирает мгновенно, незавершённые обработчики обрываются.

Симптом: после каждого деплоя — несколько потерянных платежей или зависших FSM.

Как правильно — обрабатывать сигналы, дожидаться текущих задач:

# Хорошо
async def main():
    stop = asyncio.Event()
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT, signal.SIGTERM):
        loop.add_signal_handler(sig, stop.set)

    polling_task = asyncio.create_task(dp.start_polling(bot))
    await stop.wait()
    await dp.stop_polling()
    await polling_task
    await bot.session.close()

Для k8s — terminationGracePeriodSeconds: 30, чтобы успеть доесть очередь.

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

«Тестировать будем на пользователях». Через два часа после релиза — массовые жалобы, откат.

Как правильно: отдельный бот @yourapp_staging с отдельной БД и отдельным токеном. Любой релиз сначала туда, прогон smoke-тестов, потом в прод. CI отдельный для каждой среды.

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

Бот молча зависает в три часа ночи. Узнаёте утром из чата техподдержки.

Как правильно:

  • /health endpoint, проверяет БД и Redis.
  • Внешний пинг (UptimeRobot, Healthchecks.io) каждые 60 секунд, алерт в Telegram при первом провале.
  • Метрики (Prometheus + Grafana или простой statsd): количество апдейтов в секунду, латентность handler'ов, очередь задач.
  • Sentry на исключения.

Нет проверенных бэкапов

Бэкап БД настроен, складывается в S3. Однажды БД ломается, идёте восстанавливать — оказывается, последний валидный дамп месячной давности.

Как правильно: автоматический бэкап ежедневно + проверка восстановления раз в месяц на отдельной машине. Принцип «бэкап без проверки восстановления — не бэкап».

Игнор 152-ФЗ

Бот собирает имя, телефон, email пользователей, но нигде нет политики обработки персональных данных, нет согласия, нет уведомления Роскомнадзора.

Последствия: штрафы РКН (до 18 млн ₽ для юрлиц после поправок 2025 года), блокировка ресурса.

Как правильно: политика обработки ПДн на сайте, явное согласие в боте перед сбором (отдельная кнопка), уведомление РКН об обработке ПДн, при использовании Telegram — уведомление о трансграничной передаче.

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

URL https://example.com/webhook — кто угодно может ddos'ить ваш handler пустыми POST'ами.

Как правильно: путь с секретом (/tg/webhook/<random_token>) + проверка X-Telegram-Bot-Api-Secret-Token + rate limit на nginx. IP whitelist Telegram (диапазоны опубликованы) — для параноидальных случаев.

Слишком короткий или вечный TTL у FSM

Если TTL FSM 5 минут — пользователь, отвлёкшийся на звонок, возвращается и видит «Не понимаю» вместо продолжения. Если TTL вечный — забытые сценарии плодятся в Redis годами.

Как правильно: TTL 1–7 дней для большинства сценариев, активный сброс по /cancel и при завершении сценария.

Хранение паролей пользователей

Бот придумывает свою регистрацию: логин, пароль, хеш в БД. Зачем — непонятно. Telegram уже аутентифицировал пользователя через user_id.

Как правильно: пользователь = telegram_id. Никаких паролей, никаких email-подтверждений, никаких «забыли пароль». Это главная фишка ботов.

Миграции БД руками

Разработчик локально дописал колонку через ALTER TABLE, в проде колонки нет, релиз падает.

Как правильно: миграции через инструмент (Alembic для Python, dbmate, Flyway, Prisma Migrate). Каждое изменение схемы — отдельная миграция в git, применяется CI на каждой среде. Никаких ручных ALTER TABLE на проде.

Рассылки в нерабочее время

Маркетинг запустил рассылку в 4 утра «по всей базе». Половина пользователей разбужена пушем, нажимает «Заблокировать бота».

Как правильно: учитывать language_code пользователя для приблизительной таймзоны или явно собирать таймзону в профиле. Окно отправки — 10:00–21:00 по локальному времени. Опция «не беспокоить» в настройках.

В Mini App не проверяются themeParams

Mini App вёрстана под светлую тему. Пользователь с тёмной темой Telegram видит чёрный текст на чёрном фоне.

Как правильно: использовать Telegram.WebApp.themeParams или CSS-переменные --tg-theme-*:

/* Хорошо */
body {
  background: var(--tg-theme-bg-color, #ffffff);
  color: var(--tg-theme-text-color, #000000);
}
.button {
  background: var(--tg-theme-button-color, #2481cc);
  color: var(--tg-theme-button-text-color, #ffffff);
}

Плюс подписка на themeChanged — пользователь может переключить тему прямо в Mini App.

Итого

Топ ошибок при разработке Telegram-бота: FSM в памяти процесса, polling в проде вместо webhook, webhook без secret_token, игнор FloodWait и 403, синхронные тяжёлые вызовы в handler, длинный callback_data, отсутствие идемпотентности по update_id, токен в git, нет rate limit per user, не проверяется initData Mini App, отсутствие локализации, проглатывание ошибок в глобальном try/except, SQL injection и отсутствие индексов, нет graceful shutdown и мониторинга, игнор 152-ФЗ. Большинство решается на этапе проектирования и стоит копейки. Когда вылезает на пользователях — стоит на порядки дороже. Хорошая архитектура — лучшая инвестиция в долгую жизнь бота.

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

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

Хранение состояний в обычном словаре или объекте кажется простым, но при первом же рестарте все активные диалоги ломаются — пользователи застревают посреди сценария. При запуске нескольких воркеров каждый видит только свой кусок состояний, и поведение становится недетерминированным. Память растёт линейно с аудиторией, утечки неизбежны. Правильно — внешнее хранилище FSM: Redis, Postgres или MongoDB. У aiogram это RedisStorage, у grammY — @grammyjs/storage-*, у teloxide — RedisStorage. Это базовый минимум для любого продакшен-бота, без него после первого деплоя теряются все активные диалоги.

Как защитить webhook Telegram-бота от подделки и DDoS?

Webhook без проверки подписи — открытая дверь, любой может слать поддельные апдейты. Минимум: при setWebhook задать secret_token, на каждом запросе проверять заголовок X-Telegram-Bot-Api-Secret-Token и отвечать 403 если не совпадает. Слушать только HTTPS. Использовать длинный случайный путь вместо /webhook, чтобы URL не угадывался. Включить rate limit на nginx. Для параноидальных случаев — IP whitelist по диапазонам Telegram. Без этих мер handler можно ddos-ить пустыми POST'ами или спуфить команды от имени админа.

Почему нельзя делать тяжёлые вызовы прямо в webhook handler?

Telegram повторяет запрос, если webhook не ответил за 60 секунд — пользователь получает дубли. LLM-запрос на 10 секунд, SQL на 5 секунд, генерация PDF на 8 секунд внутри handler приводят к деградации под нагрузкой и потере апдейтов. Правильная архитектура: webhook отвечает 200 за 1–2 секунды, тяжёлые задачи кладутся в очередь (Celery/RQ для Python, BullMQ для Node, NATS/Asynq для Go), отдельный воркер обрабатывает задачу и сам шлёт результат через sendMessage. Это же даёт устойчивость к падениям воркера и возможность масштабироваться.

Как правильно обрабатывать ошибки Bot API: 403, 429, 500?

Bot API возвращает несколько классов ошибок, и каждый требует своей логики. 403 — пользователь заблокировал бота: ловим TelegramForbiddenError, помечаем is_blocked=true в БД, исключаем из рассылок. 429 (Too Many Requests, FloodWait) — делаем sleep на retry_after секунд и повторяем; для рассылок добавляем rate limiter на 25 msg/sec. 500 — повторяем с exponential backoff, не считаем сообщение отправленным. 400 — логируем как баг, не повторяем (плохой запрос не починится). Глобальный try/except: pass — антипаттерн, всё валится в Sentry.

Как обеспечить идемпотентность в Telegram-боте?

Telegram может прислать один update_id дважды (если webhook не ответил вовремя или сеть моргнула). Без идемпотентности заказ создаётся два раза, платёж списывается дважды. Решение: на входе webhook делаем redis.set(f"update:id", "1", ex=600, nx=True) — если ключ уже существует, выходим. Для платежей дополнительно — уникальный idempotency_key на pre_checkout_query и successful_payment, БД должна иметь UNIQUE-индекс на этот ключ. callback_data длиннее 64 байт также вызывает проблемы — храните полный payload в Redis, в callback_data кладите короткий ID.

Какие основные ошибки безопасности при разработке Telegram-бота?

Топ дыр в безопасности: токен бота в git или в коде вместо переменных окружения (компрометация = потеря бота); webhook без secret_token (спуфинг команд); прямые SQL-запросы с подстановкой переменных вместо параметризации (SQL injection); отсутствие проверки HMAC у Mini App initData (подмена user_id через DevTools); нет rate limit per user (один тролль кладёт бот); хранение паролей пользователей вместо доверия Telegram-аутентификации; игнор 152-ФЗ при сборе ПДн (штрафы РКН до 18 млн ₽). Все эти проблемы решаются стандартными практиками — секрет-менеджер, ORM, HMAC-валидация, политика ПДн.

Какие операционные ошибки приводят к простоям Telegram-бота?

Главные операционные грабли: нет graceful shutdown (SIGTERM убивает процесс мгновенно, FSM теряются); нет /health endpoint и внешнего мониторинга (зависание замечают через жалобы); нет staging-окружения с отдельным ботом и БД (баги летят в прод); миграции БД руками вместо alembic/dbmate/flyway (drift между средами); бэкап без проверки восстановления (когда нужно — оказывается невалидным); нет индексов в БД (запросы тормозят с ростом базы); рассылки в нерабочее время местного пользователя (массовые блокировки бота). Каждый пункт — несколько часов настройки на старте против дней разбора потом.