Как я подключил MAX и VK к Chatwoot: разбираем двусторонний мост

После моей статьи о рабочем месте репетитора в Chatwoot в комментариях закономерно спросили не о календаре и не о Jitsi, а о детали, которую я тогда почти не показал: как именно сообщения из MAX и VK попадают в Chatwoot и как ответ возвращается обратно.
Короткий ответ: это не скрапинг веб-версий и не автоматизация личных аккаунтов. MAX у меня подключён через официального бота, VK — через сообщения сообщества. Между ними и двумя API inbox в Chatwoot работает небольшой сервис на Node.js.
В этой статье разберу его фактическую реализацию: маршруты вебхуков, создание контакта и диалога, хранение соответствий, защиту от повторной обработки и передачу изображений. Заодно покажу места, где маленький рабочий мост ещё не стал отказоустойчивой шиной сообщений.
Что именно я хотел получить
У меня уже был Chatwoot как единое окно для переписки. Telegram подключался ботом, WhatsApp — отдельной интеграцией. Для MAX готового канала в моей установке Chatwoot не было, а VK мне было удобнее подключить тем же способом, не смешивая логику мессенджеров с самим Chatwoot.
Требования получились небольшими:
Новое сообщение пользователя должно появиться в правильном inbox Chatwoot.
Повторный вебхук не должен создавать повторное сообщение.
Ответ оператора в Chatwoot должен уйти в тот же внешний диалог.
Перезапуск контейнера не должен уничтожать соответствия диалогов.
Токены нельзя хранить в образе или выводить в лог.
MAX и VK должны оставаться разными каналами, даже если обслуживаются одним процессом.
Есть и принципиальное ограничение: это мост ботов и сообщества, а не личных аккаунтов. Он не читает мою личную переписку в MAX или VK и не пытается изображать браузер. Пользователь пишет MAX-боту или VK-сообществу, а оператор отвечает из Chatwoot.
Архитектура
Для каждого внешнего канала в Chatwoot создан отдельный API inbox. У входящего и исходящего направления разные инициаторы:
MAX webhook ─┐
├─> Node.js bridge ─> Chatwoot Application API
VK Callback ─┘
Chatwoot message_created webhook ─> bridge ─┬─> MAX Bot API
└─> VK API

На reverse proxy опубликованы четыре POST-маршрута:
Маршрут | Кто вызывает | Назначение |
|---|---|---|
| MAX | входящие события MAX |
| VK Callback API | подтверждение сервера и входящие события VK |
| Chatwoot | исходящие сообщения MAX inbox |
| Chatwoot | исходящие сообщения VK inbox |
Отдельно есть GET /health. Сам Node.js-контейнер не публикует порт на хост: он находится во внешней Docker-сети edge, а HTTPS завершает reverse proxy.
Почему API inbox, а не новый канал внутри Chatwoot
У Chatwoot есть удобная модель для внешних интеграций: контакт связывается с inbox через source_id, внутри inbox создаётся conversation, а сообщения добавляются через Application API.
Для нового внешнего собеседника мост последовательно создаёт:
Contact со стабильным
identifier.Связь контакта с нужным inbox и получает
source_id.Conversation с этим
source_id,inbox_idиcontact_id.Incoming message в созданном conversation.
Для MAX идентификатор имеет вид max:<тип чата>:<peer id>, для VK — vk:<peer_id>. Префикс канала важен: одинаковые числовые ID разных платформ не должны превратиться в одного человека.
Сокращённый запрос создания контакта выглядит так:
await cwFetch(`/api/v1/accounts/${accountId}/contacts`, 'POST', {
inbox_id: inboxId,
name,
identifier: `max:${peerKey}`,
additional_attributes: {
max_user_id: sender.user_id,
max_chat_id: recipient.chat_id,
max_username: sender.username,
},
});
После этого создаётся conversation:
await cwFetch(`/api/v1/accounts/${accountId}/conversations`, 'POST', {
source_id: sourceId,
inbox_id: inboxId,
contact_id: contact.id,
status: 'open',
additional_attributes: { channel: 'MAX', max_peer_key: peerKey },
});
source_id здесь не внешний ID пользователя. Это идентификатор связи contact inbox, который возвращает Chatwoot. Подставить вместо него user_id из мессенджера нельзя.
Входящее сообщение: от вебхука до Chatwoot
MAX
MAX подписывает вебхук секретом, переданным при создании подписки. Мост сравнивает заголовок X-Max-Bot-Api-Secret с локальной конфигурацией, принимает только message_created и отбрасывает сообщения от ботов:
if (req.headers['x-max-bot-api-secret'] !== cfg.maxSecret) {
res.writeHead(401);
return res.end('unauthorized');
}
if (update.update_type !== 'message_created' ||
!update.message ||
update.message.sender?.is_bot) return;
Для диалога адресатом обратной отправки будет user_id, для группового чата — chat_id. Эта информация сохраняется вместе с номером conversation в Chatwoot:
const peer = {
conversationId: conversation.id,
sendBy: isDialog ? 'user_id' : 'chat_id',
sendId: peerId,
};
Текст входящего сообщения создаётся в Chatwoot как incoming. MAX-вложения текущая версия пока не переносит: вместо них оставляет текстовую пометку с типом вложения. Это осознанно незавершённая часть, а не особенность API inbox.
VK
VK сначала отправляет событие confirmation; мост возвращает выданную строку подтверждения. Для остальных событий проверяются group_id и секрет Callback API. Обрабатывается только message_new, причём исходящие события сообщества (out) пропускаются.
if (Number(body.group_id) !== Number(VK_GROUP_ID)) return unauthorized();
if (body.type === 'confirmation') return confirmationToken();
if (body.secret !== VK_CALLBACK_SECRET) return unauthorized();
У VK-ветки есть передача фотографий. Из массива attachments выбирается вариант изображения с наибольшей площадью, файл скачивается и отправляется в Chatwoot как multipart/form-data в поле attachments[].

Обратный путь: ответ из Chatwoot
На стороне Chatwoot для каждого API inbox настроен webhook события message_created. Но далеко не каждое такое событие нужно отправлять наружу. Мост проверяет сразу несколько признаков:
if (event.event !== 'message_created' ||
event.message_type !== 'outgoing' ||
event.private ||
Number(event.inbox?.id) !== inboxId) return;
Таким образом, обратно не уходят:
входящие сообщения, которые мост сам создал в Chatwoot;
приватные заметки оператора;
сообщения из другого inbox;
остальные типы событий Chatwoot.
Затем по conversation.id находится сохранённый внешний peer.
Для MAX текст отправляется официальным методом POST https://platform-api2.max.ru/messages. Токен передаётся только в заголовке Authorization, а адресат — query-параметром user_id или chat_id:
const target = new URL('https://platform-api2.max.ru/messages');
target.searchParams.set(peer.sendBy, String(peer.sendId));
await fetch(target, {
method: 'POST',
headers: {
Authorization: maxToken,
'content-type': 'application/json',
},
body: JSON.stringify({ text: content }),
});
Для VK вызывается messages.send с peer_id и случайным random_id.
Если оператор прикрепил изображение, VK требует трёхшаговый сценарий:
Получить адрес загрузки через
photos.getMessagesUploadServer.Загрузить файл на выданный URL.
Сохранить его через
photos.saveMessagesPhotoи передать полученный идентификатор вmessages.send.
В вебхуке Chatwoot ссылка на файл приходит в data_url. Именно это поле использует работающая версия моста.
Где хранится связь диалогов
Для маленького пилота я не добавлял отдельную СУБД. Состояние лежит в /data/state.json, а /data подключён как именованный Docker volume.
{
"peers": {
"dialog:123": {
"conversationId": 17,
"sendBy": "user_id",
"sendId": 123
},
"vk:2000000001": {
"conversationId": 21,
"sendId": 2000000001
}
},
"processedMax": [],
"processedChatwoot": []
}
Числа здесь вымышленные. Реальные токены и пользовательские идентификаторы в репозиторий не входят.
Запись выполняется через временный файл и rename, чтобы процесс не оставил наполовину записанный JSON:
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
fs.renameSync(tmp, statePath);
Списки обработанных событий ограничены последними 2000 элементами каждый. Это защищает файл от бесконечного роста, но одновременно задаёт границу дедупликации: очень старое событие после вытеснения теоретически может быть обработано снова.
Дедупликация — не exactly once
У входящего MAX-сообщения сохраняется mid, у VK строится ключ из peer_id и conversation_message_id, у исходящего события используется ID сообщения Chatwoot.
Проверка выглядит просто:
if (processed.includes(eventId)) return;
await deliver(event);
processed.push(eventId);
saveState();
Это хорошо работает против обычного повторного вебхука, но не даёт строгой гарантии «ровно один раз». Если внешний API уже принял сообщение, а процесс завершился до saveState(), после повтора возможен дубль. Если мост ответил источнику HTTP 200, а затем не смог обработать событие, автоматического возврата задания в очередь нет.
Причина последнего компромисса практическая: обработчик быстро отвечает 200, а работу продолжает асинхронно, чтобы платформа не ждала цепочку запросов к Chatwoot. Для моего небольшого потока это оказалось удобнее, но для клиентского сервиса я бы изменил модель:
сначала сохранял входящее событие в durable inbox/outbox;
обрабатывал его отдельным worker;
повторял временные ошибки с backoff;
переводил исчерпавшие попытки в dead-letter queue;
хранил уникальный внешний event ID в базе данных без кольцевого лимита;
добавил метрики возраста очереди и числа недоставленных событий.

Восстановление после частичного сбоя
Есть неприятный сценарий: Chatwoot успел создать контакт, но мост завершился до сохранения локального peer. При следующей попытке Chatwoot вернёт ошибку о занятом identifier.
Текущая версия не прекращает обработку. Она фильтрует контакты по стабильному identifier, находит уже созданный contact и извлекает source_id для нужного inbox. Это восстанавливает контакт после частичного сбоя.
Однако здесь важно не обещать больше, чем реализовано: локальная потеря mapping может привести к созданию нового conversation для найденного контакта. Полное восстановление должно также искать существующий открытый conversation по contact и inbox либо хранить mapping в транзакционной базе.
Передача изображений и SSRF
Наивная реализация исходящего изображения выглядит опасно: webhook Chatwoot содержит URL, а сервер без проверки скачивает всё, что ему передали. Тогда интеграцию можно попытаться использовать для запросов к внутренним адресам.
В мосте загрузчик изображений ограничен:
только HTTPS;
для файлов из Chatwoot origin должен точно совпадать с настроенным
CHATWOOT_URL;каждый redirect проверяется заново;
разрешён только MIME-тип
image/*;максимальный размер — 10 MiB по
Content-Lengthи по фактически прочитанному буферу;не более пяти перенаправлений.
Также функция HTTP-запросов при ошибке пишет в лог только origin и pathname. Query string удаляется, потому что там могут оказаться токены VK или подписанные параметры upload URL.
Это не универсальный медиапрокси: файл всё ещё целиком читается в память, сигнатура содержимого отдельно не проверяется, а антивирусного сканирования нет. Для установленного лимита и малого потока это приемлемо; при росте нагрузки лучше перейти на потоковую загрузку и жёсткий allowlist форматов.
Конфигурация и запуск
Обычные параметры лежат в .env, токены монтируются отдельными read-only файлами:
PORT=8080
MAX_WEBHOOK_SECRET=...
CHATWOOT_URL=https://chatwoot.example.com
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_INBOX_ID=3
CHATWOOT_WEBHOOK_SECRET=...
VK_CALLBACK_SECRET=...
VK_CONFIRMATION_TOKEN=...
VK_GROUP_ID=123456789
VK_CHATWOOT_INBOX_ID=5
VK_CHATWOOT_WEBHOOK_SECRET=...
VK_CHATWOOT_ASSIGNEE_ID=1
services:
bridge:
build: .
restart: unless-stopped
env_file: .env
environment:
MAX_TOKEN_FILE: /run/secrets/max_token
CHATWOOT_TOKEN_FILE: /run/secrets/chatwoot_token
VK_TOKEN_FILE: /run/secrets/vk_token
volumes:
- bridge_data:/data
- ./secrets/max.token:/run/secrets/max_token:ro
- ./secrets/chatwoot.token:/run/secrets/chatwoot_token:ro
- ./secrets/vk.token:/run/secrets/vk_token:ro
networks:
- edge
expose:
- "8080"
Образ минимален: Node.js 22 Alpine, два JavaScript-файла и запуск от непривилегированного пользователя node. Внешние npm-зависимости не нужны: используются встроенные fetch, FormData и Blob.
После настройки нужны четыре внешних действия:
Создать MAX webhook-подписку на HTTPS URL
/maxс событиемmessage_createdи секретом.Подключить Callback API VK к
/vk, подтвердить сервер и включитьmessage_new.Создать два API inbox в Chatwoot.
Создать по webhook
message_createdдля каждого inbox на его защищённый маршрут моста.
Секрет в пути Chatwoot webhook — это не идеальная схема аутентификации: URL может попасть в административные логи. Если разворачивать мост для внешних заказчиков, я бы добавил проверяемую подпись тела запроса на reverse proxy или отдельном gateway и ротацию секрета.
Что проверено в работающем экземпляре
Мост работает в Docker рядом с Chatwoot; публичный health endpoint отвечает успешно. Для VK проверена доставка фотографии из VK в Chatwoot. Код обратной передачи изображения из Chatwoot в VK использует актуальное поле data_url, а токен сообщества имеет необходимые права, однако последний disposable-тест с реальной фотографией в обратную сторону я ещё не считаю закрытым.
Поэтому текущую матрицу возможностей корректнее описывать так:
Возможность | MAX | VK |
|---|---|---|
Входящий текст | работает | работает |
Исходящий текст | работает | работает |
Фото в Chatwoot | только пометка о вложении | проверено |
Фото из Chatwoot | не реализовано | реализовано, нужен финальный end-to-end тест |
Личные аккаунты | нет | нет |
Бот/сообщество | бот | сообщество |
Что бы я изменил перед серьёзной эксплуатацией
Текущие 366 строк JavaScript решают мою задачу, но размер кода не равен готовности к высокой нагрузке. Перед внедрением в поддержку бизнеса я бы сделал следующее:
Заменил JSON-state на SQLite или PostgreSQL с уникальными индексами для event ID.
Добавил durable queue, retries и dead-letter queue.
Не отвечал бы источнику успешным статусом до надёжного сохранения события.
Восстанавливал бы не только contact, но и существующий conversation.
Добавил бы структурированные логи, счётчики ошибок и алерты.
Покрыл бы контрактными тестами реальные payload MAX, VK и Chatwoot.
Добавил бы статусы доставки и обработку rate limit
429.Расширил бы MAX-адаптер передачей изображений и файлов.
Разделил бы общий state двух адаптеров и ввёл миграции схемы.
И отдельно: нельзя считать volume резервной копией. Для production нужны backup и тест восстановления состояния вместе с Chatwoot.
Итог
Главная часть такого моста — не HTTP-вызов messages.send. Сложность появляется на границах систем: стабильная идентичность контакта, связь с конкретным inbox, восстановление после частичного сбоя, фильтрация собственных событий, дедупликация и безопасная работа с файлами.
Для личных переписок решение не подходит и не пытается подходить. Но если клиент готов писать боту MAX или сообществу VK, Chatwoot можно использовать как единое рабочее окно без скрапинга веб-интерфейсов и хранения пользовательских сессий мессенджеров.
Если будете строить похожую интеграцию, интересно сравнить подходы: где вы проводите границу подтверждения webhook — после записи события в свою очередь или только после полной доставки во вторую систему?
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.