
Production MTProto user-бот на FastAPI + Telethon: WARP для обхода DPI и 5 граблей с Telegram
В большинстве туториалов по Telegram-ботам всё начинается с одного куска кода: получили токен у @BotFather, поставили python-telegram-bot или aiogram, написали хендлер, deploy. Это Bot API. И в 90% задач этого хватает.
А потом приходит задача которую Bot API не закрывает в принципе: программно создать супергруппу под конкретный проект и добавить туда нужных людей по @username, и сделать это десятки раз в день. Bot API такое не умеет даже теоретически — метода «создать группу» там нет, метода «добавить юзера в группу» тоже. Лезете в полную документацию Telegram API искать обход, упираетесь в раздел channels.createChannel / channels.inviteToChannel под MTProto, и начинается совсем другая история — не Bot API, а user-бот через telethon.
В этой статье разбираю как мы сделали production MTProto user-бот на FastAPI + Telethon. Под капотом: Cloudflare WARP для обхода DPI (без него с российского VPS просто не подключиться), Singleton-клиент с keepalive, in-memory cache resolve-юзеров, и 5 ограничений Telegram которые знают только те кто лез туда ногами. Реальный production-сервис у клиента в нише строительства/монтажа, обслуживает связку Planfix → Telegram-группы под каждый проект.
Сервис написан на Python 3.11. Стек: Telethon 1.43.2, FastAPI 0.136.1, Uvicorn 0.46.0, Pydantic 2.13.4. На VPS под systemd, наружу через Cloudflare Tunnel. Вызывается из n8n через HTTP-ноду.
Когда MTProto user-бот вообще нужен
Идти в MTProto имеет смысл только когда стандартный Bot API упирается в свои потолки. И не «лень разобраться», а методов нет в принципе — есть конкретные операции, которые Bot API не закрывает никак.
Что Bot API делает хорошо:
-
Отправка и редактирование сообщений
-
Обработка inline-кнопок и callback-запросов
-
Управление существующими группами/каналами (если бот добавлен админом):
banChatMember,unbanChatMember,pinChatMessage,setChatTitle -
Приём
chat_join_requestиapproveChatJoinRequest/declineChatJoinRequest— то есть автоматический контроль входа по invite-link
Что Bot API не умеет:
-
Создавать супергруппы. Совсем. Метода нет
-
Добавлять юзеров в группу. Бот не может никого «затащить» внутрь — только принимать заявки от тех, кто пришёл по invite-link
-
Резолвить
@username↔user_id. Никак -
Стартовать диалог с юзером первым (нужен
/startот юзера)
Каждая из этих операций есть в MTProto. Если хотя бы две из них в задаче — это уже довод за user-бота.
В нашем случае у клиента (ниша монтажа/ремонта/строительства, десятки одновременных проектов с подрядчиками и заказчиками) был запрос: каждая новая задача в Planfix должна автоматически порождать Telegram-чат со специалистами из карточки задачи. На один проект 3-10 человек. Раньше эту работу руками делал project-менеджер/офис-менеджер: создаёшь группу, ищешь людей в адресной книге, добавляешь, потом меняется состав проекта — снова правишь руками. На бот эта роль переносится почти на 100%.
Конкретно через MTProto закрываем:
-
Создание супергруппы (только MTProto)
-
Добавление участников по
@usernameиз карточки Planfix (только MTProto) -
Resolve
id ↔ @username(только MTProto)
Bot API в той же связке используется для оперативного управления уже созданной группой: banChatMember/unbanChatMember при изменении состава проекта, approveChatJoinRequest для автоматического одобрения вернувшихся через invite-link, pin сообщений и отправка статусов. Получается гибридная архитектура: создание и пополнение группы через MTProto user-бота, остальное через Bot API.
Архитектура сервиса
Перед кодом — общая картина. Что есть в production:
┌──────────────────────────────────────┐ │ Planfix (event source) │ └────────────────┬─────────────────────┘ │ webhook ▼ ┌──────────────────────────────────────┐ │ n8n (orchestrator, 56 нод) │ └────────────────┬─────────────────────┘ │ HTTP + X-API-Key ▼ ┌──────────────────────────────────────┐ │ FastAPI service (mtproto-api) │ │ ├─ Singleton TelegramClient │ │ ├─ /create-group /add-users │ │ │ /resolve-user /health │ │ └─ keepalive 180s + ensure_connected│ └────────────────┬─────────────────────┘ │ MTProto ▼ ┌──────────────────────────────────────┐ │ Cloudflare WARP (SOCKS5 :40000) │ │ обход РКН-DPI │ └────────────────┬─────────────────────┘ ▼ ┌──────────────────────────────────────┐ │ Telegram MTProto API │ └──────────────────────────────────────┘
Снаружи сервис доступен только через Cloudflare Tunnel (mtproto.example.ru → localhost:8080), порт 8080 на сам VPS наружу не пробрасывается. Аутентификация на уровне HTTP — заголовок X-API-Key (генерируется через openssl rand -base64 32), валидируется в FastAPI dependency перед каждым endpoint.
Endpoints:
|
Метод |
Путь |
Назначение |
|---|---|---|
|
|
|
Статус MTProto-сессии, доступен без авторизации |
|
|
|
Создать супергруппу, добавить бота и админов, опционально участников |
|
|
|
Добавить участников/админов в существующую группу |
|
|
|
Resolve id ↔ username, с in-memory кешем + deep_search по группам |
|
|
|
Размер кеша resolve-юзеров |
Запрос на /create-group выглядит так:
POST /create-groupX-API-Key: <secret>Content-Type: application/json{ "title": "Проект Объект-42 Москва", "description": "Команда проекта", "admins": ["@admin_username"], "users": ["@team_member1", "@team_member2"], "request_approval": true}
В ответе — chat_id в формате Bot API (с префиксом -100, для дальнейших вызовов Bot API), invite_link, и критичная штука для production — словари *_errors с pereason’ом если кого-то не получилось добавить:
{ "status": "ok", "chat_id": -1001234567890, "invite_link": "https://t.me/+abc...", "bot_added": true, "admins_added": ["admin_username"], "admins_errors": {}, "users_added": ["team_member1"], "users_errors": {"team_member2": "user_privacy_restricted"}}
То есть HTTP-статус 200, группа создана, но team_member2 не добавлен с указанной причиной. n8n дальше может попробовать fallback — отправить приглашение через бота лично для каждого юзера из *_errors, а на стороне юзера это будет персональный invite-link, по которому он сам кликнет и попадёт в группу.
Теперь к самой тяжёлой части — почему всё это вообще работает с российского VPS.
Главный блокер: РКН-DPI на уровне MTProto-протокола
Если вы попробуете запустить Telethon-клиент с российского VPS напрямую, то увидите красивое:
ConnectionError: Connection to Telegram failed 5 time(s)
Не resolve hostname’а, не TLS-проблема, не firewall. Проблема в том, что РКН-DPI распознаёт MTProto-протокол по сигнатуре пакета и режет соединение на уровне TCP. Обфускация, встроенная в Telethon (ConnectionTcpObfuscated), на это не реагирует — DPI её прекрасно видит.
Когда поднимал сервис в первый раз без обхода блокировки, перепробовал:
-
Альтернативные DC (datacenters Telegram) — блокируются одинаково
-
Все варианты
connectionв Telethon (ConnectionTcpFull,ConnectionTcpIntermediate,ConnectionTcpAbridged,ConnectionTcpObfuscated) — результат тот же
Финальное рабочее решение — Cloudflare WARP в режиме SOCKS5 на 127.0.0.1:40000. Что это:
-
WARP — VPN-клиент Cloudflare, есть консольная версия
warp-cliпод Linux. Бесплатный -
После
warp-cli connect— весь исходящий трафик с VPS идёт через CF-сеть. Внешний IP меняется на CF Moscow (DPI его не видит, потому что трафик зашифрован между VPS и CF и снаружи выглядит как обычный HTTPS на CF) -
Telethon настраивается на использование SOCKS5 через локальный порт 40000 (стандартный WARP SOCKS-режим)
В Telethon это выглядит так:
from telethon import TelegramClientfrom telethon.network.connection import ConnectionTcpFullimport socksPROXY = (socks.SOCKS5, "127.0.0.1", 40000)client = TelegramClient( "session", API_ID, API_HASH, connection=ConnectionTcpFull, proxy=PROXY,)
После этого client.start() подключается без проблем. Проверка живости WARP:
warp-cli status # ожидается: Connectedcurl --socks5 127.0.0.1:40000 -m 10 https://ipinfo.io/# должен вернуть IP в формате Cloudflare Moscow
WARP стартует автоматически после systemctl enable warp-svc. За всё время работы сервиса WARP не падал ни разу — стартует с VPS, работает непрерывно. Единственный риск который видел — после reboot VPS WARP поднимается не моментально, и если mtproto-api.service стартует раньше, первый коннект к Telegram падает с ConnectionError. Решение — startup-зависимость mtproto-api.service от warp-svc:
[Unit]Description=MTProto FastAPI serviceAfter=network-online.target warp-svc.serviceRequires=warp-svc.service[Service]ExecStart=/opt/mtproto-api/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8080Restart=on-failure
После этого периодические reconnect’ы в journald — это keepalive после idle-таймаута, не WARP-инциденты.
Альтернативы WARP’у — платный VPS за границей с проксированием на РФ либо свой MTProxy на foreign-хосте. Дороже, сложнее, чаще обрывается, плюс ещё один компонент в цепочке. WARP бесплатный, нативно поддерживается на Linux, и снимает проблему DPI на уровне сетевого слоя.
Singleton TelegramClient — почему это критично
Базовое правило, которое я установил для себя жирной красной чертой после двух инвалидаций сессии: один MTPROTO_SESSION = один TelegramClient в один момент времени. Никаких параллельных Python-процессов с одной и той же session-строкой.
Что происходит при нарушении правила (реальный инцидент на этапе разработки сервиса): я запустил отдельный отладочный скрипт check_user.py параллельно с уже работающим mtproto-api. Оба клиента стучались в Telegram с одинаковым session-string. Telegram security-инфраструктура ловит это как подозрительную активность («один аккаунт коннектится с двух мест одновременно») и присылает AUTH_KEY_UNREGISTERED старшему по времени подключения. Сессия инвалидирована, сервис падает, восстановление через reauth.
Дважды за один день влетел в эту яму (после первого раза думал «случайность», запустил тесты resolve-user параллельно — снова инвалидация). После этого вышло простое правило в виде файла feedback_mtproto_rules.md в репозитории сервиса:
Запрещено запускать любые скрипты с Telethon на ту же
MTPROTO_SESSIONпока работаетmtproto-api. Для интерактивных тестов — отдельная сессия на тестовом номере или остановка сервиса перед запуском.
В коде сервиса это реализовано Singleton-паттерном через FastAPI lifespan:
from contextlib import asynccontextmanagerfrom fastapi import FastAPIfrom telethon import TelegramClient@asynccontextmanagerasync def lifespan(app: FastAPI): # startup app.state.client = TelegramClient( SESSION, API_ID, API_HASH, connection=ConnectionTcpFull, proxy=(socks.SOCKS5, "127.0.0.1", 40000), ) await app.state.client.start() # warmup entity cache async for _ in app.state.client.iter_dialogs(limit=300): pass # background keepalive app.state.keepalive_task = asyncio.create_task(_keepalive(app.state.client)) yield # shutdown app.state.keepalive_task.cancel() await app.state.client.disconnect()app = FastAPI(lifespan=lifespan)
Каждый endpoint берёт клиента из request.app.state.client, никакой client = TelegramClient(...) внутри обработчиков нет. Гарантировано один экземпляр на процесс.
Восстановление после инвалидации (~5 минут):
-
systemctl stop mtproto-api -
pgrep -af telethon— убедиться что параллельных процессов нет -
Атомарный reauth-скрипт через
systemd-run --user --scope(неnohup/tmuxчерез SSH, иначе скрипт умрёт при разрыве SSH-сессии и аккаунт зависнет в полу-авторизованном состоянии) -
Получаем новый session-string. Важно: запускать с того же IP что и production (тот же WARP), иначе Telegram security ставит флаг «вход с нового IP» и не даёт восстановить через SMS
После того случая правило соблюдается строго и ни одной инвалидации больше не было.
Ещё четыре грабли с Telegram
С DPI и инвалидацией разобрались. Теперь короче — четыре оставшихся ограничений, на которых я лично спотыкался.
Mutual contact и почему свежие группы пополняются легче
InviteToChannelRequest (он же channels.inviteToChannel в MTProto) может выкинуть USER_NOT_MUTUAL_CONTACT если у добавляемого юзера privacy My Contacts или Nobody, а сессионный аккаунт не в его контактах. В официальной документации Telegram перечислены коды ошибок (USER_NOT_MUTUAL_CONTACT, USER_PRIVACY_RESTRICTED, USER_BLOCKED, USER_KICKED и ещё пятнадцать), но точные условия срабатывания не раскрываются — только краткие текстовые описания.
Эмпирическое наблюдение из практики: в первые часы после создания супергруппы Telegram пропускает добавление через inviteToChannel чаще, чем при добавлении в существующую группу которой неделя/месяц. Полагаться на это нельзя — такое поведение не зафиксировано в документации, может измениться в любой момент и без предупреждения. Архитектурно нужно всегда иметь fallback на invite-link с request_approval=true.
Практический вывод для воркфлоу:
-
При создании группы сразу добавлять всех начальных участников (большая часть пройдёт)
-
Для каждого, кто вернулся в
users_errors— сразу отправлять invite-link через бота в личку -
Для всех новых членов через дни/недели после создания — использовать только invite-link + автоодобрение через Bot API при
chat_join_requestevent
Главное: не строить архитектуру на предположении «у нас будет окно когда можно добавлять любых». Окно может схлопнуться — и схлопнется когда вам надо будет добавить ключевого юзера в проект.
После удаления из группы повторное добавление не работает
Если юзер был забанен через Bot API (banChatMember) или вышел сам — повторное добавление через InviteToChannelRequest уже не сработает. Telegram отдаст UserNotMutualContactError даже если технически до этого они были mutual contact, либо запрос пройдёт без ошибки но юзер фактически в группе не появится. Поведение зависит от конкретной комбинации privacy-настроек юзера и истории его действий, и формальной спеки на это нет.
Сценарий: специалист поработал на проекте, проект закрыли, его удалили из группы через Bot API. Через месяц проект возобновили — в Planfix снова появилось его ФИО в карточке задачи. Прямой add-user через MTProto — не работает.
Рабочая схема:
-
При удалении специалиста из проекта —
banChatMemberчерез Bot API (илиkickChatMember, что для супергрупп то же самое — юзер уходит в banned list) -
При возврате специалиста в проект — сначала
unbanChatMember(вывести из banned list), затем сформировать персональный invite-link сrequest_approval=trueи отправить юзеру в личку через бота -
Когда юзер кликает на invite-link, Telegram присылает
chat_join_requestна webhook -
Bot API workflow проверяет: есть ли его
user_idв списке участников этого проекта в нашей DataTable? Если есть —approveChatJoinRequest, если нет —declineChatJoinRequest
Через unban + invite-link + автоодобрение петля «удалили → вернули» работает прозрачно для специалиста: он получает приглашение в личку, кликает и попадает в группу за пару секунд без человеческого менеджера в цикле.
access_hash и почему нельзя добавлять по numeric id
Telethon не может разрешить произвольного юзера по числовому user_id без access_hash. Access_hash — это security-токен, который Telegram выдаёт только сессии которая уже видела этого юзера (общий чат, контакт, открытый диалог).
В Bot API эта проблема скрыта (API возвращает chat_id с которым можно работать дальше). В MTProto access_hash — отдельная сущность, которую нужно где-то взять и где-то хранить.
Best practice: всегда передавать @username вместо id в /create-group и /add-users. Telethon ресолвит username через client.get_input_entity('@username') — это работает без access_hash, потому что username сам по себе достаточен.
Если в системе есть только id (например, прилетел из webhook Bot API), есть две стратегии:
-
Сначала прогнать через
/resolve-userсdeep_search=true— итерируем участников всех групп где сессия есть, ищем нужный id -
Добавлять через invite-link (без необходимости в access_hash, юзер сам кликнет)
Deep_search занимает 5-60 секунд (зависит от количества групп), поэтому используем его только когда без id никак.
Лимиты Telegram и стратегия retry
Telegram не публикует точные числовые лимиты на rate операций — в официальной документации указан только максимум участников супергруппы (200 000), а на остальное вы натыкаетесь через FLOOD_WAIT exception с конкретным retry_after в секундах для вашей сессии в вашей ситуации. То есть лимит динамический и зависит от реальной нагрузки на DC + истории аккаунта.
У нас bulk-операций нет в принципе: бизнес-специфика — средний проект 3-10 человек в группе, массовых добавлений не делаем. Точных потолков по InviteToChannelRequest и созданию супергрупп в час не нащупывали — до FLOOD_WAIT не доходим из-за самой природы задачи.
Тем не менее заложили защиту от FLOOD_WAIT на уровне сервиса — если задача когда-нибудь поменяется и потребуется bulk:
-
asyncio.Lockв FastAPI сериализует все Telegram-вызовы → один Telethon-запрос за раз внутри сервиса, никаких параллельных API-вызовов -
В n8n между batch-операциями ставлю
Wait3-10 секунд с jitter (хотя в реальной нагрузке между задачами Planfix проходят минуты) -
На уровне endpoint —
retry x2приConnectionError -
При
FloodWaitErrorот Telethon — читаемe.seconds, ждёмseconds + 5, делаем один повтор; если упало повторно — возвращаем 429 в HTTP-ответ с тем жеsecondsв payload, дальше уже n8n решает что делать
Если когда-нибудь придётся делать массовое добавление — правильнее посылать один InviteToChannelRequest с массивом всех username сразу, чем тот же объём через отдельные вызовы. Один API-запрос дешевле по rate-budget.
Production-механика: что ещё внутри
Большую часть граблей разобрал, теперь основные паттерны, которые делают сервис стабильным.
ensure_connected перед каждым endpoint. Telethon может «висеть в connected, но send падает» при долгом idle. Поэтому перед любым API-вызовом — проверка client.is_connected() и переподключение если упало:
async def ensure_connected(client: TelegramClient): if not client.is_connected(): await client.connect() # дополнительный health-check try: await asyncio.wait_for(client.get_me(), timeout=3) except (asyncio.TimeoutError, ConnectionError): await client.disconnect() await client.connect()
Keepalive 180 секунд. Background-task, который раз в три минуты дёргает get_me(). Без него Telegram закрывает idle-соединение, и первый запрос после долгого простоя падает. С keepalive — соединение всегда живое.
Warmup iter_dialogs(limit=300) при старте. Telethon кеширует entity-объекты юзеров и групп, которые встречает. При старте мы загружаем последние 300 диалогов (~5-10 секунд), что наполняет кеш. После этого client.get_input_entity('@username') работает мгновенно, без round-trip’а к Telegram.
In-memory cache resolved юзеров. В дополнение к Telethon-кешу, держу свой dict[int, dict] в памяти сервиса. Когда /resolve-user возвращает результат, кладём в кеш. Очищается при рестарте — приемлемо, потому что warmup восстановит большую часть.
Post-invite verification. После InviteToChannelRequest сервис делает дополнительный GetParticipantRequest чтобы убедиться что юзер реально в группе. Если в users_added — это подтверждённое добавление, не «отправили запрос и надеемся». Это важно потому что Telegram иногда говорит «ок, добавлено», но фактически добавление откатывается без ошибки — GetParticipantRequest ловит это и переводит юзера в *_errors.
Безопасность
Короткие правила, которые в production-сервисе с Telegram-сессией нельзя нарушать:
-
Один client per session — строго (разбирал выше)
-
Не коммитить
.envсMTPROTO_SESSIONв git. Никогда. Session-string даёт полный доступ к аккаунту, эквивалентен паролю -
Не логировать тело запросов — содержит user_id, который в связке с
@usernameможет раскрыть приватные данные -
CORS не настраивать — сервис вызывается только server-side из n8n, фронтенд туда не ходит
-
systemd-runдля интерактивных операций, особенно reauth —nohupиtmuxчерез SSH ненадёжны при разрыве SSH-сессии, скрипт умирает на середине, аккаунт остаётся в полу-авторизованном состоянии
Что в итоге
Production MTProto user-бот сводится к набору правил, многие из которых неочевидны до того как нарушишь их:
-
Singleton-клиент на сервис (не нарушать)
-
WARP SOCKS5 для обхода DPI с РФ-VPS
-
Mutual contact и льготный период — спроектировать архитектуру под это
-
Opt-out юзера — fallback на invite-link с автоодобрением
-
@usernameвсегда лучше чем numeric id -
Keepalive + ensure_connected + warmup — паттерны без которых стабильности не будет
Сам по себе сервис не сложный — основная масса кода это правильная обработка ошибок и edge cases. Самое сложное — понять что именно эти 5 граблей существуют, до того как сервис уехал к клиенту в production.
Делаем подобные интеграции в BotKraft — пишите если у вас похожий кейс с автоматизацией Telegram под бизнес-процессы.
Полезные ссылки
-
Telethon docs — главный источник по MTProto в Python
-
Telegram API docs (MTProto methods) — справочник методов
-
Cloudflare WARP CLI — установка на Linux
-
Cloudflare Tunnel — публикация сервиса без открытых портов
-
FastAPI lifespan — Singleton-pattern для долгоживущих клиентов
ссылка на оригинал статьи https://habr.com/ru/articles/1034612/