ADR-0062: Медиа-стек звонков и запись на Jitsi (JVB SFU + coturn + jibri → file storage; JWT из messenger; нативный lib-jitsi-meet)
Статус
Accepted (supersedes отложенный media-пункт ADR-0055 §2.4 — «media-стек выбирается при реализации»)
Дата
2026-07-15
Назначение
Зафиксировать медиа-стек для аудио/видео-звонков и записи в мессенджере PIN: чем обеспечиваются 1:1-звонки и групповые конференции чата сделки, кто выдаёт доступ к комнате, как ведётся запись и куда она попадает. ADR-0055 сознательно отложил этот выбор («звонки — отдельная фаза; media-стек выбирается при реализации») — здесь он делается.
Контекст
- Отложенное решение. ADR-0055 §2.4 зафиксировал только контракт
сигналинга поверх контактов и оставил media-стек (WebRTC/SFU) на этап реализации. Сам мессенджер —
собственный (контекст
messenger, .NET, PostgreSQL, доставка realtime — SignalR), без внешнего чат-сервера. - Целевая нагрузка невысока. ADR-0056: 1–10k одновременно активных, агентов/менеджеров ≤20k. Медиа-решение должно быть простым в эксплуатации и не тянуть за собой инфраструктуру «на вырост».
- Данные уже готовы к сигналинг-only модели.
CallSessionEntity(call-session.md) моделирует 1:1-звонок как запись сигналинга (кто/кому/тип/статус/причина завершения/длительность); в ней нет и не должно быть медиа-данных. Комната описывается owned-VOMediaRoom(RoomName+Domain) — это не медиа и не PII; сами access-токены/SDP/ICE/TURN эфемерны и не персистятся. - Появились групповые звонки и запись. Требуются конференции в чате сделки
(
ConferenceSessionEntity, conference-session.md; участники —CallParticipantEntity, call-participant.md) и запись звонков с сохранением файла (CallRecordingEntity, call-recording.md). Нужен SFU (mesh/P2P на 3+ участниках не тянет) и серверная запись с выгрузкой в файловое хранилище. - Кросс-тенантность и допуск по членству. Доступ к звонку/конференции определяется членством в
чате/контакте, а не
TenantId; выдача доступа к комнате должна выпускаться самимmessengerв той же транзакции, что и изменение состояния (persist-then-push, messenger-hub.md).
Решение
Самостоятельно разворачиваемый Jitsi как медиа-стек мессенджера. Компоненты и роли:
- JVB (Jitsi VideoBridge) — SFU для группового медиа (конференции чата сделки) и релея 1:1. Публичный медиатрафик — UDP 10000. JVB — узел горизонтального масштабирования по медиа.
- coturn — TURN/STUN для прохождения NAT/файрволов; TURN-креды короткоживущие, отдаются клиенту внутри
MediaJoinModel.IceServers(эфемерно, не персистятся). - jibri — серверная запись конференций и звонков в файл; по завершении файл выгружается в
file storage (
filestorage), вCallRecordingEntityфиксируетсяFileIdи метаданные (Format/SizeBytes/DurationSeconds). - JWT выпускает
messenger(HS256, общийapp_secretс prosody), короткий TTL, минтится в момент доставки realtime-события. Клиент допускается в комнату только по валидному токену — токен несётroom/участника/срок. Токен —Sensitive, никогда не логируется и не хранится. - Нативный клиент
lib-jitsi-meet(НЕ IFrame API): фронт подключается к комнате напрямую поMediaJoinModel(RoomName,AccessToken,SignalingUrlwss,MediaDomain,IceServers,ExpiresAt), UI — собственный. - 1:1 и групповые/конференц-звонки — на одном стеке. Для 1:1
CallSessionEntityостаётся сигналинг-моделью (SessionKind.Direct); конференция —ConferenceSessionEntity. - Запись по политике, а не по флагу. Авто-запись определяется типизированной
RecordingPolicy.ShouldAutoRecord(chatKind, sessionKind)(не boolean-флаг); ручной старт/стоп — модератором. Запись сопровождается уведомлением участников (RecordingNoticeState). - Жизненный цикл комнаты — через
mod_reservations. prosody перед созданием комнаты обращается кmessengerwebhook’ом (reservation): комната допускается только если еёRoomNameсовпадает с сохранённымMediaRoom.RoomNameактивной сессии (fail-closed). Присутствие/события комнаты (join/leave/recording) прокидываются кастомным prosody-модулем вmessenger. - Интеграция допускается только доверенным вызовом. Внутренние эндпойнты (
api/internal/) от медиа-стека аутентифицируются HMAC (X-Pin-Signature+ окно защиты от replay) либо mTLS; несовпадение подписи →MSG_JITSI_SIGNATURE_INVALID(401), неизвестная комната →MSG_JITSI_ROOM_UNKNOWN(403).
Готовность записи и завершение конференции публикуются в Kafka: pin.messenger.conference-ended.v1
(key DealId) и pin.messenger.call-recording-available.v1 (key SessionId). 1:1-звонок Kafka не
публикует — только realtime через outbox.
Область Действия
| Область | Входит | Не входит |
|---|---|---|
| Сервисы | messenger (звонки/конференции/запись, JWT, reservation/ingest), filestorage (хранение записей) | crm, catalog, identity — только потребители событий |
| Контракты | JWT-claims (room/участник/TTL), reservation-webhook (mod_reservations), POST /internal/jitsi/events, jibri-ingest (POST /internal/call-recordings/{sessionId}/ingest), топики pin.messenger.call-recording-available.v1, pin.messenger.conference-ended.v1; MediaJoinModel как единственный descriptor допуска | внешний медиа-протокол — не выставляется наружу |
| Данные | ConferenceSessionEntity, CallParticipantEntity, CallRecordingEntity, расширенный CallSessionEntity (+SessionKind, ссылка на запись); owned-VO MediaRoom | access-токены/SDP/ICE/TURN — эфемерны, не персистятся |
| Эксплуатация | дашборды и SLO по JVB / jibri / coturn / prosody / jicofo; публичный UDP 10000; capacity jibri | — |
Варианты
| Вариант | Описание | Плюсы | Минусы | Причина Решения |
|---|---|---|---|---|
| Self-hosted Jitsi (JVB+coturn+jibri+prosody+jicofo) | Свой медиа-стек, JWT/reservation из messenger, jibri-запись → file storage | Полный контроль над медиа, допуском и записью; своё хранилище записей (FileId); JWT/reservation «из коробки» через prosody; готовый jibri для записи | Операционная нагрузка (5 компонентов, публичный UDP, CPU jibri) | Принято — единственный вариант, дающий и SFU, и серверную запись → своё хранилище, и admission через JWT/reservation без «клея» |
| Managed SaaS (Twilio/Daily/JaaS) | Внешний медиа-провайдер | Нет ops-нагрузки, быстрый старт | Стоимость на масштабе, data residency/приватность записей, нет контроля над self-host и хранением | Отклонено: стоимость, резидентность данных, отсутствие контроля над записями |
| mesh / P2P-only | Прямые соединения без SFU | Нет серверного медиа-узла | Разваливается на 3+ участниках; нет серверной записи | Отклонено: не тянет групповые конференции и запись |
| mediasoup / LiveKit | Green-field медиа-сервер | Гибкий SFU | Нет встроенного jibri-эквивалента (запись → S3/файл) и prosody-admission (JWT/reservation) — больше «клея» под нашу модель | Отклонено: пришлось бы дописывать запись и admission руками |
Последствия
| Тип | Последствие | Компенсация |
|---|---|---|
| Архитектура | Полный контроль над медиа, допуском и собственной записью → file storage (FileId); messenger — единственный источник допуска (JWT + reservation) | MediaJoinModel — единственный descriptor; допуск fail-closed по MediaRoom.RoomName |
| Безопасность | Внутренние эндпойнты открыты медиа-стеку; JWT/TURN-креды чувствительны | HMAC (X-Pin-Signature + replay-окно) или mTLS; JWT/токены короткий TTL, Sensitive, не логируются; room-match fail-closed |
| Данные | Файлы записей в filestorage; метаданные записи в CallRecordingEntity | Ретенция RetentionUntil; медиапотоки/токены не персистятся |
| Эксплуатация | Новая ops-нагрузка: 5 компонентов, публичный UDP 10000, CPU-нагрузка jibri | JVB — узел масштабирования по медиа; jibri — узкое место по ёмкости записи (мониторинг + очередь/лимит одновременных записей) |
Затронутые Контракты И Совместимость
| Контракт | Версия | Совместимость | Миграция |
|---|---|---|---|
pin.messenger.conference-ended.v1 | 1 | новый топик | additive |
pin.messenger.call-recording-available.v1 | 1 | новый топик | additive |
CallSessionEntity (+SessionKind, RecordingStatus?/RecordingId?) | — | расширение | additive-миграция схемы |
MediaJoinModel / MediaTokenModel | 1 | новый контракт допуска | новый |
api/internal/jitsi/*, jibri-ingest | 1 | внутренний, доверенный | новый |
Нефункциональные Требования
| Требование | Цель | Проверка |
|---|---|---|
| Задержка входа | join p95 < 3 c (от запроса допуска до media-соединения) | метрика на media-token/join + дашборд JVB |
| Надёжность записи | успешность записи ≥ 99% | доля RecordingStatus=Available от инициированных; алерт по Failed |
| Безопасность | допуск только по валидному JWT + room-match; внутренние вызовы подписаны | review + тест fail-closed (MSG_JITSI_ROOM_UNKNOWN/MSG_JITSI_SIGNATURE_INVALID) |
| Наблюдаемость | дашборды JVB/jibri/coturn/prosody/jicofo | метрики компонентов + SLO join/recording |
План Отката Или Forward-Fix
| Сценарий | Действие | Условие Применения |
|---|---|---|
| Медиа-стек нестабилен / инцидент | Feature flag CALLS_MEDIA_ENABLED = off → деградация до сигналинг-only (503 MSG_CALLS_DISABLED), звонки не поднимаются | острые проблемы JVB/coturn |
| Конференции нестабильны | CONFERENCE_CALLS_ENABLED = off (503 MSG_CONFERENCES_DISABLED) | проблемы групповых звонков |
| Запись недоступна | RECORDINGS_ENABLED = off; авто-запись через RecordingPolicy не инициируется | недоступность jibri/хранилища |
Изменения В Документации
- Сервис-обзор и API: messenger/README.md, api/calls/, api/hub/messenger-hub.md.
- Entities/models: call-session.md, conference-session.md, call-participant.md, call-recording.md.
- События: events/conference-ended.md, events/call-recording-available.md.
- Отметка supersede в ADR-0055 §2.4.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: CallRecordingEntity — запись звонка (хранение в file storage), Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ConferenceSessionEntity — групповой звонок в чате сделки, Entity: CallParticipantEntity (owned-child ConferenceSessionEntity)
- API (25): GET /api/messenger/calls — Журнал звонков (входящие / исходящие / пропущенные), GET /api/messenger/calls/{callId}/recordings — Записи 1:1-звонка, GET /api/messenger/chats/{chatId}/conferences — История конференций сделки, GET /api/messenger/conferences/{conferenceId} — Карточка конференции, GET /api/messenger/conferences/{conferenceId}/recordings — Записи конференции, GET /api/messenger/recordings/{recordingId} — Карточка записи, GET /api/messenger/recordings/{recordingId}/download — Ссылка на скачивание записи, POST /api/messenger/calls — Начать звонок, POST /api/messenger/calls/{callId}/answer — Принять звонок, POST /api/messenger/calls/{callId}/decline — Отклонить звонок, POST /api/messenger/calls/{callId}/end — Завершить звонок (hangup, call-лог), POST /api/messenger/calls/{callId}/media-token — Обновить токен входа в звонок (+13)
- Use Cases (3): UC-MSG-005. Сигналинг аудио/видео-звонка, UC-MSG-006. Групповой звонок-конференция в чате сделки, UC-MSG-007. Авто-запись звонка по сделке и её выдача участнику
- События (2): Event Contract: pin.messenger.call-recording-available (CallRecordingAvailableEvent), Event Contract: pin.messenger.conference-ended (ConferenceEndedEvent)
- Фоновые задачи (3): Background Job: ConferenceRoomReaperJob, Background Job: RealtimeDispatchJob, Background Job: RecordingFinalizeRetryJob
- adr (1): ADR-0055: Собственный мини-мессенджер PIN; оркестрация чат↔сделка в CRM-событиях
- index.md (1): Messenger Service
- calls (13): Жизненный цикл сессии звонка — 1:1 и конференция (messenger), Медиа-звонки и запись на Jitsi — интеграционный гид (messenger), Медиа-звонки на Jitsi: план поставки (playbook аналитик → разработчик), Backend-реализация медиа-звонков: спека → код (messenger), Jitsi-звонки: план верификации и приёмки, Jitsi: именование комнат, admission и presence, Jitsi: инвентарь изменений в thirdparty/jitsi, Jitsi: минтинг JWT входа в .NET, Jitsi: нативный клиент lib-jitsi-meet на Next.js 16, Jitsi: роадмап транскрипции и конспектов (будущая фаза), Jitsi: сквозной пайплайн авто-записи, Jitsi: k8s-топология и взаимодействие с бэкендом (+1)