Синяя галочка есть. Ответа нет.
Я сижу и смотрю в экран телефона, где мой собственный бот игнорирует моё собственное сообщение. Сервер отвечает 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
Коротко, чтобы можно было сверяться:
-
Токен — сырой строкой в заголовке
Authorization. НеBearer, не query. -
401— про токен.404 chat.not.found— про адрес. Не путайте, это разные вечера отладки. -
Личный диалог адресуется по
user_idотправителя, не поchat_id./chatsвернёт только групповые. -
Переустанавливайте webhook-подписку при каждом старте приложения. Она пропадает.
-
Один бот — один URL подписки со своей меткой. MAX не скажет, кому адресован апдейт.
-
Перед регистрацией новой подписки удаляйте старые: MAX их копит, а не заменяет.
-
platform-api2.max.ru+ корневой сертификат Минцифры в TLS-бандле. -
Мониторьте «сколько входящих пришло за час», а не «жив ли сервис». Тишина — это тоже инцидент, и самый противный.
Что осталось за кадром
Голосовые в 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/