Тема
ТЗ — Чат/онлайн-консультант (cms/chat)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Виджет чата на сайте с операторами в Filament: назначение диалогов, статусы онлайн, история переписки, офлайн-режим с падением в лид ядра. Новая разработка на realtime-шине студии.
- Виджет чата на публичном сайте, realtime через
cms/realtime; - операторы в Filament: список онлайн, назначение диалога оператору, переключение статуса;
- история диалогов с поиском и фильтром по оператору/периоду;
- офлайн-режим (нет доступных операторов) → форма превращается в лид ядра;
- автоответы и приветственное сообщение (настраиваемый текст, задержка первого показа);
- передача файлов в диалоге в пределах лимитов медиатеки ядра; скан вложений — через provides-контракт
upload-scannerмедиатеки (карантинpendingдо вердикта; 0 реализаций — скан пропускается штатно, ревизия №2, п. 4); - рейтинг диалога от посетителя после завершения (оценка + комментарий).
Зависимости и выключение
requires: cms/realtime (доставка сообщений/typing в реальном времени; LeadService и MediaService — контракты ядра cms/core-contracts, доступны всегда, это не отдельные модули) · suggests: cms/notifications-bus (отправка транскрипта диалога на email; не установлен — NotificationDispatch уходит в log-fallback ядра, запрос транскрипта не падает, но письмо не доставляется — см. «Крайние случаи»)
Поведение при выключении: виджет чата скрывается на сайте, история диалогов сохраняется в БД для просмотра из Filament (без возможности отвечать в реальном времени). Активные на момент выключения диалоги переходят в статус closed с системной пометкой (реакция на ModuleDisabled, канал 1) — посетитель видит, что виджет пропал, не зависает в ожидании ответа, которого не будет (§3 стандарта, «выключение = деградация»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_chat_conversations | id, visitor_id, user_id (nullable), operator_id (nullable), site_id (nullable), city_id (nullable), status, lock_version, assigned_at, closed_at, rating, rated_comment | диалог, статус (open/closed/offline) |
cms_chat_messages | id, conversation_id, sender_type, sender_id, body, attachment_path, created_at | сообщения диалога |
operator_id — FK constrained()->nullable()->index(); user_id — FK constrained()->nullable()->index() (привязка гостевого диалога к пользователю после логина, см. «Крайние случаи», склейка); site_id/city_id — FK nullable()->index(), измерения по правилу §4 стандарта (ниже); conversation_id в cms_chat_messages — FK constrained()->index(). status — PHP Enum (open/closed/offline); индекс на (operator_id, status) под панель оператора; индекс на visitor_id для резолва диалога гостя по cookie. lock_version — optimistic lock на редактируемые поля (статус, рейтинг); назначение оператора — отдельная атомарная операция (compare-and-swap по operator_id IS NULL, не через lock_version — см. «Крайние случаи»).
ПДн-паспорт. body сообщений, attachment_path, rated_comment и IP/UA в логе старта диалога — персональные данные (переписка с посетителем, 152-ФЗ). Ретеншн — chat.history_retention_days (см. «Настройки»), джоба очистки — часть «Фоновой работы». Участие в «выгрузить всё по субъекту»/«забыть по запросу»: диалоги и сообщения, где visitor_id/user_id совпадает с субъектом запроса, отдаются целиком либо анонимизируются хуком ядра (body → [удалено по запросу], вложение удаляется из медиатеки через MediaService).
Входные и выходные данные
Whitelist-принцип: всё, что не перечислено ниже как вход, модуль обязан отвергать — неизвестное поле в теле запроса, чужой conversation_id без прав доступа, канал realtime вне chat.{id} (см. «Безопасность»).
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Форма старта диалога (POST /api/v1/chat/conversations, публичный) | visitor_id (cookie/сессия), первое сообщение body, опционально name/email | FormRequest whitelist, rate-limit на старт, chat.max_message_length |
Отправка сообщения (POST /api/v1/chat/conversations/{id}/messages) | conversation_id, body, attachment (файл) | FormRequest + проверка принадлежности отправителя диалогу (см. «Безопасность», IDOR), chat.max_message_length, MIME-whitelist медиатеки, chat.max_attachment_mb, скан upload-scanner (карантин pending до вердикта) |
Оценка диалога (POST /api/v1/chat/conversations/{id}/rate) | rating (1–5), comment | FormRequest, только для status=closed, только один раз |
| Filament: назначение оператора | conversation_id, operator_id | permission chat.manage, атомарное назначение (CAS) |
| Filament: смена статуса/закрытие | status | permission chat.operate/chat.manage, lock_version |
Realtime-канал «печатает» (cms/realtime, канал 4 requires) | conversation_id, is_typing (bool) | whitelist имени канала chat.{id} в cms/realtime, эфемерно — не пишется в БД |
Запрос транскрипта на email (POST /api/v1/chat/conversations/{id}/transcript) | conversation_id, email | FormRequest (валидный email), throttle, доступ только участнику диалога/оператору |
Attach гостевого диалога к пользователю (POST /api/v1/chat/conversations/{id}/attach) | conversation_id, user_id берётся из RequestContext, не из тела запроса | токен авторизации пользователя + принадлежность visitor_id текущей сессии — см. «Крайние случаи», склейка |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Виджет чата (браузер) | сообщения диалога, статус оператора, typing-индикатор | конверт {data, meta} + realtime-события cms/realtime |
| Filament (панель оператора) | список диалогов, история, рейтинги, метрики | Blade/Livewire через сервис модуля |
Ядро: LeadService | лид из офлайн-обращения | вызов LeadService::create() (канал 4) → издаёт канонический LeadCreated (ядро); источник chat, ссылка на conversation_id |
cms/notifications-bus (NotificationDispatch) | транскрипт диалога на email | текст сообщений + ссылки на вложения (не бинарники) |
cms/realtime (requires, канал 4) | сообщения/статусы/typing | сервис-вызов broadcast() на канал chat.{id} |
События ChatConversationStarted/...WentOffline/...Rated/др. | факт | канал 1, payload — id сущностей, без тела переписки (ПДн не в событии) |
cms:chat:close-stale/purge-history/status --json | статус выполнения, диагностика | JSON в stdout |
Настройки (группа chat)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
chat.widget_position | string | bottom-right | нет | Позиция виджета на странице |
chat.greeting_delay_seconds | int | 15 | нет | Задержка автоприветствия |
chat.offline_to_lead | bool | true | нет | Превращать офлайн-обращения в лид ядра |
chat.max_attachment_mb | int | 10 | нет | Лимит вложения (в пределах лимитов медиатеки) |
chat.max_message_length | int | 2000 | нет | Максимальная длина одного сообщения в символах |
chat.max_concurrent_conversations_per_operator | int | 5 | нет | Лимит одновременных активных диалогов на оператора — защита от перегрузки |
chat.history_retention_days | int | 365 | нет | Срок хранения истории диалогов (ПДн-ретеншн); 0 — без ограничения, требует явного обоснования в docs/module.md |
chat.emergency_disable | bool | false | да | Kill-switch: аварийно скрывает форму старта диалога на сайте (например при спам-атаке через форму), не выключая модуль целиком — история и панель оператора остаются доступны |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/chat/conversations | public (rate-limit) | Начать диалог |
| POST | /api/v1/chat/conversations/{id}/messages | public/auth (участник) | Отправить сообщение |
| POST | /api/v1/admin/chat/conversations/{id}/assign | admin (chat.manage) | Назначить оператора |
| POST | /api/v1/chat/conversations/{id}/rate | public | Оценить диалог после завершения |
| POST | /api/v1/chat/conversations/{id}/attach | auth (участник сессии) | Приклеить гостевой диалог к вошедшему пользователю |
| POST | /api/v1/chat/conversations/{id}/transcript | public/auth (участник или оператор) | Запросить транскрипт диалога на email |
Компоненты
Блоки (BlockRegistry): «Виджет чата» — lazy-load по видимости/взаимодействию (инициализация WebSocket-подключения и JS-бандла откладывается до первого скролла/клика, не блокирует LCP страницы, §8 стандарта). Filament: панель оператора (список диалогов, статус онлайн/офлайн, назначение, метрики — среднее время первого ответа, число зависших диалогов), история диалогов с рейтингами, действие «Приклеить диалог к пользователю вручную» (permission chat.manage, резерв на случай несработавшего attach — см. «Крайние случаи»). Команды: cms:chat:close-stale --json, cms:chat:purge-history --json (ретеншн), cms:chat:status --json (диагностика: диалоги без оператора, глубина очереди). Демо-сидер: несколько диалогов с сообщениями и рейтингами для галереи блоков /_gallery.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ChatConversationStarted | посетитель начал диалог | conversation_id |
ChatConversationWentOffline | нет доступных операторов, создан лид | conversation_id, lead_id |
ChatConversationRated | диалог оценён посетителем | conversation_id, rating |
ChatOperatorAssigned | оператор назначен (в т.ч. переназначение) | conversation_id, operator_id |
ChatConversationClosed | диалог закрыт (вручную, по расписанию или при выключении модуля) | conversation_id, closed_reason |
ChatConversationAttachedToUser | гостевой диалог приклеен к вошедшему пользователю | conversation_id, user_id |
Слушает: — (модуль сам не подписывается на чужие события, кроме служебного ModuleEnabled/ModuleDisabled собственного графа зависимостей; realtime-доставка сообщений — сервис-вызовом к cms/realtime по requires).
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: LeadService | сервис-вызов (канал 4, core-contract) | out | создание лида при уходе диалога в офлайн (chat.offline_to_lead=true) |
Ядро: MediaService | сервис-вызов (канал 4, core-contract) | in/out | загрузка и валидация вложений сообщений в пределах лимитов медиатеки |
Ядро: RequestContext | чтение (служебное, вне пяти каналов) | in | site_id/city_id/locale/пользователь для диалога и панели оператора |
| Ядро: settings-store | чтение группы chat из кеша | in | лимиты, kill-switch, ретеншн — 0 запросов на горячем пути |
cms/realtime | requires → сервис-вызов (канал 4) | out | доставка сообщений/typing/статусов подписчикам канала chat.{id} |
cms/notifications-bus (NotificationDispatch, suggests) | сервис-вызов core-contract → шина | out | отправка транскрипта диалога на email; не установлен — log-fallback ядра (см. «Крайние случаи») |
Событие ModuleEnabled/ModuleDisabled (ядро, канал 1) | событие | in | выключение cms/realtime переводит модуль на деградацию (см. «Зависимости и выключение») |
| Filament (аудитория «оператор/менеджер») | компонент ↔ модуль (представительство) | out | панель диалогов через сервис модуля, не запросы из шаблона |
Фоновая работа
Именованная очередь chat для cms:chat:close-stale (закрытие зависших диалогов по расписанию через ScheduleRegistrar ядра) и cms:chat:purge-history (ретеншн — удаление или анонимизация диалогов старше chat.history_retention_days); обе джобы идемпотентны (повторный прогон не меняет уже обработанные записи).
Метрики и алерты (§15 стандарта): среднее время первого ответа оператора, число «зависших» диалогов (open без оператора дольше N минут), глубина очереди chat — видны в Pulse/health. Алерт: доля диалогов вне SLA-окна ответа выше порога.
Мини-ранбук:
| Симптом | Что проверить | Команда |
|---|---|---|
| Диалоги висят без оператора | Онлайн ли операторы, не отстаёт ли очередь chat | cms:chat:status --json |
| Сообщения не долетают до оператора в реальном времени | Доступность cms/realtime/Reverb | health-чек cms/realtime (см. realtime.md); REST-фолбэк должен работать штатно |
cms_chat_messages растёт бесконтрольно | Стоит ли cms:chat:purge-history в расписании | cms:chat:purge-history --dry-run --json |
| Гость и вошедший пользователь видят два диалога | Отработал ли attach на фронтенде при логине | Filament-действие «Приклеить диалог к пользователю вручную» (chat.manage) |
Бэкап/рестор. Таблицы cms_chat_conversations/cms_chat_messages (включая вложения в медиатеке) — в бэкапе как обычные данные модуля. Не бэкапится: realtime-состояние (кто онлайн, presence, typing) — живёт в cms/realtime/Redis, собирается заново по факту переподключений после рестора.
Производительность и кеш
Ожидаемые объёмы. Типичный сайт: единицы-десятки одновременных диалогов, сотни сообщений в день; крупный сайт с несколькими операторами — десятки диалогов, до нескольких тысяч сообщений в день. cms_chat_messages растёт монотонно — кандидат на партиционирование по created_at при росте истории на крупных инсталляциях.
Горячие пути и бюджет запросов. Панель оператора «список активных диалогов» — один запрос по индексу (operator_id, status) с ->with() на оператора и превью последнего сообщения (денормализованное поле либо оконная функция — не N запросов на N диалогов, N+1 запрещён §10 стандарта). Виджет чата при открытии — один запрос истории (keyset-пагинация, не вся переписка целиком). Чтение настроек группы chat — 0 запросов (кеш группы).
Индексы. См. «Модель данных»: (operator_id, status) — панель оператора; visitor_id — резолв диалога гостя по cookie; user_id — резолв после логина/склейка; conversation_id в cms_chat_messages — история; при партиционировании — created_at (BRIN, журнальный характер таблицы сообщений).
Теги кеша и инвалидация. Теги chat:conversation:<id> — инвалидируются событиями смены статуса диалога (ChatOperatorAssigned, ChatConversationClosed).
Персональные данные — исключение из общего кеша. Переписка (посетитель/оператор) — персональные данные, в общий page-cache не попадает ни в каком виде: ни история диалога, ни счётчик непрочитанных на публичной странице — эти данные всегда идут мимо page-cache, через realtime/AJAX с текущим RequestContext, не через кешируемый HTML-фрагмент.
Безопасность
Оператор видит только назначенные ему или неназначенные диалоги, не чужие закреплённые (проверка через Policies). Вложение сверх max_attachment_mb или запрещённого типа отклоняется (whitelist MIME медиатеки ядра); вредоносное содержимое — скан upload-scanner (карантин pending до вердикта: получателю вложение не отдаётся, пока файл не прошёл проверку; 0 реализаций — скан пропускается штатно). Realtime-обмен сообщениями не допускает XSS (санитизация на входе и эскейпинг на выводе — двойной барьер). Rate-limit на старт диалога.
IDOR (посетитель и чужой диалог). conversation_id в URL — не единственная защита: доступ к диалогу для неавторизованного посетителя проверяется по visitor_id текущей сессии/cookie, для вошедшего — по user_id из RequestContext; перебор conversation_id без валидной сессии/токена отдаёт 403, не данные чужого диалога. Публичный идентификатор диалога — ULID/UUID, не последовательный автоинкремент (§4 стандарта: перебор ресурсов).
ПДн. Переписка, вложения и rated_comment — персональные данные (см. «Модель данных», ПДн-паспорт); в логи и аналитику модуль не пишет тело сообщений — только обезличенные идентификаторы диалога/события.
Матрица ролей (permissions chat.view, chat.operate, chat.manage + настройки группы chat):
| Действие / permission | Оператор | Старший оператор | Менеджер | Studio |
|---|---|---|---|---|
chat.view — свои назначенные диалоги | ✅ | ✅ | ✅ | ✅ |
chat.view — все диалоги, включая чужие | — | ✅ | ✅ | ✅ |
chat.operate — отвечать в назначенном диалоге, менять свой статус онлайн | ✅ | ✅ | ✅ | ✅ |
chat.manage — назначить/переназначить чужой зависший диалог, массовое закрытие | — | ✅ | ✅ | ✅ |
Настройки группы chat (лимиты, chat.emergency_disable) | — | — | ✅ | ✅ |
Права: chat.view, chat.operate, chat.manage.
UX-требования
Посетитель. Отправленное сообщение отображается сразу (оптимистичный UI), статус доставки («отправлено» / «ошибка — повторить») приходит следом, не блокируя ввод следующего сообщения. Индикатор набора текста оператором — через cms/realtime (канал chat.{id}), пропадает по таймауту, если оператор перестал печатать. История диалога сохраняется при обновлении страницы (резолв по visitor_id из cookie, не чистый лист при F5). Виджет адаптивен на мобильных: не перекрывает контент, доступен с клавиатуры (фокус-ловушка при открытии, Esc закрывает). При недоступности cms/realtime — деградация на REST-polling, не «зависший» виджет без обратной связи.
Оператор. Пустое состояние списка диалогов — «нет активных диалогов» с подсказкой, не голая пустая таблица. Массовые действия: выбрать несколько зависших диалогов в списке → «закрыть» одним действием (та же логика, что у cms:chat:close-stale, но ручной триггер из Filament, permission chat.manage). Ошибки — человеческим языком: «не удалось отправить — потеряно соединение, переподключение...» вместо стектрейса. Необратимые операции — закрытие диалога с непрочитанным сообщением посетителя, массовое закрытие диалогов — только с модальным подтверждением.
Крайние случаи и типовые баги
- Оператор офлайн,
chat.offline_to_lead=true→ лид создаётся каноническим способом через core-contractLeadService::create()(не своя таблица заявок —LeadServiceи есть «форма/чат/UGC → заявка» изcore.md), источник лида —chat, ссылка наconversation_idдля дальнейшей склейки истории с заявкой ядра; ядро издаёт каноническийLeadCreated— потребители (CRM-интеграция, аналитика) слушают его на уровне ядра, модуль не дублирует событие своим именем. - Оператор офлайн,
chat.offline_to_lead=false→ форма старта диалога не открывается вовсе: посетитель видит сообщение «сейчас никого нет онлайн, напишите позже» вместо пустого работающего виджета без единого ответа — пустой активный чат без операторов сам по себе плохой UX и источник лишних обращений в поддержку. - История диалога гостя после логина (склейка) → посетитель начал диалог анонимно (
visitor_idпо cookie), затем вошёл в кабинет: фронтенд обязан вызватьPOST /api/v1/chat/conversations/{id}/attachпри обнаружении авторизации — диалог получаетuser_id, оператор видит непрерывную историю вместо двух разных диалогов. СобытиеUserLoggedInканонизировано ревизией ядра 14.07.2026 (п. 4) — основная склейка событийная (слушательUserLoggedInпривязывает диалоги поvisitor_idиз payload/сессии); явныйattachс фронтенда остаётся резервным путём для SPA-виджета. Оба пути идемпотентны (no-op для уже привязанного диалога). - Два оператора взяли один диалог одновременно → назначение — атомарная операция
UPDATE ... WHERE operator_id IS NULL(compare-and-swap), не read-then-write: первый запрос назначает и получает 200, второй — 0 затронутых строк → 409 «диалог уже назначен оператору N», не тихий перехват чужого диалога. - Транскрипт диалога на email по запросу → сервис модуля собирает текст сообщений (без бинарных вложений — только подписанные ссылки на файлы медиатеки) и передаёт через
NotificationDispatch(core-contract) вcms/notifications-bus, канал email резолвится провайдеромmail-transport. ⚠️ Противоречие: без активногоmail-transport-провайдераcms/notifications-busмолча уходит в log-fallback ядра (см.notifications-bus.md) — API отдаст «успех», хотя письмо никуда не ушло. Решение:POST .../transcriptразличает «поставлено в очередь» и «гарантированно не доставлено» — при log-fallback API возвращает явное предупреждение, не молчаливый 200. - Выключение
cms/chatпосреди активного диалога → активные диалоги переходят вclosedс системной пометкой при обработкеModuleDisabled; посетитель видит, что виджет пропал, не зависает в ожидании ответа (см. «Зависимости и выключение»). - Сбой/таймаут
cms/realtime→ сообщения продолжают приниматься через обычный REST (POST .../messages), доставка получателю — при следующем poll/reconnect; виджет и панель оператора не блокируются ожиданием WebSocket-соединения. - Диалог с сотнями сообщений / пустой диалог → история грузится keyset-пагинацией (не вся переписка одним запросом ни в виджет, ни в панель оператора); пустой диалог (0 сообщений, только
ChatConversationStarted) — валидный кейс, не ошибка. - Мультигородской сайт: к какому городу привязан диалог/оператор → ⚠️ Противоречие: исходная модель данных не несла измерений
site_id/city_id, что противоречило §4 стандарта («контентные сущности несут измерения locale/city/site, nullable») — на мультигородском сайте оператор одного города рисковал увидеть диалоги другого. Решение (внесено в это ТЗ): nullablesite_id,city_idдобавлены вcms_chat_conversations(см. «Модель данных»), скоуп — изRequestContextна старте диалога; панель оператора по умолчанию фильтрует по городу/сайту оператора (привязка оператора к городу — часть профиля пользователя ядра, вне модуляchat). Локаль отдельным измерением не хранится: сообщения не переводимый контент, локаль резолвится изRequestContextна момент рендера. - Противоречивые настройки (
chat.offline_to_lead=falseпри ненулевомchat.greeting_delay_seconds) → если операторов нет онлайн ни при каких условиях, ожидание приветствия перед показом «никого нет онлайн» — лишняя задержка без смысла: офлайн-сообщение показывается немедленно,greeting_delay_secondsприменяется только когда хотя бы один оператор потенциально может ответить. - Вложение удалено из медиатеки, ссылка осталась в истории → сообщение с «осиротевшим»
attachment_pathрендерится с понятной заглушкой («файл недоступен»), не 404-страницей и не битой ссылкой без объяснения. - Вложение в карантине
upload-scannerна момент открытия диалога → получатель видит сообщение с пометкой «файл проверяется», ссылка на скачивание появляется после положительного вердикта; отрицательный вердикт — вложение не публикуется, сообщение остаётся с пометкой «файл заблокирован» (тот же барьер, что уcms/ugc, ревизия №2, п. 4).
Донорский код
Донор: — не применимо, донора нет, новая разработка на realtime-шине студии (§16 стандарта, «Миграция legacy-данных»: cms:chat:import-legacy не требуется).
Тесты и приёмка
- [ ] Контрактный тест: обращение при отсутствии онлайн-операторов и
offline_to_lead=trueсоздаёт лид черезLeadServiceс привязкой к диалогу; - [ ]
offline_to_lead=falseпоказывает сообщение «сейчас никого нет онлайн», а не пустой рабочий виджет; - [ ] Вложение сверх
max_attachment_mbили запрещённого типа отклоняется (whitelist MIME медиатеки); - [ ] Вложение без прохождения скана
upload-scannerне отдаётся получателю (карантинpending), 0 реализаций — скан пропускается штатно; - [ ] Сообщение длиннее
chat.max_message_lengthотклоняется; - [ ] Оператор видит только назначенные ему или неназначенные диалоги, не чужие закреплённые;
- [ ] Гонка двух операторов: второе назначение получает 409, не тихий перехват (CAS-тест);
- [ ] Attach гостевого диалога к пользователю склеивает историю по
visitor_id → user_id, повторный вызов идемпотентен; - [ ] Рейтинг можно поставить только один раз на завершённый диалог;
- [ ] Деградация при выключении модуля закрывает активные диалоги системной пометкой, не оставляет их подвисшими;
- [ ] Транскрипт на email: при отсутствии
mail-transport-провайдера API явно сообщает о log-fallback, не молчаливый успех; - [ ] IDOR: доступ к чужому диалогу по перебору
conversation_idбез валидной сессии/токена отдаёт 403; - [ ] Список диалогов оператора грузится без N+1 (бюджет запросов — контрактный тест на
(operator_id, status)); - [ ] Измерения
site_id/city_idдиалога работают корректно и при их отсутствии (оба режима — контрактный тест §4 стандарта); - [ ] Ретеншн истории: джоба
cms:chat:purge-historyанонимизирует/удаляет диалоги старшеchat.history_retention_days, идемпотентна; - [ ]
chat.emergency_disableскрывает форму старта диалога без выключения модуля целиком; - [ ] Realtime-обмен сообщениями не допускает XSS (санитизация на входе и эскейпинг на выводе);
- [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
chat_test;migrate:fresh/refresh/reset,db:wipeзапрещены.