Тема
ТЗ — 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_contacts | user_id, channel, identifier, opted_in_at, verified_at, last_inbound_at | подтверждённые адресаты (opt-in); last_inbound_at — время последнего входящего сообщения, база расчёта окна 24ч |
cms_messenger_templates | channel, code, body, status, approved_at | шаблоны (для WhatsApp — статус модерации провайдера) |
cms_messenger_log | contact_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-in | user_id, channel, identifier | FormRequest, channel — whitelist по messengers.channels_enabled |
POST /api/v1/messengers/opt-in/confirm | user_id, channel, code | rate-limit, код с TTL, одноразовый |
Вебхук провайдера (статусы/входящие) через cms/webhooks-in | provider_msg_id, status, from, text | подпись/секрет провайдера, идемпотентность по provider_msg_id |
Вызов от cms/notifications-bus (канал whatsapp/viber/max) | user_id, channel, template_code, payload | provides-контракт notification-channel, whitelist шаблонов со status = approved |
| Filament CRUD шаблонов | channel, code, body | FormRequest, permission messengers.manage, лимит длины провайдера |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
Провайдер (через cms/integrations-bus) | сообщение по шаблону/session | HTTP POST JSON, только из очереди |
cms/notifications-bus | статус доставки канала, инициирование fallback | возврат сервис-вызова + событие |
cms/chat (если включён, suggests) | входящее сообщение клиента как реплика диалога | сервис-вызов в публичный сервис cms/chat (requires при включённом модуле) |
ядро LeadService | лид, если cms/chat выключен | сервис-вызов core-contracts |
| Filament (журнал) | cms_messenger_log со статусом/стоимостью | таблица, keyset-пагинация |
| Подписчики шины | MessengerMessageSent / MessengerMessageFailed / MessengerBudgetThresholdExceeded | payload события |
Настройки (группа messengers)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
messengers.channels_enabled | array | [] | нет | Включённые каналы (whatsapp/viber/max) |
messengers.fallback_chain | array | ["sms","mail"] | нет | Порядок fallback при недоставке; недоступный канал (модуль выключен) пропускается |
messengers.optin_required | bool | true | нет | Требовать подтверждённый opt-in (не отключать в РФ) |
messengers.quiet_hours | string | 22:00-09:00 | нет | Тихие часы по таймзоне сайта (не слать); при заполненном измерении city_id получателя — по таймзоне города |
messengers.monthly_budget_limit | int | 0 | нет | Лимит расходов в месяц в минорных единицах валюты (0 — без лимита), аналогично cms/sms |
messengers.message_log_retention_days | int | 90 | нет | Ретеншн журнала доставки (ПДн, 152-ФЗ) |
messengers.outbound_enabled | bool | true | нет | 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/log | messengers.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_limit | spent, limit |
Слушает: события-триггеры через подписки шины cms/notifications-bus (сам на доменные события не подписывается). Provides-контракты notification-channel whatsapp/viber/max — потребляются шиной уведомлений наравне с push/webpush/sms/ telegram.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/notifications-bus | provides-контракт notification-channel + сервис-вызов (requires) | notifications-bus → messengers | шина резолвит whatsapp/viber/max, вызывает отправку по шаблону |
cms/notifications-bus | сервис-вызов (повторная публикация на следующий канал) | messengers → notifications-bus | недоставка запускает следующий канал fallback_chain через шину, а не прямой вызов чужого модуля |
cms/sms (suggests, элемент fallback_chain) | опосредованно через cms/notifications-bus | messengers → 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/health | health-чек из манифеста | 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.view | messengers.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запрещены.