Задача
Я основатель сервиса, который автоматизирует выпуск рекламных постов в каналах MAX, и его поддержка жила в обычном аккаунте мессенджера. Со временем там набралось 253 диалога и около 1 400 ответов, и на таком объёме личный аккаунт перестал справляться. У него нет API, поэтому нельзя прикрутить ни автоответы, ни шаблоны, ни отчёты. Нет статусов, и решён вопрос или висит, видно только по самой переписке. Вход один на всю команду, так что непонятно, кто кому отвечал. Когда я выгрузил историю и разобрал её, оказалось, что 22 человека так и не дождались ответа, а один и тот же срок выплат мы называли четырьмя разными способами.
Писать клиенты при этом должны были по-прежнему в MAX. Там они проводят день и получают от нас уведомления, а на сайт заходят на пару минут, и в основном с телефона. Виджет чата на сайте в такой ситуации ничего не решает: окно живёт, пока открыта вкладка, и ответ человек увидит, только если вернётся. Поэтому клиент пишет боту в MAX, операторы работают в нормальной системе поддержки, а между ними стоит мост.
Систему поддержки я выбрал Chatwoot. Это open source, он ставится на свой сервер, умеет API-входящие для произвольного канала и даёт очередь, назначения, шаблоны ответов и отчёты из коробки. Облачная версия не подошла: её серверы за рубежом, а в переписке с клиентами есть персональные данные. Первая рабочая версия моста заработала примерно за час, ещё пару вечеров ушло на то, чтобы довести её до ума.
Схема
Мостом я называю небольшой сервис-посредник между двумя системами, которые не умеют разговаривать друг с другом напрямую. Он забирает новые сообщения из бота MAX и создаёт их в Chatwoot, а ответы операторов из Chatwoot отправляет обратно клиенту в MAX. MAX и Chatwoot друг о друге не знают, вся логика в мосте.
MAX Bot API ── GET /updates (long polling) ──▶ мост ── REST API ────▶ ChatwootMAX Bot API ◀── POST /messages, /uploads ──── мост ◀── ActionCable ── Chatwoot
Всё живёт в одном docker compose: Chatwoot v4.18.0-ce отдельными сервисами rails и sidekiq, PostgreSQL с pgvector, Redis с паролем и Caddy, который сам получает сертификат Let’s Encrypt и единственный смотрит наружу. Мост работает в отдельном контейнере на python:3.12-alpine. Хватает сервера на 1 vCPU и 4 ГБ памяти с подкачкой 2 ГБ, после запуска занято около 0,9 ГБ. На 2 ГБ Chatwoot вместе с базой и sidekiq будет тесно, это я бы не экономил.
Входящих портов у моста нет. В MAX он ходит long polling’ом, в Chatwoot по внутренней сети compose. Внешних библиотек тоже нет, только стандартная библиотека Python: файл подключается в контейнер томом, образ не собирается, а обновление сводится к копированию одного файла и перезапуску. Из-за этого WebSocket-клиент для ActionCable я написал сам по RFC 6455, вышло около 120 строк с рукопожатием, маскированием кадров, фрагментами, ping/pong и закрытием.
Как это выглядит для клиента и оператора
Для клиента это обычный чат с ботом в MAX. Он пишет вопрос и сразу получает автоответ, что запрос принят, а ответ оператора приходит в тот же чат с обычным уведомлением. На частые вопросы бот отвечает сам через меню /help, а после закрытия диалога просит оценить помощь. Операторы в это время работают в Chatwoot: видят общую очередь и отвечают из шаблонов, причём тексты шаблонов те же, что клиент видит в /help.
Как зарегистрировать бота в MAX
Создать бота в MAX можно только от имени юрлица, ИП или самозанятого с резидентством РФ, физическому лицу платформа его не даст. Регистрация идёт на платформе MAX для партнёров business.max.ru или в мини-приложении «MAX для бизнеса» прямо в мессенджере. Входите по номеру телефона, создаёте профиль организации и подтверждаете его через Госуслуги, T-Business ID или Alfa ID. Подтверждать организацию должен руководитель или администратор её кабинета на Госуслугах, роль сотрудника не подойдёт.
Дальше раздел «Чат-боты» и кнопка «Создать». В карточке нужны логотип 500×500, название до 59 символов и описание до 200. Никнейм платформа выдаёт сама по шаблону idИНН_bot, выбрать свой пока нельзя. Организация может завести до пяти ботов, самозанятый до двух.
После создания бот уходит на модерацию. Она занимает до 48 часов по рабочим дням, а о статусе пишет бот «MAX для бизнеса». Токен для API появляется только после модерации: «Чат-боты», меню бота, «Настройки», поле «Токен доступа». Любая правка карточки снова отправляет бота на модерацию, поэтому название и описание лучше продумать сразу. Добавление в групповые чаты по умолчанию выключено, для бота поддержки его так и стоит оставить.
Особенности MAX Bot API
Сертификат Минцифры
Сертификат API MAX выпущен удостоверяющим центром Минцифры, и его корня нет в системных хранилищах. curl и Python падают с unable to get local issuer certificate, а в интернете чаще всего советуют просто отключить проверку. Я так делать не стал, потому что токен бота уходит в каждом запросе. Вместо этого рядом лежит корневой сертификат Russian Trusted Root CA, и для запросов к MAX собран отдельный SSL-контекст. Токен в заголовке Authorization передаётся как есть, без Bearer.
MAX_SSL = ssl.create_default_context(cafile=MAX_CA_FILE)# Файлы MAX лежат и на *.max.ru (корень Минцифры), и на CDN с обычными сертификатамиDOWNLOAD_SSL = ssl.create_default_context()DOWNLOAD_SSL.load_verify_locations(cafile=MAX_CA_FILE)def max_api(method, path, body=None, timeout=40): return http_json(method, f"{MAX_API}{path}", body, {"Authorization": MAX_TOKEN}, timeout, MAX_SSL)
Вложения не готовы сразу
Файл загружается в MAX через POST /uploads, в ответ приходит токен, с которым потом отправляется сообщение. Если отправить его сразу, MAX может ответить attachment.not.ready: он ещё обрабатывает файл. Помогает повтор с нарастающими паузами в 1, 2, 4, 8 и 15 секунд, на практике хватает первых двух.
Приветствие мимо Chatwoot
На «Запустить бота» (bot_started) и на /start бот здоровается и просит почту аккаунта, чтобы оператор сразу нашёл человека. Сначала напрашивается отправить приветствие через Chatwoot, но тогда он сочтёт диалог начатым и не пришлёт автоответ «Ваш запрос принят» на первое настоящее сообщение клиента. Поэтому мост отправляет приветствие прямо в MAX, а /start дополнительно закрывает открытые диалоги клиента, чтобы следующее сообщение начало новый.
Ограничения, которые влияют на продукт
Бот не может написать первым, только тем, кто уже писал ему сам. Поэтому ссылка на бота должна стоять везде, где клиент ищет помощь, у нас кнопка «Поддержка» на сайте ведёт прямо в него. Ответ 200 OK на отправку тоже не означает, что человек сообщение увидел: оно может лечь во вкладку «Новые», а не в общий список. Если клиент говорит, что ничего не пришло, первым делом стоит попросить его заглянуть туда. У сообщения в MAX лимит в 4 000 символов, поэтому мост режет длинные ответы по переводам строк.
Особенности Chatwoot
Вебхуки не ходят во внутреннюю сеть
Сначала я хотел сделать по-простому: API-входящие Chatwoot шлют вебхук на мост при каждом ответе оператора. Так не вышло, перед запросом Chatwoot проверяет адрес и отказывается ходить на приватные IP, а мост живёт во внутренней сети compose. Приватные сети можно разрешить настройкой, но это ослабит защиту всей установки, поэтому я пошёл другим путём и подписался на тот же ActionCable, через который обновляется интерфейс оператора. Для подписки нужен pubsub_token из /api/v1/profile:
def cable_identity(): """pubsub_token и id владельца API-токена: с ними RoomChannel отдаёт события его входящих.""" profile = http_json("GET", f"{CHATWOOT_URL}/api/v1/profile", headers={"api_access_token": CHATWOOT_TOKEN}) return json.dumps({"channel": "RoomChannel", "pubsub_token": profile["pubsub_token"], "account_id": int(ACCOUNT_ID), "user_id": profile["id"]}, separators=(",", ":"))
Дальше обычный протокол ActionCable: ждём welcome, отправляем subscribe, ждём confirm_subscription. Из событий нужны два: message.created для ответов операторов, автоответов и опроса качества и conversation.status_changed для паузы. Сокет иногда умирает молча, поэтому после 6 секунд тишины мост шлёт свой ping (сам ActionCable пингует раз в 3 секунды), а после 15 переподключается с растущей паузой до минуты. Чтобы ничего не потерять за время разрыва, раз в 30 секунд и сразу после переподключения идёт сверка через REST.
FORCE_SSL
HTTPS снимает Caddy, а мост ходит в rails по обычному http внутри сети. Если включить FORCE_SSL=true, rails начнёт перенаправлять эти запросы на https, и мост сломается. Значение остаётся false, снаружи всё равно доступен только HTTPS.
Файл ещё не на диске
Событие о сообщении оператора с картинкой приходит раньше, чем Chatwoot допишет файл на диск, и первые запросы к файлу получают 404. Это всплыло уже в работе: картинка оператора просто не уходила в MAX. Теперь мост несколько секунд ждёт на месте, а если файла всё ещё нет, отдаёт сообщение в общую очередь повторов. Заодно он подменяет хост в data_url вложения: Chatwoot строит этот адрес от внешнего домена панели, а качать файл нужно с внутреннего адреса rails.
FILE_READY_DELAYS = (0, 0.5, 1, 2, 3)for attempt, delay in enumerate(FILE_READY_DELAYS, 1): time.sleep(delay) try: data, ctype = http_download(url) break except urllib.error.HTTPError as e: if e.code != 404 or attempt == len(FILE_READY_DELAYS): raise
Автоназначение, участники входящих и почта
Мост работает под API-токеном администратора, и пока открыт сокет, Chatwoot считает этого пользователя онлайн. При включённом автоназначении диалоги начинают сыпаться ему. Я выключил автоназначение и распределяю вручную, а аккуратнее было бы завести мосту отдельную служебную учётку. Ещё оператор, которого не добавили участником во входящие, видит только назначенные ему диалоги. У нас новый оператор из-за этого какое-то время не видел очередь.
Почту я настроил не сразу, а без SMTP приглашения операторам не приходят. Ссылку на установку пароля можно выпустить через rails runner методом set_reset_password_token, она живёт 6 часов, и новую лучше не выпускать, пока не воспользовались старой. Пока администратор не создан, страницу /installation/onboarding может открыть кто угодно, так что создавать его стоит сразу после первого запуска.
Доставка ровно один раз
Ответ оператора может прийти в мост двумя путями: по сокету и при сверке. Клиент не должен получить его дважды или не получить вовсе. Поэтому сокет и сверка не отправляют сообщения сами, а «занимают» id сообщения Chatwoot в файле состояния: сначала в outbox, после отправки в delivered. Отправляет только тот, кто занял первым:
def enqueue_delivery(message, conv_id, user_id, via, conv_uuid=None): mid = str(message["id"]) # ... def claim(s): if is_claimed(s, mid): return False s.setdefault("outbox", {})[mid] = job return True if not change_state(claim): return False
Очередь лежит в том же файле и переживает перезапуск. Длинный ответ режется на части, номер отправленной части сохраняется, и повтор продолжает с неё, а не с начала. Если контейнер остановили посреди отправки, эту часть я считаю отправленной, потому что повтор мог дать дубль, и оставляю оператору заметку «Возможно, не ушло в MAX». На SIGTERM мост дожидается текущих отправок до 8 секунд, docker compose по умолчанию ждёт 10.
С порядком тоже пришлось повозиться. Если у клиента в очереди два сообщения и первое упало с ошибкой, второе не должно его обогнать. Поэтому у каждого клиента в работе только самое раннее сообщение:
def pick_delivery(jobs, busy, now): blocked, wait = set(busy), None for mid in sorted(jobs, key=int): user, next_at = jobs[mid] if user in blocked: continue blocked.add(user) if next_at <= now: return mid, None wait = next_at - now if wait is None else min(wait, next_at - now) return None, wait
Повторы идут через 5, 20, 60 и 180 секунд и только на 429, 5xx и сетевых ошибках. Если не вышло совсем, оператор получает личную заметку «Не ушло в MAX» с причиной, так что потерянный ответ не останется незамеченным.
Что ещё делает мост
Команда /help показывает меню частых вопросов кнопками: роль, тема, вопрос. Ответ открывается в том же сообщении через callback, а тексты берутся из шаблонов ответов Chatwoot, так что правка делается в одном месте и одинаково видна и оператору, и клиенту. Когда оператор ставит диалог в «Ожидание», клиент получает короткое «Нам нужно немного больше времени». При завершении диалога в MAX приходит вопрос с кнопками от 1 до 5, и оценка сохраняется через публичный API опроса Chatwoot, поэтому попадает в стандартный отчёт.
Для команды мост умеет ещё две вещи. Если клиент ждёт первого ответа дольше часа рабочего времени, в Telegram-группу уходит одно сообщение со ссылкой на диалог. А личная заметка оператора с командой /report заводит задачу в трекере со ссылкой на диалог и перепиской, где почты и телефоны замаскированы. Эндпоинт /health показывает, подключён ли сокет и сколько сообщений ждут отправки, а для отладки есть режим, в котором сообщения пишутся в файл вместо отправки в MAX. Рабочие часы, автоответ вне них и автозакрытие после часа тишины настроены средствами самого Chatwoot. Настройку я делаю скриптом через rails runner, чтобы её можно было повторить на чистой установке.
Итоги
Сообщение из MAX появляется у оператора меньше чем за секунду, ответ уходит обратно так же быстро, вложения проходят в обе стороны. Сервер стоит около 3 400 ₽ в месяц вместе с бэкапами, лицензий нет, переписка хранится у нас. Версию Chatwoot я закрепил и обновляю по одной, а база и папка вложений бэкапятся ежедневно.
Больше всего пользы в итоге дал разбор старой переписки до того, как писать шаблоны. В нём нашлось 18 мест, где ответы команды расходились с нашими же документами, и если бы шаблоны появились раньше, они бы закрепили эти ошибки. Дальше хочу узнавать клиента по его MAX ID и сразу показывать оператору карточку аккаунта, у большинства наших пользователей MAX уже привязан. Если вы связывали с Chatwoot другие мессенджеры, расскажите в комментариях, как решали доставку ответов: через вебхуки, сокет или что-то третье.
ссылка на оригинал статьи https://habr.com/ru/articles/1104074/