PunchReps ask FG to crack down on fake, adulterated food productsThe Jerusalem PostIran prolongs talks in stalemate to punish Trump ahead of US midterms, officials sayESPNFree agents: Stroud, Miller lead top options to addInquirerPagasa: Northern portions of PH to be most affected by RosalRTP DesportoFC Porto vence PSV na Youth LeagueESPN DeportesKonaté habla de la "mala suerte" de Real Madrid con los árbitrosDaily MaverickBAIL DENIED: ‘Dangerous pattern’: Shadrack Sibiya denied bail over threats and wiped cellphoneInquirer EntertainmentChristiane Amanpour says she’s leaving CNN after 43 yearsZDF heuteEntdecken Sie das ZDF-Nachrichtenstudion-tvRegierung räumt Versäumnisse ein: Frankreich arbeitet Krawalle in Untersuchungsausschuss aufANSA SportEuropa League: il danese Krogh arbitra la Juve, greco Fotias per il MilanGolem.deAnzeige: Nur bis Donnerstag | Amazon verkauft smarten Luftreiniger zum reduzierten Preis
The Daily Newsstand · Free, Always
Tuesday, October 20, 2026

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

Translate

У меня есть бот-консультант: отвечает клиентам, уточняет задачу и оформляет заявку. Он написан на 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, расскажите, с чем столкнулись вы: сертификат, лимиты, группы, что-то ещё. Интересно сравнить.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.