Бот в MAX молчит: четыре грабли Bot API, о которых не пишут в документации

от автора

Синяя галочка есть. Ответа нет.

Я сижу и смотрю в экран телефона, где мой собственный бот игнорирует моё собственное сообщение. Сервер отвечает 200 OK, в логах — ни строчки паники, база чистая, YandexGPT спокойно отдаёт ответ. Всё работает. Просто человек на другом конце этого не видит.

К этому моменту я уже вторую неделю живу с MAX — российским мессенджером, который в 2026-м стало трудно игнорировать. Дальше было ещё три таких же вечера: каждый раз бот молчал по новой причине, и ни одна из них не была описана в документации словами, которые я мог бы найти поиском. Собственно, ради этого текст и написан — чтобы следующему человеку хватило получаса вместо недели.

Зачем я вообще полез в MAX

Я делаю NAMI — бота-администратора для салонов красоты: он общается с клиентом в мессенджере и записывает его к мастеру. Соло, без команды, на российском стеке (Python/FastAPI, PostgreSQL, YandexGPT — 152-ФЗ, данные должны лежать в России).

Telegram был первым каналом и работает отлично. Но у моей аудитории — мастера маникюра, барберы, владельцы небольших студий — своя реальность: Telegram и WhatsApp в России периодически «замедляют», клиент салона в лучшем случае не понимает, почему сообщение не уходит, в худшем — просто закрывает чат и не записывается. MAX работает без всяких плясок, и туда идёт государственный пуш. Для продукта, который живёт на конверсии «клиент написал → клиент записался», это не идеология, а арифметика.

Так что вопрос был не «стоит ли», а «сколько это займёт». Я посмотрел на Bot API, увидел знакомые очертания TamTam (MAX вырос из него), прикинул полдня работы — и ошибся ровно вчетверо.

Приятная новость сразу: концептуально API нормальное. Webhook, POST /messages, inline-клавиатуры, вложения. Если вы писали телеграм-ботов, вы освоитесь за час. Все четыре грабли — не про «сложно», а про «нигде не написано». Именно поэтому они и стоят вечера каждая.

Грабля нулевая, разминочная: где живёт токен

Начнём с мелочи, чтобы вы не потеряли на ней двадцать минут, как я.

Токен передаётся в заголовке Authorization, сырой строкой. Не Bearer <token> — просто токен. И не в query-параметре access_token, как во многих примерах в интернете: этот способ ещё работает, но помечен как deprecated, и MAX прямым текстом отвечает «use Authorization header».

self._headers = {"Authorization": bot_token}  # без Bearer. Совсем без.

Если добавить Bearer, получите 401 с сообщением «No access token» — что, согласитесь, звучит издевательски, когда токен вот он.

Отсюда же — правило диагностики, которое потом сэкономило мне часы:

  • 401 — проблема с токеном или способом его передачи;

  • 404 chat.not.found — с токеном всё в порядке, вы стучитесь не туда.

Запомните это разделение. Через два абзаца оно спасёт нас.

Ещё одна разминочная деталь: MAX переезжает с platform-api.max.ru на platform-api2.max.ru, старый домен отключают 19 июля 2026 года. И TLS-цепочка платформы завязана на российский корневой сертификат Минцифры — на голом Ubuntu-контейнере с certifi вы получите ошибку верификации. Я не стал требовать от сервера установленных системных сертификатов (сегодня они есть, завтра пересоберут образ — и всё встанет), а положил корень прямо в репозиторий и собрал свой bundle:

def ru_verify() -> ssl.SSLContext:    """certifi + корневой сертификат Минцифры. Один context на все российские API."""

Бонусом это же понадобилось для GigaChat — у них та же история. Мелочь, а приятно.

Грабля первая: 404 chat.not.found на чат, в котором вы прямо сейчас переписываетесь

Вот теперь — тот самый вечер с синей галочкой.

Входящий апдейт приходит, всё как ожидалось:

{  "update_type": "message_created",  "message": {    "sender":    {"user_id": 1234567, "name": "Денис"},    "recipient": {"chat_id": 7654321},    "body":      {"text": "Здравствуйте! Хочу записаться"}  }}

Берём recipient.chat_id, отвечаем ровно так, как подсказывает здравый смысл и любой опыт с Telegram:

POST /messages?chat_id=7654321→ 404 {"code": "chat.not.found"}

Чат не найден. Тот самый чат, в котором мне только что написали. Из которого я секунду назад достал этот самый chat_id.

Первая мысль — токен. Проверяю: GET /me отдаёт 200 и имя моего бота. Значит, по нашему правилу выше, 404 — это не авторизация, это адрес. Иду смотреть, какие чаты бот вообще видит:

GET /chats→ {"chats": []}

Пусто. Хотя переписка идёт.

И вот здесь щёлкает: личные диалоги один-на-один в MAX не являются «чатами». В /chats лежат только групповые. Диалог с пользователем адресуется не по chat_id, а по user_id собеседника — того самого, что лежит в sender.user_id входящего сообщения (а для нажатой кнопки — в callback.user.user_id).

# Личный диалог 1-на-1 адресуется по user_id ОТПРАВИТЕЛЯ, а не по chat_id.# Есть user_id → шлём по нему; иначе (групповой чат) — по chat_id.target = {"user_id": user_id} if user_id else {"chat_id": chat_id}resp = await client.post(f"{base}/messages", params=target, headers=headers, json=body)

200. Бот заговорил.

Что тут важно для архитектуры, а не только для «лишь бы отправилось»: chat_id я всё равно продолжаю хранить как external_chat_id — это идентификатор диалога для истории сообщений и дедупликации. А user_id — это адрес доставки. В Telegram это одно и то же число, и мой унифицированный слой каналов честно считал, что так везде. Пришлось протащить user_id через UnifiedMessage до самой отправки.

Мораль, которую стоит выписать на стену: идентификатор диалога и адрес доставки — не обязательно одно и то же. Если ваш ChannelAdapter построен на молчаливом предположении, что одно и то же, — второй мессенджер это предположение сломает.

Грабля вторая: подписка на webhook молча испаряется

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

Логи nginx: запросов на /webhooks/max нет вообще. То есть это не мы теряем — это до нас не доходит.

GET /subscriptions→ {"subscriptions": []}

Пусто. Час назад эта подписка исправно доставляла сообщения. Я её не удалял, деплоя не было, токен не менял.

Я не знаю причины — то ли TTL, то ли перезапуск на их стороне, то ли реакция на серию неудачных доставок (у меня в тот период backend пару раз уходил в unhealthy под тяжёлым тестом — вполне возможно, что MAX отписал «мёртвый» endpoint и просто не сказал об этом). Что я знаю точно: на подписку в MAX нельзя полагаться как на что-то, установленное однажды.

Лечение простое и, в общем, правильное независимо от причины — не считать регистрацию webhook разовым актом при подключении бота, а переустанавливать её при каждом старте приложения:

async def register_all_max_webhooks() -> int:    """Старт: (пере)регистрируем webhook-подписку ВСЕМ подключённым MAX-ботам.    MAX со временем СБРАСЫВАЕТ подписку (наблюдалось в бою) → без переустановки    на старте канал молча перестаёт получать сообщения."""

Вызывается в lifespan FastAPI, в лог падает честное MAX webhook: зарегистрировано 2/2 ботов. Telegram-разработчики поморщатся — там setWebhook живёт вечно; здесь же считайте это дешёвой страховкой ценой в один HTTP-запрос на старте.

Отдельно скажу про класс проблем, к которому это относится. Самая дорогая ошибка в интеграциях — не та, что валится с исключением. Та, что возвращает 200 OK и ничего не делает. Мой webhook честно отдавал 200 на каждый запрос — которых не было. Мониторинг «сервис жив» был зелёным, потому что сервис действительно был жив. Если у вас канал общения с клиентами, метрика «сервис отвечает 200» бесполезна — нужна метрика «за последний час пришло N входящих», и алерт на ноль.

Грабля третья: два бота, и MAX не говорит, кому из них написали

Тут пора вспомнить, что у меня SaaS с мультиарендностью: каждый салон подключает своего бота, со своим токеном. Второго MAX-бота я поднял для тестового тенанта — и он, разумеется, молчал. Первый при этом отвечал прекрасно.

Смотрю на приём. Резолв бота выглядел так (MVP же, один бот, что могло пойти не так):

SELECT tenant_id, bot_token FROM channel_connectionsWHERE channel = 'max' AND status = 'connected'ORDER BY updated_at DESC LIMIT 1

LIMIT 1. Все входящие всех ботов уезжали к последнему подключённому.

Ладно, чиню: возьму бота из payload и… а вот и нет. MAX не сообщает в теле апдейта, какому боту он адресован. Ни bot_id, ни чего-либо, за что можно зацепиться. Апдейт для бота А и апдейт для бота Б выглядят одинаково. У Telegram эта проблема не стоит вовсе, потому что токен зашит в URL самого вебхука, и я, честно говоря, только на MAX осознал, насколько это было удобно.

Решение подсказано самим же ограничением: если платформа не скажет мне, кто адресат — я скажу это себе сам, заранее. При регистрации подписки каждый бот получает свой URL, с меткой:

# cc_id — идентификатор подключения (tenant_id): кладём в URL как ?cc=…, чтобы приём# резолвил ИМЕННО этого бота (мультиарендность: >1 MAX-бота; MAX не сообщает бота в payload).url = f"{s.max_webhook_url}?secret={s.max_webhook_secret}&cc={cc_id}"await client.post(f"{s.max_api_base}/subscriptions", headers=headers, json={"url": url})

Приём читает ?cc=, валидирует как UUID (иначе получите чудесную SQL-инъекцию через query-параметр — нет, спасибо) и достаёт нужного бота. Старый путь без cc остался фолбэком для легаси-подписок.

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

cur = await client.get(f"{base}/subscriptions", headers=headers)for sub in (cur.json().get("subscriptions") or []):    if sub.get("url") and sub["url"] != url:        await client.delete(f"{base}/subscriptions", params={"url": sub["url"]}, headers=headers)

Формально это был скрытый блокер всей мультиарендности в MAX: до этого фикса я физически не мог подключить второго клиента. Красивая иллюстрация того, как «MVP-шный LIMIT 1» доживает до продакшена и превращается в архитектурную мину.

Чек-лист, если вы сейчас пишете бота для MAX

Коротко, чтобы можно было сверяться:

  1. Токен — сырой строкой в заголовке Authorization. Не Bearer, не query.

  2. 401 — про токен. 404 chat.not.found — про адрес. Не путайте, это разные вечера отладки.

  3. Личный диалог адресуется по user_id отправителя, не по chat_id. /chats вернёт только групповые.

  4. Переустанавливайте webhook-подписку при каждом старте приложения. Она пропадает.

  5. Один бот — один URL подписки со своей меткой. MAX не скажет, кому адресован апдейт.

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

  7. platform-api2.max.ru + корневой сертификат Минцифры в TLS-бандле.

  8. Мониторьте «сколько входящих пришло за час», а не «жив ли сервис». Тишина — это тоже инцидент, и самый противный.

Что осталось за кадром

Голосовые в MAX я ещё не добил: вложение приходит ссылкой, но формат контейнера, судя по всему, отличается от oggopus у Telegram — сейчас логирую Content-Type и определяю контейнер по сигнатуре файла. Если у кого-то уже есть выстраданное знание — буду благодарен в комментариях.

А в целом — MAX оказался нормальной платформой с честным Bot API и молодой документацией. Все четыре истории выше — не про кривой дизайн, а про то, что кто-то должен пройти этот путь первым и записать. Вот, записал.

Бот, кстати, теперь в MAX работает: принимает сообщение, ведёт диалог, находит свободный слот и записывает клиента к мастеру. Тот же код, что и в Telegram, — ровно из-за слоя ChannelAdapter, который эта интеграция дважды проверила на прочность.


Автор — Денис Мельников, делаю NAMI: платформу онлайн-записи для салонов красоты — AI-боты в Telegram и MAX записывают клиентов, панель управления показывает расписание, клиентов и мастеров. Пишу о том, что ломается по дороге в продакшен.

Следующий текст — про то, как двадцать человек одновременно записались на один и тот же слот к одному мастеру, и чем это лечится в PostgreSQL. Спойлер: EXCLUDE USING gist, но интересное началось не с него.

ссылка на оригинал статьи https://habr.com/ru/articles/1060586/