Переносим бота из Telegram в MAX: что отличается в API и как не переписывать логику

У меня есть бот-консультант: отвечает клиентам, уточняет задачу и оформляет заявку. Он написан на aiogram 3 и живёт внутри приложения на FastAPI, историю держит в PostgreSQL, состояния и счётчики в Redis, разговаривает через Claude. С февраля 2026 Telegram в России замедляют официально, и часть людей до бота просто не доходит. Жалоб от них нет, они молча не дописывают.
Переписывать бота под MAX с нуля не хотелось. Я исходил из того, что канал — это только транспорт. Если логика, база и интеграции отделены от мессенджера, второй канал получается адаптером на несколько сотен строк. Ниже рассказываю, что в MAX Bot API устроено иначе, чем в Telegram, где я потерял время и как всё это проверялось без боевого токена.
Как устроено ядро, чтобы транспорт был сменным
Консультант у меня — это агентный цикл. Берём историю диалога из базы, отдаём модели; пока она просит инструмент, выполняем его; в конце получаем текст ответа. Инструмент у модели ровно один, submit_lead: когда задача клиента понятна, модель заполняет поля заявки, запись ложится в базу, а карточка с кнопками уходит в рабочий чат. Ни одна строка этого цикла не знает, из какого мессенджера пришёл человек.
Чтобы так получилось, адаптер должен соблюсти несколько договорённостей.
Начнём с пользователя. В Telegram у него есть id, username и имя, и репозиторий заводит запись по этим полям. Для MAX я сделал «утиный» объект с теми же атрибутами, и функция get_or_create_user работает для обоих каналов, не зная о различиях:
class _MaxUser:
"""Duck-typed stand-in for an aiogram User, so repo.get_or_create_user works for both channels."""
def __init__(self, raw: dict[str, Any]):
self.max_id = int(raw["user_id"])
self.id = -self.max_id # negative ids = MAX users
self.username = raw.get("username") or None
name = raw.get("name") or " ".join(p for p in (raw.get("first_name"), raw.get("last_name")) if p)
self.full_name = name or None
Дальше общая таблица. Пользователи обоих мессенджеров лежат в одной таблице users. Чтобы пространства идентификаторов не пересеклись, id из MAX хранится отрицательным, а поле channel говорит, откуда человек. Тот же приём с ключами в Redis: антифлуд и дневной бюджет токенов считаются по тому же отрицательному id, и отдельных веток для второго канала в коде нет.
И включение. Канал включается одной переменной окружения MAX_BOT_TOKEN. Переменная пустая, и клиент MAX равен None; остальное приложение этого не замечает.
Что в MAX Bot API устроено иначе
Документацию на dev.max.ru я сверял 1 октября 2026 с тем, что реально отвечает сервер. Вот что отличается от Telegram Bot API и от старых примеров, которые гуляют по сети.
Хост теперь platform-api2.max.ru. Старый platform-api.max.ru из ранних примеров уже не тот.
Токен передаётся в заголовке Authorization как есть, без Bearer и без query-параметра.
Сообщение отправляется запросом POST /messages?user_id=… или ?chat_id=…, правится через PUT /messages?message_id=…, ответ на нажатие кнопки уходит в POST /answers?callback_id=…. Если в ответе на кнопку передать message, исходное сообщение заменяется целиком. Так у меня обновляется карточка заявки после кнопки «Взять в работу».
Лимиты такие: 30 запросов в секунду и 4 000 знаков в тексте. На 429 клиент делает паузу в секунду и повторяет запрос один раз, длинный ответ модели режется на части по абзацам и пробелам.
Клавиатура описывается вложением типа inline_keyboard, кнопки бывают callback с payload и link с url.
def inline_keyboard(rows: list[list[dict[str, str]]]) -> dict[str, Any]:
buttons = []
for row in rows:
out = []
for b in row:
if "url" in b:
out.append({"type": "link", "text": b["text"], "url": b["url"]})
else:
out.append({"type": "callback", "text": b["text"], "payload": b["payload"]})
buttons.append(out)
return {"type": "inline_keyboard", "payload": {"buttons": buttons}}
Больше всего времени съел сертификат. Сервер platform-api2.max.ru показывает сертификат, выпущенный «Russian Trusted Sub CA», это цепочка Минцифры. В стандартных наборах корневых сертификатов её нет, поэтому aiohttp честно падает с CERTIFICATE_VERIFY_FAILED. Отключать проверку нельзя, иначе токен бота уйдёт первому, кто встанет посередине. Я добавил промежуточный сертификат доверенным якорем только для клиента MAX, а системное хранилище и соединения с Telegram и Anthropic оставил как были.
_CA_FILE = Path(__file__).parent / "certs" / "russian_trusted_sub_ca.pem"
def _ssl_context() -> ssl.SSLContext:
ctx = ssl.create_default_context()
if _CA_FILE.exists():
ctx.load_verify_locations(cafile=str(_CA_FILE))
ctx.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN # the Sub CA is the anchor; its root is not shipped
return ctx
Флаг VERIFY_X509_PARTIAL_CHAIN нужен, потому что корневой сертификат Минцифры в контейнер я не кладу. Якорем служит промежуточный, тот самый, который присылает сервер. Его отпечаток записан рядом в README, чтобы при замене сертификата было с чем сравнить.
Типы обновлений, на которые я подписываюсь: message_created, message_callback, bot_started, bot_added, bot_admin_permissions_changed. Последние два нужны для бытовой вещи. Когда бота добавляют в группу и делают администратором, он сам пишет туда идентификатор чата, и его не приходится искать. Про группы есть отдельная деталь: события из группы бот получает только с правом администратора «читать все сообщения».
Вебхук вместо polling
В разработке удобен long polling, GET /updates с маркером, почти как getUpdates в Telegram. В бою MAX требует вебхук. Подписка делается запросом POST /subscriptions, адрес должен быть на HTTPS и порту 443, сертификат от доверенного центра, ответ 200 нужно вернуть за 30 секунд. Caddy с Let’s Encrypt закрывает это без усилий.
На этом месте я потерял час. Пока подписка активна, long polling с тем же токеном молча ничего не получает (в документации это написано, но кто читает документацию до первого бага). Локальный бот запускается, ошибок нет, сообщения не приходят. Теперь приложение в режиме polling при старте смотрит список подписок и пишет предупреждение в лог.
Сам обработчик вебхука короткий. Проверки две, как и у вебхука Telegram: неизвестный секрет в пути даёт 404, неверный заголовок X-Max-Bot-Api-Secret даёт 403. Дальше MAX нужно ответить сразу, поэтому обновление уходит в фоновую задачу.
@router.post("/max/webhook/{secret}")
async def max_webhook(secret: str, request: Request,
x_max_bot_api_secret: str | None = Header(default=None)) -> dict:
service = getattr(request.app.state, "max_service", None)
if service is None or secret != settings.max_webhook_path_secret:
raise HTTPException(status_code=404, detail="Not found")
if x_max_bot_api_secret != settings.max_webhook_secret:
raise HTTPException(status_code=403, detail="Forbidden")
update = await request.json()
msg = update.get("message") or {}
ident = ((update.get("callback") or {}).get("callback_id")
or (msg.get("body") or {}).get("mid")
or (update.get("user") or {}).get("user_id"))
if ident is not None:
key = f"upd:max:{update.get('update_type')}:{ident}:{update.get('timestamp')}"
if not await redis.set(key, 1, nx=True, ex=3600):
return {"ok": True} # MAX redelivers when it gets no 200 in time
service.spawn(update) # answer right away, handle in the background
return {"ok": True}
Повторная доставка случается на практике. Если MAX не получил 200 вовремя, он присылает обновление ещё раз, и без этого ключа в Redis клиент получал бы два ответа на одно сообщение. Для Telegram у меня такая же защита по update_id.
Если подписка при старте не встала, то есть POST /subscriptions вернул ошибку или адреса нет в GET /subscriptions, канал уходит на polling. Эндпоинт /healthz показывает, в каком режиме живёт MAX: webhook, polling или polling-fallback. Так видно, что случилось с подпиской, не читая логи.
Один ход на пользователя и прочие грабли
Человек в мессенджере часто отправляет два сообщения подряд. Если обработать их параллельно, второе возьмёт историю без первого, а модель может оформить заявку дважды. В aiogram это решается в одну строку, Dispatcher(events_isolation=SimpleEventIsolation()). Для MAX я держу asyncio.Lock на каждого пользователя внутри сервиса; процесс uvicorn один, поэтому замков в памяти достаточно. Второе сообщение ждёт, пока обработается первое.
Ещё несколько мелочей, которые у каналов разные.
Ответы клиенту отправляю простым текстом без разметки в обоих мессенджерах. Это проще, чем держать два набора форматирования и ловить ошибки экранирования.
Диплинк в MAX выглядит как
https://max.ru/<имя_бота>?start=<метка>, метка до 128 символов. Сквозная метка источника та же, что в Telegram: с сайта, из рекламы, из QR-кода на визитке.Контакт для заявки. В Telegram подставляется
@username, в MAX ника может не быть, тогда берутся имя и идентификатор профиля.Сбой MAX при старте не должен ронять приложение. Неверный токен или недоступный API дают ошибку в логе, а Telegram и форма на сайте продолжают работать.
Что не переносится
Подписчики и переписка. У MAX свои идентификаторы пользователей, и способа «скопировать аудиторию» нет. Я сделал сценарий переезда: сообщение в старом боте со ссылкой на нового, кнопка и QR-код на сайте, общий профиль клиента по телефону или почте, чтобы история не раздваивалась. Telegram при этом никуда не делся, MAX добавился рядом.
Оплата внутри чата и мини-приложения устроены иначе, и правила платформы меняются. Я проверяю их на момент конкретного проекта и в этой статье не разбираю.
Как это проверялось
Боевой токен для тестов мне не нужен. Смоук-тест поднимает поддельный клиент MAX, настоящую базу и настоящий Claude и проходит 23 шага: приветствие по диплинку с меткой, диалог, вызов инструмента, карточка с кнопками в рабочем чате, смена статуса по кнопке. Запускается он в одноразовом контейнере, и приложение при этом не стартует. Это важно, потому что старт локального бота снимает боевой вебхук Telegram, и на эти грабли я уже наступал.
В бою карточку и кнопки проверил руками в настоящем MAX: добавил бота в группу, сделал администратором, получил идентификатор чата, взял тестовую заявку в работу и закрыл.
Что бы я сделал иначе
Адаптер вместе с вебхуком и сертификатом занял у меня пару дней, и большая часть времени ушла на сертификат и на тот час с заглохшим polling. Начинал бы заново, сначала прочитал бы раздел документации про подписки, а потом писал код.
Клиент MAX я бы сразу вынес в отдельный пакет. Сейчас он живёт внутри приложения, а пригодился бы и в других ботах.
Если вы уже переносили бота в MAX, расскажите, с чем столкнулись вы: сертификат, лимиты, группы, что-то ещё. Интересно сравнить.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.