Skip to content

ТЗ — WhatsApp/мессенджеры (cms/messengers)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Каналы мессенджеров (WhatsApp Business API, Viber, MAX) для шины уведомлений — уведомления клиентам там, где они читают. Telegram вынесен отдельным модулем cms/telegram (зрелый донор).

  • канал(ы) для шины cms/notifications-bus — статусы заказа, напоминания, коды;
  • шаблоны сообщений per-канал (WhatsApp требует премодерированные шаблоны — реестр шаблонов со статусами согласования);
  • окно 24 часа (WhatsApp Business API session window): после последнего входящего сообщения клиента у бизнеса есть 24 часа на свободные (session) сообщения; вне окна допустимы только премодерированные шаблонные сообщения (cms_messenger_templates.status = approved) — правило проверяется перед каждой отправкой, не только при постановке в очередь;
  • привязка получателя: телефон → подтверждение владения (opt-in обязателен);
  • fallback-цепочка: мессенджер недоступен/не доставлено → SMS (cms/sms) → email;
  • входящие ответы клиентов → сообщение в диалоге cms/chat (если включён) или лид ядра — единый inbox: оператор отвечает из интерфейса cms/chat, ответ уходит обратно через канал мессенджера, не через отдельный интерфейс модуля;
  • статусы доставки/прочтения, стоимость-учёт per-канал с контролем месячного бюджета.

Зависимости и выключение

requires: cms/notifications-bus, cms/integrations-bus, cms/webhooks-in · suggests: cms/sms, cms/chat provides: notification-channel (whatsapp, viber, max)

Поведение при выключении: шина уведомлений перестаёт видеть каналы модуля и уходит по fallback-цепочке (SMS/email); журнал доставки сохраняется. Если выключен cms/webhooks-in (жёсткий requires — приём статусов доставки и входящих сообщений идёт только через него) — self-test на enable проваливается, если webhooks-in недоступен изначально; если его выключили после того, как messengers уже enabled, модуль деградирует частично: исходящая отправка через cms/integrations-bus продолжает работать, но статусы доставки и входящие ответы клиентов перестают поступать (окно 24 часа не продлевается по last_inbound_at).

Стоимость внешних API: WhatsApp/Viber/MAX тарифицируются провайдером за сообщение или за открытие диалогового окна (в отличие от cms/telegram, где Bot API бесплатен) — см. messengers.monthly_budget_limit в «Настройках» и учёт cost в «Модели данных», аналогично cms/sms.

Модель данных

ТаблицаКлючевые поляПримечание
cms_messenger_contactsuser_id, channel, identifier, opted_in_at, verified_at, last_inbound_atподтверждённые адресаты (opt-in); last_inbound_at — время последнего входящего сообщения, база расчёта окна 24ч
cms_messenger_templateschannel, code, body, status, approved_atшаблоны (для WhatsApp — статус модерации провайдера)
cms_messenger_logcontact_id, template_code, direction, status, cost, provider_msg_id, created_atжурнал отправок/доставки/входящих, стоимость по факту ответа провайдера

user_id — FK constrained()->index(); channel — PHP Enum (whatsapp/viber/max). Уникальный составной индекс (user_id, channel) в cms_messenger_contacts — по нему же читается last_inbound_at на горячем пути проверки окна. cms_messenger_log — append-only, BRIN-индекс по created_at, индекс на provider_msg_id под идемпотентность колбэков.

ПДн-паспорт: identifier (телефон) — ПДн; cms_messenger_log может содержать текст сообщения в payload-подобных полях шаблона — ПДн. Ретеншн: журнал доставки — 90 дней (config messengers.message_log_retention_days), после — обезличивание (оставить статус/стоимость, стереть привязку к тексту); cms_messenger_contacts хранится, пока действует opt-in (отзыв → запись помечается неактивной, не удаляется сразу — нужна для защиты от повторной незапрошенной отправки). Модуль реализует хуки ядра «выгрузить всё по субъекту» и «забыть по запросу» (152-ФЗ) для обеих таблиц.

Входные и выходные данные

Входы (whitelist-принцип: всё не перечисленное ниже — отклоняется):

ИсточникПоляЧем валидируется
POST /api/v1/messengers/opt-inuser_id, channel, identifierFormRequest, channel — whitelist по messengers.channels_enabled
POST /api/v1/messengers/opt-in/confirmuser_id, channel, coderate-limit, код с TTL, одноразовый
Вебхук провайдера (статусы/входящие) через cms/webhooks-inprovider_msg_id, status, from, textподпись/секрет провайдера, идемпотентность по provider_msg_id
Вызов от cms/notifications-bus (канал whatsapp/viber/max)user_id, channel, template_code, payloadprovides-контракт notification-channel, whitelist шаблонов со status = approved
Filament CRUD шаблоновchannel, code, bodyFormRequest, permission messengers.manage, лимит длины провайдера

Выходы:

ПотребительДанныеФормат
Провайдер (через cms/integrations-bus)сообщение по шаблону/sessionHTTP POST JSON, только из очереди
cms/notifications-busстатус доставки канала, инициирование fallbackвозврат сервис-вызова + событие
cms/chat (если включён, suggests)входящее сообщение клиента как реплика диалогасервис-вызов в публичный сервис cms/chat (requires при включённом модуле)
ядро LeadServiceлид, если cms/chat выключенсервис-вызов core-contracts
Filament (журнал)cms_messenger_log со статусом/стоимостьютаблица, keyset-пагинация
Подписчики шиныMessengerMessageSent / MessengerMessageFailed / MessengerBudgetThresholdExceededpayload события

Настройки (группа messengers)

КлючТипДефолтaffectsPageCacheОписание
messengers.channels_enabledarray[]нетВключённые каналы (whatsapp/viber/max)
messengers.fallback_chainarray["sms","mail"]нетПорядок fallback при недоставке; недоступный канал (модуль выключен) пропускается
messengers.optin_requiredbooltrueнетТребовать подтверждённый opt-in (не отключать в РФ)
messengers.quiet_hoursstring22:00-09:00нетТихие часы по таймзоне сайта (не слать); при заполненном измерении city_id получателя — по таймзоне города
messengers.monthly_budget_limitint0нетЛимит расходов в месяц в минорных единицах валюты (0 — без лимита), аналогично cms/sms
messengers.message_log_retention_daysint90нетРетеншн журнала доставки (ПДн, 152-ФЗ)
messengers.outbound_enabledbooltrueнетKill-switch: аварийно отключить всю исходящую отправку без выключения модуля — opt-in и входящие продолжают обрабатываться

API

МетодПутьДоступНазначение
POST/api/v1/messengers/opt-inпользовательЗапрос подтверждения канала (код)
POST/api/v1/messengers/opt-in/confirmпользователь (rate-limit)Подтверждение кода
GET/api/v1/admin/messengers/logmessengers.viewЖурнал доставки с фильтрами
CRUD/api/v1/admin/messengers/templates…messengers.manageРеестр шаблонов

Входящие колбэки провайдеров (статусы, ответы) — через cms/webhooks-in. Журнал доставки — keyset-пагинация (не OFFSET).

Компоненты

Filament: журнал доставки, реестр шаблонов со статусами модерации, статистика per-канал, отчёт по расходу за период (аналогично cms/sms), виджет здоровья (доступность провайдеров, остаток баланса). Демо-контент: у модуля нет блоков/виджетов витрины — в галерею /_gallery не участвует; отдельный демо-сидер не требуется.

Команды: cms:messengers:health --json (доступность провайдеров, остаток баланса), cms:messengers:sync-delivery-status --json (синхронизация статусов при недоступности колбэков).

События и обмен

СобытиеКогдаPayload
MessengerMessageSentсообщение принято провайдеромcontact_id, channel, template_code, cost
MessengerMessageFailedдоставка не удалась (запущен fallback)contact_id, channel, error
MessengerBudgetThresholdExceededпревышен monthly_budget_limitspent, limit

Слушает: события-триггеры через подписки шины cms/notifications-bus (сам на доменные события не подписывается). Provides-контракты notification-channel whatsapp/viber/max — потребляются шиной уведомлений наравне с push/webpush/sms/ telegram.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/notifications-busprovides-контракт notification-channel + сервис-вызов (requires)notifications-bus → messengersшина резолвит whatsapp/viber/max, вызывает отправку по шаблону
cms/notifications-busсервис-вызов (повторная публикация на следующий канал)messengers → notifications-busнедоставка запускает следующий канал fallback_chain через шину, а не прямой вызов чужого модуля
cms/sms (suggests, элемент fallback_chain)опосредованно через cms/notifications-busmessengers → smsесли sms выключен, канал в цепочке пропускается, ошибки «канал не найден» нет
cms/chat (suggests, становится requires-вызовом при включении)сервис-вызовmessengers → chatвходящий ответ клиента добавляется как сообщение в cms_chat_conversations; ответ оператора уходит обратно тем же путём
ядро LeadServiceсервис-вызов (core-contracts)messengers → ядроесли cms/chat выключен, входящий ответ создаёт лид
cms/webhooks-inвебхук (внешний канал)провайдер → messengersстатусы доставки/входящие сообщения, идемпотентность по provider_msg_id
cms/integrations-busсервис-вызов (requires)messengers → integrations-busвсе внешние HTTP к провайдерам — только через шину интеграций (SSRF-защита)
cms/healthhealth-чек из манифестаmessengers → healthдоступность провайдеров, остаток баланса — cms:messengers:health

Фоновая работа

Именованная очередь messengers для отправки (внешний HTTP-вызов к провайдеру — только из очереди, через cms/integrations-bus) и для fallback-цепочки при недоставке; джобы идемпотентны — недоставка запускает fallback ровно один раз. Джоба отправки перепроверяет окно 24 часа и статус opt-in на момент выполнения, а не на момент постановки в очередь (см. «Крайние случаи»).

Метрики и алерты: messengers.outbound_sent_total, messengers.outbound_failed_total по каналу, messengers.spend_month_to_date, messengers.provider_latency_ms. Алерты: messengers.provider_unavailable, messengers.budget_threshold_exceeded (MessengerBudgetThresholdExceeded).

Мини-ранбук:

СимптомЧто проверитьКоманда
Сообщения не уходят, health красныйпровайдер недоступен/токен интеграции невалиденcms:messengers:health --json, проверить креды в cms/integrations-bus
Часть исходящих отклоняется «нужен шаблон»окно 24ч закрыто, а сообщение не шаблонноеожидаемое поведение (см. крайние случаи), не баг — проверить last_inbound_at контакта
Статусы доставки не обновляютсяколбэки провайдера не доходятcms:messengers:sync-delivery-status --json (поллинг вместо вебхука)
Расход близок к monthly_budget_limitбюджет на исходеотчёт по расходу в Filament, поднять лимит или дождаться нового периода

Бэкап/рестор: в бэкап попадают все три таблицы модуля (контакты/opt-in, шаблоны, журнал). После рестора статусы модерации шаблонов у провайдера могут разойтись с локальными — команда cms:messengers:health --json актуализирует статус при первом запуске; расход бюджета за текущий период восстанавливается из журнала, отдельного пересчёта не требует.

Производительность и кеш

Ожидаемые объёмы: до нескольких тысяч исходящих сообщений в день на активный клиентский сайт (напоминания, статусы, коды), контактов с opt-in — сотни–тысячи (растёт линейно с базой клиентов), журнал cms_messenger_log — append-only, пропорционален отправкам. Горячий путь — перед отправкой: проверка окна 24 часа (чтение last_inbound_at по индексу (user_id, channel)) и статуса opt-in — 1–2 индексных запроса; резолв шаблона — из тегированного кеша, не из БД. Тяжёлый HTTP-вызов провайдеру — только из очереди, не на горячем пути. Критичные индексы (см. «Модель данных»): уникальный (user_id, channel) в cms_messenger_contacts (там же — last_inbound_at под окно), индекс на provider_msg_id в cms_messenger_log под идемпотентность колбэков, BRIN по created_at. Теги: messengers:templates — инвалидируются при изменении статуса модерации шаблона (afterSave() в Filament). Контакты и журнал доставки — персональные данные, в общий page-cache не попадают.

Безопасность

Отправка без подтверждённого opt-in невозможна (optin_required — обязателен в РФ, не отключать); перед фактической отправкой в очереди opted_in_at/verified_at перепроверяются повторно — отозванный между постановкой в очередь и отправкой opt-in отменяет отправку, а не просто блокирует постановку. Колбэки провайдера идемпотентны по provider_msg_id. Тихие часы откладывают отправку, а не теряют её. Внешние вызовы — только через cms/integrations-bus (SSRF-защита). Rate-limit на opt-in/confirm (защита от подбора кода подтверждения).

Матрица ролей:

Рольmessengers.viewmessengers.manage
Администратор клиента
Менеджер✅ (журнал, статистика)
Редактор
Studio✅ (+ настройка monthly_budget_limit, kill-switch)

ПДн-паспорт — см. «Модель данных». Номер телефона и текст сообщения не логируются в открытом виде сверх необходимого для журнала.

UX-требования

Для пользователя/клиента: opt-in — короткая форма «получать уведомления в WhatsApp» + код подтверждения за один шаг, без повторных запросов кода при обычной работе; сообщения читаемы (шаблоны без markdown-мусора, укладываются в лимит длины провайдера); отсутствие спама — fallback_chain не дублирует одно и то же уведомление на несколько каналов одновременно (следующий канал пробуется только при неудаче предыдущего, не веерная рассылка), quiet_hours соблюдается для всех каналов разом.

Для админа/оператора: пустое состояние реестра шаблонов — подсказка «добавьте шаблон и отправьте на модерацию провайдеру»; массовое действие — переотправка недоставленных сообщений за период; ошибки на человеческом языке («шаблон не одобрен провайдером», а не «400 Bad Request», «окно 24 часа закрыто — нужен шаблон», а не код ошибки провайдера); статус провайдера и остаток бюджета видны в дашборде модуля; в едином inbox cms/chat оператор отвечает клиенту там же, где видит историю переписки, — без переключения между интерфейсом чата и интерфейсом мессенджеров.

Крайние случаи и типовые баги

  • Окно 24 часа истекло → вне окна допустима только отправка премодерированного шаблона (cms_messenger_templates.status = approved); попытка отправить произвольный (session) текст вне окна отклоняется сервисом до вызова провайдера — не долетает до платного API с отказом.
  • Входящее сообщение продлевает окно → каждый входящий колбэк обновляет cms_messenger_contacts.last_inbound_at; отсутствие этого обновления приводило бы к ложному закрытию окна и отказам в отправке session-сообщений.
  • Гонка: сообщение поставлено в очередь до истечения окна, отправляется после → джоба перепроверяет окно на момент фактической отправки, не на момент постановки в очередь — иначе платный сбой у провайдера/отказ на стороне API.
  • Провайдер тарифицирует даже неудачную отправку session-сообщения (открытие диалогового окна списывается независимо от прочтения) → cost в cms_messenger_log фиксируется по факту ответа провайдера, а не по факту постановки в очередь; расхождение между «отправлено» и «оплачено» видно в отчёте.
  • Повторный колбэк провайдера на один provider_msg_id → идемпотентность по provider_msg_id, повторный колбэк не удваивает cost и не меняет статус задним числом на худший без причины.
  • cms/chat выключен (suggests) → входящие ответы клиента не попадают в единый inbox — модуль деградирует до создания лида ядра напрямую; оператор видит только лид без истории переписки — это ожидаемая деградация, не баг, отражается в UX явной надписью в Filament («модуль cms/chat не установлен»).
  • cms/sms выключен, но указан в fallback_chain → недоступный канал в цепочке пропускается молча (переход к следующему, например mail), ошибки «канал не найден» пользователю/логам не показывается.
  • Шаблон превышает лимит длины провайдера → отклоняется при сохранении в Filament, не обрезается молча (аналогично cms/sms).
  • Opt-in отозван клиентом, пока сообщение уже лежит в очереди → перед отправкой повторная проверка opted_in_at — отозванный opt-in отменяет отправку, событие MessengerMessageFailed с причиной opt_in_revoked.
  • cms/webhooks-in выключен (жёсткий requires) → входящие статусы доставки и ответы клиентов перестают поступать; исходящая отправка через cms/integrations-bus продолжает работать, cms:messengers:sync-delivery-status --json (поллинг) остаётся единственным способом актуализировать статусы до восстановления модуля.
  • ⚠️ Противоречие: чей часовой пояс использует quiet_hours. Настройка messengers.quiet_hours в исходном ТЗ не уточняла, чья таймзона используется при расчёте «тихих часов» — сайта или получателя; правило измерений §4 стандарта требует, чтобы модуль корректно работал и при отсутствии измерения city_id получателя (nullable). Разрешение, зафиксированное в этом ТЗ: по умолчанию — таймзона сайта (RequestContext/настройка site), при заполненном city_id получателя — таймзона города; тест обязан покрывать оба режима (правило измерений §4 стандарта).
  • Единый inbox: окно 24ч закрылось между входящим сообщением и ответом оператора → перед отправкой ответа из cms/chat сервис повторно проверяет окно (см. выше); если оно закрылось — оператору в интерфейсе чата явно предлагается отправить шаблон вместо свободного текста, тихого фейла нет.
  • Один контакт пишет из нескольких каналов (WhatsApp и Viber у одного клиента) → уникальный индекс (user_id, channel) допускает по записи на каждый канал; в едином диалоге cms/chat каждое сообщение помечается каналом-источником, сообщения разных каналов не сливаются в одну ленту без пометки.

Донорский код

Донор: — (новая разработка; транспортный слой — через cms/integrations-bus).

Миграция legacy: не применимо — донора нет, все контакты и opt-in собираются заново через /api/v1/messengers/opt-in (согласие нельзя импортировать без повторного подтверждения по 152-ФЗ и требованиям провайдеров).

Тесты и приёмка

  • [ ] Контрактный тест notification-channel: канал регистрируется в шине и доставляет мок-сообщение;
  • [ ] отправка без подтверждённого opt-in невозможна (тест), отзыв opt-in отменяет уже поставленное в очередь сообщение;
  • [ ] вне окна 24 часа отправляется только шаблон, произвольный текст отклоняется до вызова провайдера;
  • [ ] входящее сообщение обновляет last_inbound_at и продлевает окно;
  • [ ] недоставка → fallback-цепочка срабатывает ровно один раз (идемпотентность), недоступный канал в цепочке пропускается без ошибки;
  • [ ] колбэки провайдера идемпотентны по provider_msg_id, повторный колбэк не удваивает cost;
  • [ ] тихие часы откладывают отправку, а не теряют её; оба режима (с city_id и без) покрыты тестами;
  • [ ] превышение monthly_budget_limit даёт алерт MessengerBudgetThresholdExceeded, не блокирует отправку без явной настройки;
  • [ ] входящий ответ клиента появляется в cms_chat_conversations при включённом cms/chat, при выключенном — создаёт лид;
  • [ ] messengers.outbound_enabled=false останавливает исходящую отправку, не трогая opt-in и входящие;
  • [ ] выключение модуля не ломает шину (деградация на fallback-каналы);
  • [ ] ретеншн cms_messenger_log и хуки «выгрузить/забыть по субъекту» покрыты тестами;
  • [ ] контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] тестовая БД только messengers_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии