Telegram Bot Toolkit
Разработка, отладка и деплой Telegram-ботов на Python (python-telegram-bot, Telethon).
Бота планируют как воронку из TG Ads? Прочитай
references/tg-ads-bot-funnel.mdДО написания кода: 80% бот-воронок из платного трафика убыточны, и правильный ответ — не «написать бота лучше», а поменять место бота в воронке (TG Ads → лендинг с регистрацией → бот для прогрева). Смежное:telegram-ads-pro-ru,manychat-funnel-ru.
ConversationHandler: четыре поломки
Все четыре выглядят как «бот завис» и все четыре чинятся в объявлении хендлера.
per_user=True, per_chat=True— без них сцены разных юзеров лезут друг в друга, и баг проявляется только когда в боте больше одного человека, то есть на проде.filters.TEXT & ~filters.COMMANDв состояниях. С голымfilters.TEXTсостояние съедает/startкак обычный текст, и юзер не может выйти. Это и есть классическое «застрял в онбординге».- Команды в
fallbacks(/start,/cancel) — аварийный выход из любого состояния. Без него единственный способ выйти — удалить чат. per_message=False, если состояния ловят сообщения, а не callback-и.
Порядок хендлеров
Группы обрабатываются по возрастанию, и порядок здесь не косметика: команда, зарегистрированная после ConversationHandler, до неё просто не доходит.
app.add_handler(MessageHandler(filters.ALL, log_all_updates), group=-1) # видеть всё
app.add_handler(MessageHandler(filters.ALL, check_auth), group=0)
app.add_handler(CommandHandler('start', start_command), group=1) # ДО conversation
app.add_handler(CommandHandler('help', help_command), group=1)
app.add_handler(conversation_handler, group=2)
app.add_handler(MessageHandler(filters.TEXT, unknown), group=999) # ловит остальное
Callback queries
await query.answer() — первой строкой обработчика, до любой логики. Пока
callback не отвечен, у юзера крутится часик на кнопке, и он жмёт её ещё раз.
Гонки и утечки
- Критические секции (баланс, платежи) —
asyncio.Lockнаuser_id. Два быстрых сообщения от одного юзера обрабатываются параллельно, и списание проходит дважды. context.user_data.clear()при завершении conversation + периодическая чистка юзеров, у которыхlast_seenстарше суток. Иначе в long-pollinguser_dataрастёт до перезапуска процесса.app.add_error_handler(...)обязателен: логировать сexc_info=Trueи ответить юзеру человеческим текстом с подсказкой/start. Без него ошибка выглядит как молчание бота.
Деплой
- Разработка —
app.run_polling(allowed_updates=Update.ALL_TYPES). - Прод — webhook: FastAPI-эндпоинт
POST /webhook, внутриUpdate.de_json(await request.json(), app.bot)→app.process_update(update), плюсawait app.bot.set_webhook(url=..., allowed_updates=Update.ALL_TYPES). ОтдельныйGET /health— для healthcheck контейнера. - Конфиг —
pydantic_settings.BaseSettingsсenv_file=".env". Токен в коде не хранится никогда; в Docker передаётся черезenvironment.
Публичный LLM-бот: ловушки
Когда бот публичный (один токен — много юзеров) и управляется LLM с tool-calling,
стандартный wiki python-telegram-bot не покрывает ничего из этого списка.
Реализация каждого пункта → references/multi-tenant-llm-bot.md.
- Секрет юзера (BYOK) не должен попасть в контекст LLM. Оттуда он уедет в
history.jsonlи во все последующие промпты. Перехватывать ввод FSM-флагом вprofile.jsonДО вызова агента, шифровать Fernet, в ответе не эхоить значение. - Данные — по папке на
tg_user_id, не глобальными таблицами: удаление юзера должно быть однойrm -rf, а не DELETE по десятку таблиц. - Shortcuts — reply keyboard, не inline. Кнопка reply-клавиатуры приходит
обычным текстом и идёт в тот же tool-routing, что и свободный ввод. У
callback_queryнет text content — это отдельный поток и отдельный роутинг. Inline оставить только для действий над конкретным объектом (✅ Опубликовать / ✏️ Edit), где нуженcallback_data. parse_mode=HTML+ конвертер markdown→HTML. LLM генерит**bold**;Markdownустарел,MarkdownV2требует экранировать_*[]()~>#+-=|{}.!. Без конвертера юзер видит буквальные звёздочки.- В memory писать
tool_calls, а не только тексты. Иначе следующий ход агента не помнитdraft_ts/job_idиз предыдущего tool-вызова. MSYS_NO_PATHCONV=1при тестах из Git bash на Windows. MSYS превращает/startвC:/Program Files/Git/startДО запуска Python, и бот получает мусор:MSYS_NO_PATHCONV=1 python tg_client.py send @bot "/start"
Telethon: каналы и лимиты
Поиск реальных каналов (вместо LLM-галлюцинаций) — MTProto SearchRequest:
from telethon.tl.functions.contacts import SearchRequest
from telethon.tl.types import Channel
result = await client(SearchRequest(q=query, limit=limit))
chans = [c for c in result.chats
if isinstance(c, Channel) and (c.broadcast or c.megagroup)]
Фильтр broadcast or megagroup нужен, чтобы попали и каналы, и супергруппы;
различать их потом по этим же полям.
FloodWait. SearchRequest и iter_messages под нагрузкой дают
FloodWaitError(seconds=N). Ждать и ретраить при e.seconds <= 20, иначе падать
с человекочитаемой ошибкой «retry later». Плюс в system prompt агента:
"Call discover_channels AT MOST 2 times per user message" — без этого LLM
выпускает 6 запросов на одно сообщение юзера и flood-wait накапливается.
iter_messages одинаков для channel и megagroup, get_entity(@username)
резолвит оба. Разница в метриках: у супергруппы msg.views = 0, но есть
msg.reactions. Поэтому виральность считать по всем трём сигналам:
virality = views + 3 * reactions + 5 * forwards