Пролог: почему мы решили это сделать

Long Polling — это как постоянно звонить в дверь и спрашивать: «Ну что, есть что-нибудь?» MAX API отвечает, но если соединение обрывается — бот падает, а мы узнаём об этом только когда пользователи жалуются.

В документации MAX чётко сказано: «Получение обновлений с помощью Long Polling ограничено по скорости и сроку хранения событий — этот способ не подходит для production-окружения. Рекомендуем на всех этапах работы использовать Webhook».

Значит, пора.

Шаг 1. Изучение документации MAX

Первым делом — документация. MAX предлагает три метода работы с подписками:

  • POST /subscriptions — подписка на Webhook
  • GET /subscriptions — получение списка подписок
  • DELETE /subscriptions — отписка

Основные требования:

  • Webhook-endpoint должен быть доступен только по HTTPS на порту 443
  • Сертификат должен быть выдан доверенным центром (самоподписные не принимаются)
  • Ответ должен приходить в течение 30 секунд, иначе MAX повторит попытку
  • При 10 неудачных попытках в течение 8 часов бот автоматически отписывается

Шаг 2. Настройка PHP-приёмника

Мы решили, что вебхук будет принимать PHP-скрипт, который будет сохранять события в файл-очередь, а Python-воркер — забирать их оттуда. Это давало гибкость: если бот упал, события не теряются.

Создали скрипт index.php в папке бота на сервере:

<?php
define('QUEUE_FILE', __DIR__ . '/queue.jsonl');
define('SECRET_KEY', 'my-secret-key-123');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $raw_input = file_get_contents('php://input');
    $entry = json_encode(['timestamp' => time(), 'data' => json_decode($raw_input, true)]) . "\n";
    file_put_contents(QUEUE_FILE, $entry, FILE_APPEND | LOCK_EX);
    http_response_code(200);
    echo json_encode(['status' => 'ok']);
}
?>
    

Важно: скрипт должен отвечать кодом 200 мгновенно, чтобы MAX не повторял попытки. Основная обработка происходит позже.

Шаг 3. Подписка на Webhook в MAX API

Первый запрос на подписку:

curl -X POST "https://platform-api2.max.ru/subscriptions" \
  -H "Authorization: {access_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/max_api/",
    "update_types": ["message_created", "bot_started"],
    "secret": "my-secret-key-123"
  }'
    

Получили {"success":true}. Но события не шли.

Шаг 4. Ловим ошибки: секрет не совпадает

В логах Nginx мы увидели ошибку 500. Причина: fastcgi_finish_request() — эта функция работает только при PHP через FastCGI, а у нас был модуль Apache. Удалили — заработало.

Дальше — ошибка 400. PHP не мог распарсить JSON от MAX. Мы добавили логирование сырых данных и увидели, что MAX отправляет запросы без слеша — на /max_api, а не на /max_api/. Сервер возвращал 301 редирект, и тело запроса терялось.

Исправили подписку: указали URL с закрывающим слешем — https://example.com/max_api/. После этого запросы стали приходить на правильный адрес, и в логах появилось 200.

Шаг 5. Проблема с .htaccess и правами

Apache отдавал 301 редирект на /max_api/ из-за директивы DirectorySlash. Добавили в .htaccess в папке /max_api/:

DirectorySlash Off
RewriteEngine Off
    

Редирект пропал.

Также были проблемы с правами на запись в queue.jsonl — пользователь www-data не мог писать в папку www-root. Исправили права:

chown -R www-root:www-data /var/www/www-root/data/www/example.com/max_api/
chmod 775 /var/www/www-root/data/www/example.com/max_api/
chmod 666 /var/www/www-root/data/www/example.com/max_api/queue.jsonl
    

Шаг 6. Формат данных от MAX

В debug.log мы увидели реальный формат события:

{
  "timestamp": 1788524373340,
  "message": {
    "recipient": {"chat_type": "dialog", "chat_id": 123456789},
    "body": {"text": "А ты разве не можешь найти сайт администрации озера?"},
    "sender": {"user_id": 2789491, "first_name": "Геннадий"}
  },
  "update_type": "message_created"
}
    

Формат отличается от того, который использует maxapi-библиотека в Python. Пришлось адаптировать обработку.

Шаг 7. Отказ от отдельного воркера

Изначально у нас был отдельный процесс — webhook_worker.py, который читал очередь и передавал события в BotDispatcher. Но это создавало проблемы:

  • Воркер импортировал main.py и запускал бота повторно
  • Воркер создавал свои собственные хендлеры, а не использовал существующие

Мы решили: воркер не нужен. Всё можно сделать внутри main.py, заменив Long Polling на чтение очереди.

Шаг 8. Изменения в коде

bot/dispatcher.py

Убрали зависимость от main.py:

# Было:
try:
    from main import update_last_max_event_time
except ImportError:
    ...

# Стало:
def update_last_max_event_time():
    pass
    

Добавили метод process_webhook_event, который принимает данные из очереди, создаёт объект события и передаёт его в существующие хендлеры.

main.py

Вместо запуска BotDispatcher.start() (Long Polling) добавили задачу QueueReader:

async def queue_reader(dispatcher):
    while not shutdown_event.is_set():
        if Path(QUEUE_FILE).exists():
            with open(QUEUE_FILE, 'r') as f:
                lines = f.readlines()
            for line in lines:
                entry = json.loads(line.strip())
                await dispatcher.process_webhook_event(entry['data'])
        await asyncio.sleep(2)
    

Теперь main.py запускает:

  • QueueReader — читает очередь и передаёт события в диспетчер
  • VkListener — слушает VK API (LongPoll, отдельный поток)
  • TgListener — слушает Telegram
  • BackgroundSync — синхронизация постов
  • HealthCheck — мониторинг памяти и CPU
  • Supervisor — следит за задачами

Шаг 9. Секретный ключ

В MAX API при создании подписки передаётся secret. Этот ключ отправляется в заголовке X-Max-Bot-Api-Secret и позволяет проверить, что запрос пришёл именно от MAX.

В PHP-скрипте:

define('SECRET_KEY', 'my-secret-key-123');

В подписке MAX:

"secret": "my-secret-key-123"

Они должны совпадать. Если не совпадают — PHP возвращает 403, и MAX не отправляет события.

Шаг 10. Коммит и финал

git add bot/dispatcher.py main.py config.py
git commit -m "Переход с Long Polling на Webhook

- Убран Long Polling для MAX API
- Добавлен QueueReader в main.py (чтение очереди queue.jsonl)
- Добавлен метод process_webhook_event в BotDispatcher
- Удалён webhook_worker.py (больше не нужен)
- Исправлены импорты в dispatcher.py (убрана зависимость от main.py)
- В main.py добавлен путь к очереди"
    

Что мы узнали

  1. MAX API требует HTTPS на порту 443 — без этого события не приходят.
  2. URL в подписке должен заканчиваться на / — иначе MAX получает редирект и теряет тело запроса.
  3. Секрет в PHP и в подписке MAX должны совпадать — иначе PHP возвращает 403.
  4. fastcgi_finish_request() работает только при FastCGI — на модуле Apache его нет.
  5. PHP и Nginx часто работают от разных пользователей — права на запись нужно выставлять явно.
  6. Отдельный воркер — лишняя сущность — если бот уже умеет обрабатывать события, пусть он сам читает очередь.
  7. MAX присылает события в своём формате — он не совпадает с форматом maxapi-библиотеки.

Итоговая архитектура

MAX
  ↓ (Webhook)
https://example.com/max_api/
  ↓ (PHP, index.php)
queue.jsonl
  ↓ (QueueReader в main.py)
BotDispatcher.process_webhook_event()
  ↓
хендлеры → ответы
    

Вместо эпилога

Бот больше не падает. В логах только сообщения о новых событиях. Администратор спокоен. Пользователи довольны.

Главный урок: документация MAX — это хорошо, но реальный формат событий и поведение API лучше проверять на практике. И не забывайте про слеши в URL.