После моей статьи о рабочем месте репетитора в 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 — после записи события в свою очередь или только после полной доставки во вторую систему?