Пролог: почему мы решили это сделать
Long Polling — это как постоянно звонить в дверь и спрашивать: «Ну что, есть что-нибудь?» MAX API отвечает, но если соединение обрывается — бот падает, а мы узнаём об этом только когда пользователи жалуются.
В документации MAX чётко сказано: «Получение обновлений с помощью Long Polling ограничено по скорости и сроку хранения событий — этот способ не подходит для production-окружения. Рекомендуем на всех этапах работы использовать Webhook».
Значит, пора.
Шаг 1. Изучение документации MAX
Первым делом — документация. MAX предлагает три метода работы с подписками:
POST /subscriptions— подписка на WebhookGET /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— слушает TelegramBackgroundSync— синхронизация постовHealthCheck— мониторинг памяти и CPUSupervisor— следит за задачами
Шаг 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 добавлен путь к очереди"
Что мы узнали
- MAX API требует HTTPS на порту 443 — без этого события не приходят.
- URL в подписке должен заканчиваться на
/— иначе MAX получает редирект и теряет тело запроса. - Секрет в PHP и в подписке MAX должны совпадать — иначе PHP возвращает 403.
fastcgi_finish_request()работает только при FastCGI — на модуле Apache его нет.- PHP и Nginx часто работают от разных пользователей — права на запись нужно выставлять явно.
- Отдельный воркер — лишняя сущность — если бот уже умеет обрабатывать события, пусть он сам читает очередь.
- MAX присылает события в своём формате — он не совпадает с форматом
maxapi-библиотеки.
Итоговая архитектура
MAX
↓ (Webhook)
https://example.com/max_api/
↓ (PHP, index.php)
queue.jsonl
↓ (QueueReader в main.py)
BotDispatcher.process_webhook_event()
↓
хендлеры → ответы
Вместо эпилога
Бот больше не падает. В логах только сообщения о новых событиях. Администратор спокоен. Пользователи довольны.
Главный урок: документация MAX — это хорошо, но реальный формат событий и поведение API лучше проверять на практике. И не забывайте про слеши в URL.