PunchNLNG unveils AI tool to cut MRI scan timeThe Jerusalem PostWearable counter‑drone tech enters frontline service across US, Ukraine and IDF unitsBollywood HungamaMahakavya Shri Ramayan Katha producer Prakash Mahobiya alleges ‘negative marketing’; hints at big-budget Ramayana saying, “We never got a chance to reach our audience”ESPNTransfer rumors, news: Man United, Arsenal, Chelsea battle for Freiburg strikerInquirerSara Duterte: Mom prefers Baste for national politicsCNN Türk"Fon" ödemesi hangi formülle olacak?UOLTelevangelista americano Jim Bakker, envolvido em escândalos de fraude e sexo, morre aos 86 anos20 MinutenXena (24): «Sie trauen mir als Frau den Chefposten nicht zu»ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioHet Laatste NieuwsVoormalig hoofd van Duitse inlichtingendienst aangehouden op verdenking van spionage en landverraadWirtualna PolskaPrezes UOKiK: Liczymy na refleksję po stronie Google'aNHK 社会デヴィ夫人 元マネージャーなど暴行の罪で罰金20万円
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

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

Translate

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

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

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

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

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

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.