Важно: API и интерфейсы MAX развиваются. Перед продакшеном сверьте каждый пример — формат заголовка Authorization, поля объекта Update, список заголовков вебхука — по официальной документации. Если статья расходится с dev.max.ru, приоритет у платформы.

Для кого эта статья

Если вы впервые подключаете бота к мессенджеру MAX и видите слова «вебхук», «endpoint» и «API» — ниже сначала коротко объясним смысл, затем разберём эхо-бот: он отвечает тем же текстом, который вы ему написали. Так проще понять цепочку: MAX → ваш сервер → снова MAX. Словарик в двух фразах
  • Вебхук (webhook) — это когда MAX сам присылает уведомление на ваш адрес в интернете (обычно POST с JSON). Вам не нужно постоянно опрашивать API вопросом «есть ли новые сообщения?».
  • EndpointURL вашей программы, например https://bot.example.com/webhook, куда MAX шлёт эти POST-запросы.
  • Подписка (в API MAX) — регистрация у платформы этого endpoint: «шли сюда события бота». Подробно — в разделе «Подписки» ниже.

Готовый пример: репозиторий и результат в MAX

Актуальный код эхо-бота (Flask, вебхук, проверка X-Max-Bot-Api-Secret, вызов POST /messages с user_id / chat_id в query по типу чата recipient.chat_type, деплой на Bothost) выложен в репозитории bothost-tech/bothost_webhook-max — в README описаны переменные окружения платформы и пошаговая проверка. После того как бот доступен по HTTPS и вы зарегистрировали URL вебхука в MAX (это и есть подписка — см. раздел ниже), диалог в MAX может выглядеть так: Диалог в MAX: пользователь «Привет», бот «Эхо: Привет»

Зачем боту вебхук и как быть с long polling

Чтобы бот реагировал на сообщения, кнопки и другие действия, сервер должен получать обновления (объект Update). По позиции MAX, long polling (GET /updates) ограничен по скорости и сроку хранения событий и не подходит для production; для продуктовых интеграций рекомендуется вебхук — см. «События» и раздел «Подписки» ниже (метод POST /subscriptions). Это не значит, что long polling «плох»: для локальной отладки, быстрого просмотра JSON и сценария без публичного HTTPS он часто удобнее вебхука. Когда появится стабильный HTTPS на 443, переключайтесь на вебхук. Важно: вебхук и long polling нельзя включить одновременно — при активной подписке на вебхук long polling отключается (FAQ).

Подписки: что это и как связаны с доменом

Слово «подписка» в MAX Bot API не про оплату и не про подписку пользователя на канал. Это настройка у платформы MAX: «для моего бота отправляй уведомления о событиях на вот этот адрес в интернете».

Что такое подписка на вебхук — одной мыслью

Подписка на вебхук — это указание MAX единственного (актуального) URL, куда облако будет само присылать HTTP-запросы с телом события (JSON с Update): новое сообщение, нажатие кнопки и т.д. Пока этот URL не зарегистрирован через API, для MAX ваш сервер «вне игры»: бот может крутиться на домене круглосуточно, но входящих событий по вебхуку не будет — платформа просто не знает, куда их доставлять. По смыслу это похоже на адрес доставки в службе доставки: вы получили домен и подняли сайт — это ещё не «подписка». Подписка — это отдельный шаг: вы сообщаете MAX полный HTTPS-адрес вебхука, например https://ваш-домен/webhook, и с этого момента туда и пойдут POST-уведомления.

От домена к потоку событий

Разложим по шагам, чтобы не путаться в терминах:
  • Домен и HTTPS — вы вывели бота в интернет: есть имя хоста (домен или поддомен) и валидный TLS на 443. Без этого MAX вебхук на ваш URL не повесит (требования к URL — в разделе ниже «Требования к адресу вебхука»).
  • Путь вебхука — внутри домена вы выбрали конкретный путь, куда ваш код принимает POST (часто /webhook). Вместе с протоколом и доменом это и есть полный URL вебхука.
  • Подписка — вы вызываете API MAX и передаёте этот полный URL в поле url. Формально это делает метод POST /subscriptions: вы как бы говорите платформе: «все выбранные типы событий слать сюда». Это и есть подписка на вебхук в смысле документации: не «рассылка на email», а привязка доставки событий к вашему endpoint.
  • Дальше — при новом сообщении MAX сам делает POST на ваш URL; ваш код отвечает 200 и при необходимости вызывает исходящий POST /messages, чтобы ответить пользователю в чате.
Если коротко: домен — это «где живёт сервер»; подписка — это «MAX, шли сюда уведомления о жизни бота». Без шага подписки домен не подключается к потоку событий MAX.

Проверить, что MAX запомнил адрес

Текущую конфигурацию (какой url и какие update_types сейчас сохранены) можно посмотреть запросом GET /subscriptions с тем же токеном в Authorization. Повторный POST /subscriptions обычно заменяет предыдущую подписку новой — то есть «переезд» бота на другой URL делается новым POST.

Регистрация подписки в API: POST /subscriptions

Чтобы создать или обновить подписку программно, вызывают POST https://platform-api.max.ru/subscriptions. В заголовке Authorization передаётся токен бота (из кабинета MAX). В теле JSON — как минимум url (ваш вебхук), при желании secret и список update_types.

Заголовок Authorization: с Bearer или без?

В официальном примере в документации MAX указано Authorization: {access_token} — то есть без префикса Bearer (POST /subscriptions, GET /me). Ориентируйтесь на это как на канон для MAX. Если ваш клиент или прокси при таком формате возвращает 401, проверьте токен и попробуйте вариант по RFC 6750: Authorization: Bearer <токен> — в экосистемах встречаются оба стиля; рабочий вариант лучше зафиксировать тестом GET /me перед подпиской. Пример подписки (подставьте токен, URL и секрет):
curl -X POST "https://platform-api.max.ru/subscriptions" \
  -H "Authorization: ВАШ_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-domain.com/webhook",
    "update_types": ["message_created", "bot_started"],
    "secret": "your_webhook_secret"
  }'
  • urlHTTPS, порт в URL не пишется (снаружи 443).
  • secret — строка 5–256 символов в разрешённом наборе (см. доку); MAX присылает её в X-Max-Bot-Api-Secret. Значение в подписке и в коде должно совпадать байт в байт с тем же именем переменной окружения (без лишних пробелов и переводов строк).
  • update_types — фильтр событий; полный список — в Update.
Ниже — те же POST и GET одной командой для Windows (curl.exe); разбор ответа GET см. в подразделе «Проверить, что MAX запомнил адрес». В cmd или PowerShell вызывайте именно curl.exe из системы (например C:\Windows\System32\curl.exe), а не алиас curl в PowerShell — он часто указывает на Invoke-WebRequest и ломает те же флаги.
  • -sS — не показывать прогресс-бар, но не глушить ошибки сети и TLS (-S обязателен вместе с -s, если нужна диагностика).
  • Authorization — токен бота в том же виде, с которым у вас уже проходит GET /me (см. выше: с Bearer или без — как принято у MAX для вашего окружения).
  • В -d JSON в двойных кавычках с экранированием \" внутри — удобный вариант для одной строки в Windows; url, secret и список update_types должны совпадать с тем, что вы задали в коде и в переменных окружения (WEBHOOK_URL, WEBHOOK_SECRET).
Подписка (создать или обновить): подставьте ВАШ_ТОКЕН, свой HTTPS-URL вместо примера на Bothost и свой WEBHOOK_SECRET вместо плейсхолдера:
curl.exe -sS -X POST "https://platform-api.max.ru/subscriptions" -H "Authorization: ВАШ_ТОКЕН" -H "Content-Type: application/json" -d "{\"url\":\"https://bot-1778625434-3168-alex.bothost.tech/webhook\",\"update_types\":[\"message_created\"],\"secret\":\"WEBHOOK_SECRET\"}"
Проверка подписки — запрос GET /subscriptions без тела; в ответе сверьте url, update_types и что платформа видит активную конфигурацию (детали полей — в актуальной документации MAX):
curl.exe -sS "https://platform-api.max.ru/subscriptions" -H "Authorization: ВАШ_ТОКЕН"
Если POST вернул ошибку, сначала исправьте токен, JSON или требования к url (HTTPS на 443), затем снова GET — так вы отделяете проблему «подписка не принята» от «вебхук не отвечает 200». Токен: business.max.ru«Чат-боты» → «Интеграция» → «Получить токен» (названия в интерфейсе могут меняться).

Требования к адресу вебхука

По POST /subscriptions:
  • Только HTTPS на 443; самоподписанный сертификат не подходит.
  • Домен в URL совпадает с CN/SAN сертификата; отдаётся полная цепочка сертификатов.
  • Ответ HTTP 200 в течение 30 секунд.
  • Ошибки доставки — до 10 повторов с растущей паузой; 8 часов без успеха — автоотписка.
Частая схема: приложение на 8080, nginx на 443 проксирует на приложение.

Безопасность вебхука: secret и границы модели

Документация MAX описывает проверку заголовка X-Max-Bot-Api-Secret, если при подписке задан параметр secret. Это отсекает случайные запросы на URL и снижает риск простого сканирования. Важно: это не замена полному моделированию угроз. Если платформа позже документирует, например, подпись тела запроса (аналог HMAC от сырого body), следуйте новой спецификации на dev.max.ru. В текущем разделе подписки не описан заголовок вроде X-Max-Bot-Api-Signature — не стоит копировать примеры из других сервисов (Stripe, GitHub) без проверки по документации MAX. Дополнительно (если доступно в вашей инфраструктуре): ограничение по IP, отдельный путь только для MAX, rate limit на уровне nginx.

Правила платформы (кратко)

  • Токен только в Authorization, не в query (документация).
  • secret и X-Max-Bot-Api-Secret — пара «подписка ↔ код».
  • Ориентир нагрузки на platform-api.max.ru — порядка ~30 rps; при 429 — паузы и backoff (точные лимиты смотрите в актуальной документации).

События update_types и bot_started

Список типов — в Update. Для эхо достаточно message_created. bot_started по смыслу официального описания — когда пользователь впервые начал общение с ботом или возобновил после остановки (кнопка в настройках MAX). То есть это не «строго один раз за всю жизнь аккаунта»: при повторном «запуске» сценарий может снова получить событие. Используйте его для приветствия, но не полагайтесь на то, что оно придёт ровно один раз без дублей от ретраев вебхука.

Эхо-бот с нуля (Python + Flask)

Идея: message_created → извлечь текст и адресата для POST /messages: в личке в query обычно нужен user_id отправителя, в чате/каналеchat_id (ориентир — recipient.chat_type: dialog / chat / channel) → быстро ответить 200 на вебхук. Ниже — актуальный пример из репозитория bothost_webhook-max (echo_bot.py): логирование при сбое, идемпотентность по mid, проверка секрета только если задан WEBHOOK_SECRET, HEAD на /webhook. Установка: pip install flask requests. Переменные окружения:
  • MAX_BOT_TOKEN — токен из кабинета.
  • WEBHOOK_SECRET — тот же secret, что в POST /subscriptions, если подписка с секретом; если переменная не задана, заголовок X-Max-Bot-Api-Secret не проверяется (удобно для отладки, в проде задайте пару «подписка ↔ env»).
  • Опционально MAX_USE_BEARER=1 — добавить префикс Bearer к токену для исходящих запросов, если без него GET /me или /messages дают 401.
  • PORT — порт HTTP (на Bothost задаёт платформа; локально по умолчанию 8080).
import os
import json
import hmac
import logging

import requests
from flask import Flask, request, jsonify

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("echo")

app = Flask(__name__)

MAX_API = "https://platform-api.max.ru"
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
TOKEN = (os.environ.get("MAX_BOT_TOKEN") or "").strip()
USE_BEARER = os.environ.get("MAX_USE_BEARER", "").lower() in ("1", "true", "yes")

# Идемпотентность: последние обработанные ключи (в памяти; в проде — Redis/БД)
_SEEN_MID = set()
_SEEN_MAX = 500


def auth_value() -> str:
    if USE_BEARER and not TOKEN.lower().startswith("bearer "):
        return f"Bearer {TOKEN}"
    return TOKEN


def api_headers():
    return {
        "Authorization": auth_value(),
        "Content-Type": "application/json",
    }


def extract_message_payload(data: dict):
    """Возвращает (user_id, chat_id, chat_type, text, mid) из Update или пять None.

    Для POST /messages MAX: в личке (dialog) в query обычно нужен user_id отправителя;
    в группе/канале — chat_id. См. https://dev.max.ru/docs-api/methods/POST/messages
    и recipient.chat_type в Update.
    """
    if data.get("update_type") != "message_created":
        return None, None, None, None, None
    msg = data.get("message")
    if not isinstance(msg, dict):
        logger.warning("message_created без объекта message: keys=%s", list(data.keys()))
        return None, None, None, None, None
    recipient = msg.get("recipient")
    if not isinstance(recipient, dict):
        logger.warning("Нет recipient; message keys=%s", list(msg.keys()))
        return None, None, None, None, None
    body = msg.get("body")
    if not isinstance(body, dict):
        logger.warning("Нет body; message=%s", json.dumps(msg, ensure_ascii=False)[:800])
        return None, None, None, None, None
    chat_id = recipient.get("chat_id")
    chat_type = recipient.get("chat_type")
    text = (body.get("text") or "").strip()
    mid = body.get("mid")
    user_id = None
    sender = msg.get("sender")
    if isinstance(sender, dict) and not sender.get("is_bot"):
        user_id = sender.get("user_id")
    return user_id, chat_id, chat_type, text, mid


def send_max_message(
    user_id: int | None,
    chat_id: int | None,
    recipient_chat_type: str | None,
    text: str,
) -> None:
    """POST /messages: user_id или chat_id в query (официальный пример MAX)."""
    url = f"{MAX_API}/messages"
    params = {}
    ct = (recipient_chat_type or "").strip().lower()
    if ct in ("chat", "channel") and chat_id is not None:
        params["chat_id"] = int(chat_id)
    elif user_id is not None:
        params["user_id"] = int(user_id)
    elif chat_id is not None:
        params["chat_id"] = int(chat_id)
    else:
        logger.warning("send_max_message: нет user_id и chat_id, ответ не отправлен")
        return
    body = {"text": text}
    try:
        r = requests.post(url, headers=api_headers(), params=params, json=body, timeout=15)
        if not r.ok:
            logger.error("messages API: %s %s", r.status_code, r.text[:500])
    except requests.RequestException as e:
        logger.exception("Ошибка сети при POST /messages: %s", e)


def remember_mid(mid) -> bool:
    """True если уже обрабатывали (дубликат)."""
    if not mid:
        return False
    if mid in _SEEN_MID:
        return True
    if len(_SEEN_MID) >= _SEEN_MAX:
        _SEEN_MID.clear()
    _SEEN_MID.add(mid)
    return False


@app.route("/webhook", methods=["POST", "HEAD"])
def webhook():
    if request.method == "HEAD":
        return "", 200

    if WEBHOOK_SECRET:
        got = request.headers.get("X-Max-Bot-Api-Secret", "")
        if not hmac.compare_digest(got, WEBHOOK_SECRET):
            return jsonify({"error": "forbidden"}), 403
    else:
        logger.warning(
            "WEBHOOK_SECRET не задан — вебхук без проверки заголовка (только для отладки, в проде задайте secret)"
        )

    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        logger.warning("Тело не JSON или пусто")
        return jsonify({"ok": True}), 200

    update_type = data.get("update_type")

    if update_type == "message_created":
        user_id, chat_id, chat_type, user_text, mid = extract_message_payload(data)
        if mid and remember_mid(mid):
            logger.info("Пропуск дубликата по mid=%s", mid)
            return jsonify({"ok": True}), 200
        if user_text and (user_id is not None or chat_id is not None):
            send_max_message(
                int(user_id) if user_id is not None else None,
                int(chat_id) if chat_id is not None else None,
                str(chat_type) if chat_type is not None else None,
                f"Эхо: {user_text}",
            )
        elif user_id is None and chat_id is None:
            logger.warning(
                "Не удалось извлечь user_id/chat_id; payload=%s",
                json.dumps(data, ensure_ascii=False)[:1200],
            )

    elif update_type == "bot_started":
        logger.info("bot_started: %s", json.dumps(data, ensure_ascii=False)[:500])

    return jsonify({"ok": True}), 200


@app.route("/health", methods=["GET"])
def health():
    return jsonify({"status": "ok"}), 200


if __name__ == "__main__":
    if not TOKEN:
        raise SystemExit("Задайте MAX_BOT_TOKEN")
    port = int(os.environ.get("PORT", "8080"))
    app.run(host="0.0.0.0", port=port)
Структура Update — только по официальной схеме. При сбое включите временно logger.setLevel(logging.DEBUG) или один раз залогируйте json.dumps(data, indent=2, ensure_ascii=False) (не в проде на все запросы — ПДн и шум).

Запуск, HTTPS и подписка

Запуск: python echo_bot.py. Снаружи — HTTPS (nginx + Let's Encrypt или туннель вроде ngrok для теста). Подписка (тот же secret, что в WEBHOOK_SECRET):
curl -X POST "https://platform-api.max.ru/subscriptions" \
  -H "Authorization: ВАШ_MAX_BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"url\":\"https://ваш-домен/webhook\",\"update_types\":[\"message_created\",\"bot_started\"],\"secret\":\"my-secret-12345\"}"
Проверка в MAX: напишите боту Привет → ожидается Эхо: Привет.

Если что-то не работает

  • 401 на /me или /messages — токен, формат Authorization (попробуйте MAX_USE_BEARER=1 или наоборот уберите Bearer, если копировали с префиксом).
  • 403 на вебхуке — secret в подписке и WEBHOOK_SECRET в коде не совпадают, либо в подписке без secret, а в окружении задан WEBHOOK_SECRET (MAX не пришлёт заголовок — проверка не пройдёт). Если секрет в подписке не используете, уберите WEBHOOK_SECRET из env или задайте пару «подписка ↔ env».
  • Нет эхо — логи Не удалось извлечь user_id/chat_id + сверка JSON с Update.
  • Unknown recipient на POST /messages — неверный получатель: для лички используйте user_id в query, для группы/канала — chat_id (см. актуальный пример в bothost_webhook-max).
  • Дубли эхо — ретраи вебхука; расширьте идемпотентность (Redis по mid, комбинация полей из документации).

Быстрый ответ 200 и очередь

В продакшене: проверка секрета → немедленно 200 → событие в очередь (Redis, RQ, Celery) → воркеры зовут /messages. Тяжёлая логика не должна блокировать ответ до таймаута 30 с.

Чек-лист для продакшен-бота

  • [ ] Authorization проверен тестом GET /me.
  • [ ] Подписка с secret — в коде hmac.compare_digest для X-Max-Bot-Api-Secret (если WEBHOOK_SECRET не задан, проверку заголовка в примере из bothost_webhook-max можно не выполнять — только для отладки).
  • [ ] HEAD/GET на пути вебхука при необходимости для прогрева балансировщика.
  • [ ] Идемпотентность по стабильным полям из Update (например mid).
  • [ ] Обработка 429 и Retry-After на исходящих вызовах.
  • [ ] Логи без токенов и секретов; при ошибках — алерты.

Отладка без своего домена

Для просмотра JSON удобны long polling или туннель к localhost. Для постоянной работы с MAX извне — вебхук с нормальным HTTPS.

Хостинг бота на Bothost

На Bothost задают WEBHOOK_URL, MAX_TOKEN / MAX_BOT_TOKEN и т.д. — ваш процесс отвечает 200, исходящие запросы идут на platform-api.max.ru с тем же токеном. Готовый эхо-пример с README под эту схему — репозиторий bothost-tech/bothost_webhook-max.

Куда смотреть дальше

POST /subscriptions, События, POST /messages, Update.
Ещё раз: при расхождении текста статьи с dev.max.ru верьте документации MAX.

386 просмотров
0 лайков
0 комментариев