После моей статьи о рабочем месте репетитора в Chatwoot в комментариях закономерно спросили не о календаре и не о Jitsi, а о детали, которую я тогда почти не показал: как именно сообщения из MAX и VK попадают в Chatwoot и как ответ возвращается обратно.

Короткий ответ: это не скрапинг веб-версий и не автоматизация личных аккаунтов. MAX у меня подключён через официального бота, VK — через сообщения сообщества. Между ними и двумя API inbox в Chatwoot работает небольшой сервис на Node.js.

В этой статье разберу его фактическую реализацию: маршруты вебхуков, создание контакта и диалога, хранение соответствий, защиту от повторной обработки и передачу изображений. Заодно покажу места, где маленький рабочий мост ещё не стал отказоустойчивой шиной сообщений.

Обложка: MAX и VK сходятся в Chatwoot через собственный мост
Обложка: MAX и VK сходятся в Chatwoot через собственный мост

Что именно я хотел получить

У меня уже был Chatwoot как единое окно для переписки. Telegram подключался ботом, WhatsApp — отдельной интеграцией. Для MAX готового канала в моей установке Chatwoot не было, а VK мне было удобнее подключить тем же способом, не смешивая логику мессенджеров с самим Chatwoot.

Требования получились небольшими:

  1. Новое сообщение пользователя должно появиться в правильном inbox Chatwoot.

  2. Повторный вебхук не должен создавать повторное сообщение.

  3. Ответ оператора в Chatwoot должен уйти в тот же внешний диалог.

  4. Перезапуск контейнера не должен уничтожать соответствия диалогов.

  5. Токены нельзя хранить в образе или выводить в лог.

  6. 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

входящие события MAX

/vk

VK Callback API

подтверждение сервера и входящие события VK

/chatwoot/<secret>

Chatwoot

исходящие сообщения MAX inbox

/chatwoot-vk/<secret>

Chatwoot

исходящие сообщения VK inbox

Отдельно есть GET /health. Сам Node.js-контейнер не публикует порт на хост: он находится во внешней Docker-сети edge, а HTTPS завершает reverse proxy.

Почему API inbox, а не новый канал внутри Chatwoot

У Chatwoot есть удобная модель для внешних интеграций: контакт связывается с inbox через source_id, внутри inbox создаётся conversation, а сообщения добавляются через Application API.

Для нового внешнего собеседника мост последовательно создаёт:

  1. Contact со стабильным identifier.

  2. Связь контакта с нужным inbox и получает source_id.

  3. Conversation с этим source_id, inbox_id и contact_id.

  4. 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 требует трёхшаговый сценарий:

  1. Получить адрес загрузки через photos.getMessagesUploadServer.

  2. Загрузить файл на выданный URL.

  3. Сохранить его через 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 в базе данных без кольцевого лимита;

  • добавил метрики возраста очереди и числа недоставленных событий.

Граница гарантий: дедупликация есть, exactly once нет
Граница гарантий: дедупликация есть, exactly once нет

Восстановление после частичного сбоя

Есть неприятный сценарий: 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.

После настройки нужны четыре внешних действия:

  1. Создать MAX webhook-подписку на HTTPS URL /max с событием message_created и секретом.

  2. Подключить Callback API VK к /vk, подтвердить сервер и включить message_new.

  3. Создать два API inbox в Chatwoot.

  4. Создать по 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 решают мою задачу, но размер кода не равен готовности к высокой нагрузке. Перед внедрением в поддержку бизнеса я бы сделал следующее:

  1. Заменил JSON-state на SQLite или PostgreSQL с уникальными индексами для event ID.

  2. Добавил durable queue, retries и dead-letter queue.

  3. Не отвечал бы источнику успешным статусом до надёжного сохранения события.

  4. Восстанавливал бы не только contact, но и существующий conversation.

  5. Добавил бы структурированные логи, счётчики ошибок и алерты.

  6. Покрыл бы контрактными тестами реальные payload MAX, VK и Chatwoot.

  7. Добавил бы статусы доставки и обработку rate limit 429.

  8. Расширил бы MAX-адаптер передачей изображений и файлов.

  9. Разделил бы общий state двух адаптеров и ввёл миграции схемы.

И отдельно: нельзя считать volume резервной копией. Для production нужны backup и тест восстановления состояния вместе с Chatwoot.

Итог

Главная часть такого моста — не HTTP-вызов messages.send. Сложность появляется на границах систем: стабильная идентичность контакта, связь с конкретным inbox, восстановление после частичного сбоя, фильтрация собственных событий, дедупликация и безопасная работа с файлами.

Для личных переписок решение не подходит и не пытается подходить. Но если клиент готов писать боту MAX или сообществу VK, Chatwoot можно использовать как единое рабочее окно без скрапинга веб-интерфейсов и хранения пользовательских сессий мессенджеров.

Если будете строить похожую интеграцию, интересно сравнить подходы: где вы проводите границу подтверждения webhook — после записи события в свою очередь или только после полной доставки во вторую систему?