Перейти к основному содержимому

UC-MSG-007. Авто-запись звонка по сделке и её выдача участнику

Назначение

Описывает сценарий: звонок в контексте сделки (как правило — конференция в Deal-чате, UC-MSG-006) авто-записывается по типизированной политике RecordingPolicy.ShouldAutoRecord(chatKind, sessionKind) (call-recording.md, INV-REC-3): запись провизится при старте сессии с Trigger = AutoPolicy, InitiatedByUserId = null. Участники уведомляются индикатором записи (согласие-нотис, INV-REC-4) до старта захвата. По завершении сессии рекордер (jibri) финализирует файл и стримит байты в ingest-эндпоинт (jibri-recording-ingest) → file storage (S3) → стабильный FileIdRecordingStatus = Available → интеграционное pin.messenger.call-recording-available.v1. После этого участник скачивает запись пресайн-ссылкой (get-recording-download, аудит ReadSensitive). Бинарь в messenger не хранится — только FileId + метаданные (INV-REC-6, по образцу вложений). Событие call-recording-available — заодно шов будущего пайплайна транскрипции/конспектирования (transcription-roadmap). Связан с контекстом messenger.

Metadata

ПолеЗначение
IDUC-MSG-007
Контекстmessenger (см. docs/06-services/messenger/)
Типосновной (+ A1 ручная остановка модератором, A2 сбой рекордера→Failed→retry, A3 запись ещё не готова, A4 retention→Deleted, A5 будущая транскрипция)
Статусdraft

Актор

ПолеЗначение
РольУчастники сессии (Агент/МенеджерПИН/Инвестор) — просмотр/скачивание; модератор — остановка; RecordingPolicy/рекордер (ExternalSystem) — авто-старт и финализация
Permissions / scopeJWT identity; messenger.recordings.view (участник сессии) — просмотр/скачивание; messenger.recordings.manage (модератор) — остановка; ingest — ExternalSystem (HMAC/mTLS, не JWT)
Tenantне применяется строково — доступ по участию в сессии-владельце; DealTenantId — scope выгрузки в file storage + ключ Kafka
Record-levelдоступ = актор ∈ участников сессии (пара Caller/Callee для 1:1 или ростер конференции); остановка — ConferenceRole = Moderator

Предусловие

  • Идёт сессия в контексте сделки (UC-MSG-006), подпадающая под RecordingPolicy.ShouldAutoRecord (Deal-чат — как правило, запись включена).
  • Feature flag RECORDINGS_ENABLED = true (иначе MSG_RECORDINGS_DISABLED, 503); доступны рекордер (jibri) и file storage.

Триггер

ПолеЗначение
ИсточникСтарт сессии (RecordingPolicy — не UI-кнопка) → авто-провизия записи; финализация — ingest-вебхук рекордера
Триггерящий элементстарт конференции/звонка → RecordingPolicy → ingest POST /api/messenger/internal/call-recordings/{sessionId}/ingest

Основной Поток

Нить — успешная авто-запись конференции по сделке и её выдача. Полный алгоритм и маппинг — в jibri-recording-ingest.md и get-recording-download.md; ниже — поток на уровне use-case.

  1. Решение политики. При старте сессии (UC-MSG-006, шаг 3) RecordingPolicy.ShouldAutoRecord(chatKind = Deal, sessionKind) возвращает «записывать»: провизится CallRecordingEntityStatus = Pending, Trigger = AutoPolicy, InitiatedByUserId = null (INV-REC-3), XOR-связь с сессией-владельцем по SessionKind (INV-REC-1), не более одной активной записи на сессию (INV-REC-7).
  2. Индикатор записи (согласие-нотис). До старта захвата участникам широковещается индикатор записи: NoticeState = Announced, AnnouncedAt = now; поднимается RecordingNoticeAnnouncedEvent → push в chat:{ChatId}. Даже при авто-записи участники обязаны видеть индикатор (INV-REC-4); пер-участниковое согласие как гейт — роадмап, старт запись не гейтит.
  3. Старт захвата. Рекордер (jibri) подтверждает захват через presence/событие media-стека (jitsi-events): Pending → Recording, StartedAt = now. Вход в Recording при NoticeState = Pending — нарушение (INV-REC-4). Поднимается RecordingStartedEvent → push RecordingState(Started).
  4. Завершение сессии. Сессия завершается (UC-MSG-006, шаг 10) или модератор останавливает запись (A1): Recording → Uploading, StoppedAt = now; push RecordingState(Stopped). Рекордер начинает финализацию файла.
  5. Ingest-финализация. jibri стримит финализированные байты в ingest-эндпоинт POST /internal/call-recordings/{sessionId}/ingest (jibri-recording-ingest): ExternalSystem-актор, подпись X-Pin-Signature (HMAC + replay-window) или mTLS, fail-closed сверка имени комнаты со MediaRoom.RoomName (иначе MSG_JITSI_ROOM_UNKNOWN/MSG_JITSI_SIGNATURE_INVALID).
  6. Выгрузка в file storage. Байты сохраняются в file storage (S3); возвращается стабильный FileId; заполняются метаданные FileName/ContentType/SizeBytes/DurationSeconds/Format/ExternalRecordingId (иначе MSG_STORAGE_UNAVAILABLE, 503, A2). Бинарь в messenger не хранится (INV-REC-6).
  7. Готовность. Uploading → Available, AvailableAt = now; FileId/SizeBytes/DurationSeconds/Format становятся обязательны и далее иммутабельны (INV-REC-2/5). Поднимается RecordingAvailableEvent.
  8. Факт наружу. Атомарно с переходом → Available публикуется интеграционное pin.messenger.call-recording-available.v1 (ключ SessionId): crm при DealId != null прикрепляет FileId к таймлайну/встрече сделки, notifications уведомляет участников. Домен-push RecordingState(Available) несёт FileId, но не URL (hub не отдаёт ссылок).
  9. Скачивание участником. Участник запрашивает GET /recordings/{recordingId}/download (get-recording-download): гейт messenger.recordings.view + участие в сессии; запись Available (иначе MSG_RECORDING_NOT_READY, 409, A3); возвращается RecordingDownloadModel с короткоживущим пресайн-Url (Sensitive) и ExpiresAt; чтение аудируется как ReadSensitive. Бинарь/URL в события и логи не попадают.

Диаграмма

Альтернативные Потоки

A1. Модератор остановил запись вручную

  1. Модератор жмёт «Остановить запись» POST /conferences/{id}/recording/stop (гейт messenger.recordings.manage, ConferenceRole = Moderator, иначе MSG_RECORDING_FORBIDDEN, 403): Recording → Uploading, далее — как основной поток с шага 5. Сессия при этом может продолжаться без записи.

A2. Сбой рекордера/выгрузки → Failed → retry

  1. Рекордер недоступен / сбой захвата / сбой выгрузки в file storage: запись переходит в Failed, FailureReason = RecorderUnavailable/UploadFailed/Timeout/Error (INV-REC-2); поднимается RecordingFailedEvent → push RecordingState(Failed) + внутренний алерт.
  2. Транзиентные сбои выгрузки/финализации добирает фоновый recording-finalize-retry (идемпотентно, обновляет только неконечные/Failed-по-транзиенту статусы); при исчерпании ретраев запись остаётся Failed — сама сессия и её conference-ended.v1 этим не блокируются.

A3. Запись ещё не готова

  1. Участник запрашивает скачивание, пока запись Pending/Recording/UploadingMSG_RECORDING_NOT_READY (409). Клиент ждёт push RecordingState(Available) / событие call-recording-available.

A4. Истёк срок хранения → Deleted

  1. Наступил RetentionUntil (ADR-0032): retention-джоба переводит Available → Deleted, FileId очищается, файл удаляется из file storage; строка записи сохраняется как факт (метаданные без бинаря). Скачивание после — MSG_RECORDING_FILE_MISSING (400) / MSG_RECORDING_NOT_FOUND (404).

A5. Будущая транскрипция/конспект (шов)

  1. Событие call-recording-available (несёт FileId/SessionId/DealId?/DurationSeconds) — триггер будущего STT/суммаризация-пайплайна: отдельная сущность CallMeetingInsightEntity (1:1 к записи), поля TranscriptFileId/SummaryFileId зарезервированы как шов (transcription-roadmap). Текущую модель не блокирует.

Постусловие

  • Создана CallRecordingEntity с терминальным статусом: Available (заданы FileId + SizeBytes/ DurationSeconds/Format, иммутабельны, INV-REC-5) либо Failed (FailureReason); при истечении retention — Deleted (FileId очищен).
  • Бинарь — в file storage (S3), в messenger только FileId + метаданные (INV-REC-6); индикатор записи был разослан участникам (NoticeState = Announced, INV-REC-4).
  • При Available опубликовано ровно одно pin.messenger.call-recording-available.v1 (ключ SessionId) — crm прикрепил запись к сделке (при DealId != null); участник может скачать пресайн-ссылкой (аудит ReadSensitive), URL/бинарь в события и логи не попали.

Инварианты, статусы и валидации

Переходы RecordingStatus (полный перечень)

FromToУсловие переходаИнициатор
[*]PendingRecordingPolicy.ShouldAutoRecord = записывать (INV-REC-3)политика (старт сессии)
PendingRecordingрекордер подтвердил захват; NoticeState = Announced (INV-REC-4)media-стек (presence)
RecordingUploadingсессия завершена / ручная остановка модератором (A1)сессия / модератор
UploadingAvailableбинарь выгружен, задан FileId + метаданные (INV-REC-2/5)ingest (jibri)
любойFailedсбой рекордера/выгрузки (FailureReason, INV-REC-2)media-стек / worker (A2)
AvailableDeletedнаступил RetentionUntil (ADR-0032) — FileId очищенretention-джоба (A4)

NoticeState: Pending → Announced (индикатор разослан) — обязателен до входа в Recording (INV-REC-4). Терминальные: Available (до Deleted), Failed, Deleted.

Инварианты (полный перечень)

ИнвариантСмысл
INV-REC-1Ровно один из CallSessionId/ConferenceSessionId задан и согласован с SessionKind (XOR).
INV-REC-2Допустимы только перечисленные переходы; FileId/SizeBytes/DurationSeconds/Format обязательны в Available, null до него.
INV-REC-3Авто-запись сессии в контексте сделки: Trigger = AutoPolicy, InitiatedByUserId = null.
INV-REC-4NoticeState = Announced (индикатор разослан) обязателен до входа в Recording.
INV-REC-5FileId/SizeBytes/DurationSeconds/Format в Available иммутабельны.
INV-REC-6Бинарь не хранится в messenger — только FileId (file storage) + метаданные.
INV-REC-7Не более одной активной (Pending/Recording/Uploading) записи на сессию.

Валидации границы ingest (media-стек → messenger)

  • Подпись X-Pin-Signature (HMAC над сырым телом) + свежесть X-Pin-Timestamp (окно replay ≤ 5 мин) — иначе MSG_JITSI_SIGNATURE_INVALID (401).
  • RoomName/{sessionId} совпадает с провизированной комнатой сессии (fail-closed) — иначе MSG_JITSI_ROOM_UNKNOWN (403).
  • Outcome = Completed требует непустую часть file — иначе MSG_RECORDING_FILE_MISSING (400).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
MSG_RECORDINGS_DISABLED503RECORDINGS_ENABLED = false«Запись временно недоступна»
MSG_RECORDING_PROVISION_FAILED503не удалось провизировать запись при старте сессииСессия идёт без записи; ретрай политики
MSG_RECORDING_NOT_FOUND404запись не найдена / актор не участник сессии«Запись не найдена»
MSG_RECORDING_FORBIDDEN403остановка не модератором (нет recordings.manage)Скрыть «остановить запись»
MSG_RECORDING_NOT_READY409скачивание при Pending/Recording/Uploading (A3)Ждать RecordingState(Available)
MSG_STORAGE_UNAVAILABLE503file storage недоступен при выгрузке (A2)Ретрай финализации фоном
MSG_RECORDING_FILE_MISSING400FileId отсутствует/очищен (retention, A4)«Файл записи недоступен»
MSG_JITSI_ROOM_UNKNOWN403ingest: имя комнаты не совпало со MediaRoom.RoomName (fail-closed)— (внутренний вебхук)
MSG_JITSI_SIGNATURE_INVALID401ingest: неверная подпись X-Pin-Signature / вне replay-window— (внутренний вебхук)

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

СобытиеКогда поднимаетсяSubscribers
RecordingNoticeAnnouncedEventNoticeState → Announced (шаг 2)push индикатора записи в chat:{ChatId} / user:{party}
RecordingStartedEvent→ Recording (шаг 3)push RecordingState(Started)
RecordingStoppedEvent→ Uploading (шаг 4 / A1)push RecordingState(Stopped)
RecordingAvailableEvent→ Available (шаг 7)доменный push RecordingState(Available) (без URL) + интеграционное событие
RecordingFailedEvent→ Failed (A2)push RecordingState(Failed) + внутренний алерт
RecordingDeletedEvent→ Deleted (A4, retention)запрос удаления в file storage; очистка FileId

Все — доменные события; hub-push — non-PII, без ссылок/URL (RecordingState несёт FileId только при Available).

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
RecordingAvailableEventpin.messenger.call-recording-available.v1атомарно с Uploading → Available (ключ SessionId)crm (прикрепить FileId к сделке при DealId != null), notifications (уведомить участников)

Публикация — через транзакционный outbox из ingest-эндпоинта (persist-then-push). В событии нет URL/секретов — потребитель берёт пресайн сам. Это же событие — триггер будущего пайплайна транскрипции (A5). Контракт — call-recording-available.md.

Конкурентность, идемпотентность и восстановление

  • Ingest. Идемпотентен по RecordingId/SessionId, version-guard по RecordingStatus: повтор финализации на терминале (Available/Failed) → no-op с текущим статусом; выгрузка в file storage не повторяется.
  • Старт захвата. RecordingStatusChanged(on) (presence) идемпотентен, порядок — по OccurredAt; вход в Recording при NoticeState = Pending — нарушение (INV-REC-4).
  • Одна активная запись (INV-REC-7). Партиал-уникальный индекс активных записей не даёт завести вторую на сессию; повтор провизии → MSG_RECORDING_ALREADY_ACTIVE.
  • Транзиентные сбои выгрузки. Запись остаётся Uploading, добирается фоновым recording-finalize-retry идемпотентно; исчерпание ретраев → Failed. Сама сессия и её conference-ended.v1 этим не блокируются (decoupling: запись переживает звонок).
  • Доставка события. call-recording-available.v1 — атомарно с Uploading → Available (транзакционный outbox), at-least-once; консюмеры (crm/notifications) идемпотентны по RecordingId (first-seen); ключ SessionId.
  • Retention (A4). Available → Deleted по RetentionUntilFileId очищен, файл удалён; скачивание после → MSG_RECORDING_FILE_MISSING (400) / MSG_RECORDING_NOT_FOUND (404).

Кросс-сервисные контракты, порядок событий и промежуточные состояния

Ожидает (входные гарантии):

  • media-стек: RecordingStatusChanged(on/off) (presence — старт/стоп захвата), jibri finalize ingest (подписанный, room-match, jibri-recording-ingest.md).
  • file storage (S3): стабильный FileId при выгрузке, пресайн для скачивания, удаление по retention. При недоступности — MSG_STORAGE_UNAVAILABLE (503), ретрай финализации фоном.

Предоставляет:

  • pin.messenger.call-recording-available.v1 — топик .v1, ContractVersion = 1, 3 партиции, ключ SessionId. Payload: RecordingId/SessionKind/SessionId/ChatId?/DealId?/TenantId?/MediaType/ FileId/Format/DurationSeconds/SizeBytes/Trigger/StartedAt/EndedAt/AvailableAtбез URL/секретов/бинаря (call-recording-available.md). Консюмеры: crm (при DealId != null прикрепить FileId, SLA < 1 мин), notifications (уведомить участников).
  • Домен-push RecordingState(...) в chat:{ChatId}/user:{party} — non-PII, без URL.

Порядок событий (важно для консюмеров):

  • conference-ended.v1 (UC-MSG-006) и call-recording-available.v1независимые события: запись готова позже завершения сессии (время финализации/выгрузки). crm получает карточку встречи из conference-ended, а FileId записи — отдельным, более поздним событием и прикрепляет к уже существующей карточке. Консюмер conference-ended не должен ждать записи в своём обработчике.

Промежуточные состояния:

  • Участник видит индикатор записи (Announced) до старта захвата (INV-REC-4). После завершения сессии — «запись обрабатывается» (Uploading): скачивание → MSG_RECORDING_NOT_READY (409, A3), пока не придёт RecordingState(Available) / событие.

Наблюдаемость и НФТ

  • Записи редки (по сделкам); выгрузка — потоковая memory-bounded; латентность готовности зависит от длительности/размера файла.
  • Логи: ingest received/uploaded/available/failed (sessionId, RecordingId, SizeBytes, Format, result); байты записи, пресайн-URL и секрет подписи — не логируются.
  • Метрики: messenger_recording_ingest_total{result}, upload bytes/latency p95, доля Failed/retry; alert на Failed/storage-unavailable.
  • Доступ: messenger.recordings.view (участник — просмотр/скачивание, аудит ReadSensitive), messenger.recordings.manage (модератор — остановка); ingest — ExternalSystem (HMAC/mTLS, не JWT).

Acceptance Criteria

#КритерийПроверка
AC1Звонок по сделке авто-записывается по RecordingPolicy: Trigger = AutoPolicy, InitiatedByUserId = null (INV-REC-3)Тест: старт Deal-конференции → запись Pending, AutoPolicy
AC2Индикатор записи разослан (NoticeState = Announced) до входа в Recording (INV-REC-4)Тест: Recording при NoticeState = Pending → нарушение
AC3Ingest fail-closed: неверная подпись → 401; чужая комната → 403 (сверка MediaRoom.RoomName)Тест: bad X-Pin-Signature401 SIGNATURE_INVALID; unknown room → 403 ROOM_UNKNOWN
AC4При → Available заданы FileId+SizeBytes+DurationSeconds+Format (иммутабельны), бинарь не в БД (INV-REC-5/6)Тест: call_recordings без бинаря; Available без FileIdINVALID_STATE
AC5Ровно одно call-recording-available.v1 (ключ SessionId), без URL/секретовТест: outbox содержит событие; payload без URL
AC6Скачивание — участнику Available-записи, пресайн-Url короткоживущий, аудит ReadSensitive; не готова → 409Тест A3: Uploading409 NOT_READY; Available → пресайн + audit-строка
AC7Сбой → Failed (FailureReason); транзиент добирает recording-finalize-retry идемпотентно; сессия не блокируетсяТест A2: сбой выгрузки → Failed; retry без дубля; conference-ended.v1 опубликован
AC8conference-ended и call-recording-available независимы: готовность записи приходит позже; консюмер conference-ended не ждёт записиТест: conference-ended обработан без записи; позже call-recording-available прикрепляет FileId к карточке
AC9Retention: Available → Deleted по RetentionUntil, FileId очищен; скачивание после → FILE_MISSING/NOT_FOUNDТест A4: наступил RetentionUntilDeleted; download → 400/404

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы