Жизненный цикл сессии звонка — 1:1 и конференция (messenger)
Сводный разбор жизненного цикла медиа-сессии контекста messenger: как создать/открыть, как
подключить/добавить участников и как закрыть — для обеих моделей звонка: 1:1
(call-session.md) и групповой конференции в чате сделки
(conference-session.md + ростер
call-participant.md). Это гид: он ссылается на API-операции, состояния и
события, но остаётся на контрактном уровне — дом полей, инвариантов и статусов живёт в domain-доках, детали
операций — в api/.
Media-стек нейтрален (ADR-0062). Медиа (звук/видео/демонстрация экрана) идёт клиент ↔ SFU; бэкенд
messengerне в медиа-плоскости: он ведёт факты жизненного цикла и ростер, а вход в комнату раздаёт дескрипторомMediaJoinModel(комната + короткоживущий токен доступа). SDP/ICE и токены не персистятся (INV-CALL-4/6,INV-CONF-7). Media за флагамиCALLS_MEDIA_ENABLED(1:1) /CONFERENCE_CALLS_ENABLED(конференции).
Назначение
Дать единую карту сессии, чтобы за один документ понять: какие состояния проходит звонок, какими командами он открывается и закрывается, кто и как входит/выходит из состава, и чем 1:1 отличается от конференции. Разбор нужен и фронту (что показывать по каждому переходу), и бэкенду (кто авторитет состава, кто закрывает осиротевшую комнату), и аналитику (какие события и куда публикуются).
Две модели рядом
| Аспект | 1:1-звонок | Конференция |
|---|---|---|
| Агрегат | CallSessionEntity (messenger.call_sessions) | ConferenceSessionEntity (messenger.conference_sessions) |
| Состав | фиксированная пара Caller/Callee | динамический ростер CallParticipantEntity (owned-child) |
| Гейт доступа | Accepted-контакт вне чёрного списка (CommunicationPolicy.CanCall, INV-CALL-1) | активное членство в Deal-чате (record-level, INV-CHAT-4); отдельного контактного гейта нет |
| Роли | нет ролей модерации | ровно один ConferenceRole = Moderator (INV-CONF-6), остальные Speaker |
| Статусы | Ringing → Answered → Ended (+ Declined/Cancelled/Missed/Failed) | Provisioning → Active → Ended (+ Failed) |
| Комната | Room (owned VO MediaRoom), минтится при → Ringing | Room, резервируется при старте; admission подтверждает Provisioning → Active |
| Авто-завершение | ring-timeout → Missed; stale → Failed (reaper) | опустошение/stale/провизия → Ended/Failed (reaper) |
| Запись | по политике RecordingPolicy (в сделке) | по политике (для сделки, как правило, включена) |
| Kafka | не публикует | pin.messenger.conference-ended.v1 (ключ DealId) при Ended/Failed |
| Reaper | call-timeout-reaper | conference-room-reaper |
| Авторитет состава | сам агрегат (пара фиксирована) | presence-вебхук media-стека (jitsi-events.md, INV-CPART-5) |
Общее для обеих моделей: persist-then-push (ADR-0055) — смена состояния и строка
messenger.realtime_outbox пишутся в одной транзакции; после фиксации realtime-dispatch доставляет push и
минтит MediaJoinModel.AccessToken в момент доставки (в БД/outbox токен не хранится). Комнаты в
развёртывании не самоподнимаются — их допуск решает admission-вебхук
(jitsi-reservation.md, fail-closed).
Создание / открытие сессии
1:1 — start-call
Инициатор зовёт Accepted-контакт. Проверяется CanCall (INV-CALL-1) и отсутствие активного звонка пары
(INV-CALL-5), резервируется Room (RoomName детерминирован из id + nonce), по политике заводится запись
Pending. Создаётся сессия Ringing, в той же транзакции — строка outbox CallInvited для callee. После
фиксации callee получает CallInvite (комната + токен) в группу user:{CalleeUserId}; инициатор — свой Room
в ответе 201.
Конференция — start-conference
Активный участник Deal-чата поднимает встречу. Проверяется Deal-тип и не-Closed чата (INV-CONF-1) и
guard одной активной на чат (INV-CONF-2, ix_conf_active), резервируется Room, по политике заводится
запись Pending. Создаётся сессия (Provisioning → Active) и строка ростера инициатора
(ConferenceRole = Moderator, State = Joined, INV-CONF-6); ActiveParticipantCount = 1. В той же
транзакции — намерение broadcast ConferenceInvite в группу chat:{ChatId}. Все активные участники чата
получают приглашение с дескриптором входа.
Роль admission (Provisioning → Active). Комнату поднимает media-стек только с разрешения
jitsi-reservation: mod_reservations спрашивает messenger по имени
комнаты, первое успешное резервирование переводит конференцию Provisioning → Active (StartedAt). Пока
admission не пришёл — конференция в Provisioning, вход невозможен (MSG_CONFERENCE_NOT_ACTIVE). 1:1-звонок
провизии не требует — комната минтится сразу при → Ringing.
Подключение / добавление участников
1:1 — фиксированная пара
- answer-call — только
Callee:Ringing → Answered(AnsweredAt, старт отсчёта длительности), callee получаетRoomв ответ, обеим сторонам —CallSignal(Answered). - media-token — любой участник в
Answeredпереполучает свежийMediaJoinModel(реконнект/истёкший токен). Состояние не меняется (Query-операция), комната и роль те же.
Конференция — четыре пути + presence-вебхук
Ростер меняют четыре команды, а авторитетно сверяет их серверный presence-вебхук
(jitsi-events.md, INV-CPART-5):
| Путь | Операция | Переход строки ростера | Realtime |
|---|---|---|---|
| Сам входит | join-conference | [*]/Invited/Left → Joined (идемпотентная реактивация, INV-CPART-1) | MediaParticipant(Joined) → chat:{ChatId}, актору — свежий Room |
| Адресное приглашение | invite-participant | создаётся Invited для конкретных членов чата | адресный ConferenceInvite → user:{UserId} (тот же hub-метод, но targeted) |
| Отклонение | decline-conference | Invited → Declined | MediaParticipant(Left) инициатору приглашения / в chat:{ChatId} |
| Удаление модератором | remove-participant | Joined → Left (RemovedByModerator) + eject из комнаты | MediaParticipant(Left) → chat:{ChatId} |
Авторитет ростера — presence-вебхук. OccupantJoined/OccupantLeft от media-стека upsert-ят строку
ростера (создание/реактивация/soft-leave), пересчитывают ActiveParticipantCount/PeakParticipantCount и
LastActivityAt. При расхождении с локальными командами выигрывает серверное событие (INV-CPART-5).
Реконнект, grace, двойное устройство. Разрыв сети переводит участника в Left c
LeaveReason = Disconnected только после grace-окна без реконнекта; реконнект в пределах окна реактивирует ту
же строку (Left → Joined, новый JoinedAt), дубля нет. Один пользователь с двух устройств — одна строка
ростера (ux_call_participants_conf_user): второй вход реактивирует существующую. Вход не назначает
модерацию — роль остаётся Speaker, если ранее не был модератором.
Закрытие сессии
Все пути завершения обеих моделей. Строка ростера при закрытии сессии soft-leave (Left, история
сохраняется); сессия — терминальный статус. Kafka публикует только конференция (Ended/Failed).
| Действие | Кто | Переход | EndReason | Hub-событие | Доменное / интеграционное |
|---|---|---|---|---|---|
| 1:1 | |||||
| decline | Callee в Ringing | Ringing → Declined | Declined | CallSignal(Declined) обеим | CallDeclinedEvent (Kafka нет) |
| cancel | Caller в Ringing (end-call) | Ringing → Cancelled | CancelledByCaller | CallSignal(Cancelled) обеим | CallCancelledEvent (Kafka нет) |
| timeout | call-timeout-reaper | Ringing → Missed | Timeout | CallSignal(Missed) / CallMissed инициатору | CallMissedEvent (Kafka нет) |
| hangup | любая сторона в Answered (end-call) | Answered → Ended | HangupCaller/HangupCallee | CallSignal(Ended) обеим | CallEndedEvent (Kafka нет) |
| signaling/stale error | сигналинг-слой / reaper (stale-answered) | Ringing/Answered → Failed | Error | CallSignal(Ended) / CallEnded | CallEndedEvent (Kafka нет) |
| Конференция | |||||
| leave | сам участник (leave-conference) | ростер Joined → Left; сессия остаётся Active | SelfLeft (строка) | MediaParticipant(Left) → chat:{ChatId} | ConferenceParticipantLeftEvent (Kafka нет) |
| remove | модератор (remove-participant) | ростер Joined → Left + eject | RemovedByModerator (строка) | MediaParticipant(Left) → chat:{ChatId} | ConferenceParticipantLeftEvent (Kafka нет) |
| выход последнего | последний активный вышел | сессия остаётся Active до grace → закрывает reaper | LastParticipantLeft (семантика) / EmptyTimeout (по факту reaper) | — (закрытие даёт reaper) | см. reaper-строку |
| moderator end | модератор (end-conference) | Active → Ended (ростер закрыт ConferenceEnded) | ModeratorEnded | ConferenceSignal(Ended) → chat:{ChatId} | ConferenceEndedEvent + conference-ended.v1 |
| chat closed | закрытие Deal-чата (INV-CHAT-8) | Active → Ended | ChatClosed | ConferenceSignal(Ended) | ConferenceEndedEvent + conference-ended.v1 |
| reaper empty/stale | conference-room-reaper | Active → Ended | EmptyTimeout (пусто > grace) / Timeout (stale > cap) | ConferenceSignal(Ended) | ConferenceEndedEvent + conference-ended.v1 |
| provision fail | reaper (admission не пришёл) | Provisioning → Failed | Error | опц. ConferenceSignal(Failed) ждавшим | ConferenceEndedEvent (без conference-ended) |
| fatal media | сигналинг-слой / reaper | Active/Provisioning → Failed | Error | ConferenceSignal(Ended) | ConferenceEndedEvent + conference-ended.v1 (для Active → Failed) |
Заметки к закрытию.
- Выход последнего ≠ мгновенное завершение. leave-conference при
опустошении оставляет сессию
Active; фактически пустую комнату закрывает reaper черезEmptyRoomGrace(EmptyTimeout,INV-CONF-8). ЗначениеLastParticipantLeftзарезервировано доменом как причина штатного авто-завершения; в текущем right-sized-дизайне (ADR-0056) операционно закрывает reaper. conference-ended.v1публикуется и дляFailed, когда конференция былаActive(Active → Failed) — downstream (crm/notifications/dev-stats) получает факт завершения независимо от причины. Исключение —Provisioning → Failed(встречи не было): интеграционное событие не пишется, только внутренний алерт.- Остановка записи. Терминальный переход при активной записи запрашивает остановку рекордера (запись
→ Uploading); готовность бинаря придёт отдельным событием call-recording-available.md. Отказ media-стека не блокирует терминальный переход. RoomDestroyed/DELETEрезервирования не форсируют завершение: ростер помечается вышедшим,LastActivityAtобновляется, финал отдаётся reaper (grace) — чтобы не гонять с реконнектом.
Идемпотентность и гонки
| Ситуация | Разрешение |
|---|---|
Двойной answer / decline (1:1) | первый переход из Ringing побеждает; повторный видит не-Ringing → MSG_CALL_INVALID_STATE (идемпотентно проигрывает) |
Glare — встречный вызов A→B и B→A | ix_call_active гейтит по направленной паре, обе сессии формально допустимы; UI показывает обеим входящий, принявшая сторона отменяет свой исходящий (Cancelled) |
| Двойной старт конференции на чат | partial unique ix_conf_active пропускает один; проигравший — MSG_CONFERENCE_ALREADY_ACTIVE (INV-CONF-2) |
Повторный join / реконнект | реактивация той же строки ростера (INV-CPART-1), ActiveParticipantCount не задваивается, выдаётся свежий Room |
| Reaper ↔ штатное завершение | оба конкурируют за терминал; первый перевод побеждает (guard статуса на перечитанном состоянии), опоздавший — no-op / MSG_*_INVALID_STATE; событие публикуется один раз |
| Presence-вебхук ↔ команда join/leave | расхождение разрешается в пользу серверного presence-события (INV-CPART-5); устаревшее по OccurredAt — no-op, состояние не откатывается |
Двойной end-conference | первый — Ended + событие; повторный — MSG_CONFERENCE_INVALID_STATE, без повторного события/пересчёта |
Повторная доставка conference-ended.v1 | ключ DealId; консюмеры идемпотентны по ConferenceId (first-seen) |
Диаграммы
1. 1:1 — полный цикл со всеми ветками закрытия
2. Конференция — старт → broadcast + targeted invite → join(×N) → remove → закрытие
3. Реконнект участника конференции (Disconnected → grace → Joined)
Матрица «действие → эндпоинт → переход → hub → домен/Kafka»
| Действие | Эндпоинт | Переход | Hub-событие | Домен / Kafka |
|---|---|---|---|---|
| Старт 1:1 | start-call | [*] → Ringing | CallInvite → user:{Callee} | CallStartedEvent |
| Ответ 1:1 | answer-call | Ringing → Answered | CallSignal(Answered) обеим | CallAnsweredEvent |
| Токен 1:1 | media-token | без изменения (Answered) | — | — (Query) |
| Отклонение 1:1 | decline-call | Ringing → Declined | CallSignal(Declined) | CallDeclinedEvent |
| Отмена 1:1 | end-call | Ringing → Cancelled | CallSignal(Cancelled) | CallCancelledEvent |
| Hangup 1:1 | end-call | Answered → Ended | CallSignal(Ended) | CallEndedEvent |
| Timeout 1:1 | call-timeout-reaper | Ringing → Missed / Answered → Failed | CallMissed / CallEnded | CallMissedEvent / CallEndedEvent |
| Старт конференции | start-conference | Provisioning → Active | broadcast ConferenceInvite → chat:{ChatId} | ConferenceStartedEvent |
| Admission | jitsi-reservation | Provisioning → Active | — (инвайты уже разосланы) | — |
| Присоединение | join-conference | ростер → Joined | MediaParticipant(Joined) | ConferenceParticipantJoinedEvent |
| Приглашение | invite-participant | ростер → Invited | targeted ConferenceInvite → user:{UserId} | ConferenceParticipantInvitedEvent |
| Отклонение | decline-conference | ростер Invited → Declined | MediaParticipant(Left) | ConferenceParticipantDeclinedEvent |
| Выход | leave-conference | ростер Joined → Left (SelfLeft) | MediaParticipant(Left) | ConferenceParticipantLeftEvent |
| Удаление | remove-participant | ростер Joined → Left (RemovedByModerator) | MediaParticipant(Left) + eject | ConferenceParticipantLeftEvent |
| Presence | jitsi-events | ростер upsert / запись Pending → Recording | MediaParticipant/ScreenShareState/RecordingState | — (авторитет ростера, INV-CPART-5) |
| Завершение модератором | end-conference | Active → Ended (ModeratorEnded) | ConferenceSignal(Ended) | ConferenceEndedEvent + conference-ended.v1 |
| Авто-завершение | conference-room-reaper | Active → Ended / Provisioning → Failed | ConferenceSignal(Ended) | ConferenceEndedEvent (+ conference-ended.v1 кроме Provisioning → Failed) |
| Закрытие чата | (Deal-чат → Closed, INV-CHAT-8) | Active → Ended (ChatClosed) | ConferenceSignal(Ended) | ConferenceEndedEvent + conference-ended.v1 |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ConferenceSessionEntity — групповой звонок в чате сделки, Entity: CallParticipantEntity (owned-child ConferenceSessionEntity)
- API (3): POST /api/messenger/conferences/{conferenceId}/decline — Отклонить приглашение в конференцию, POST /api/messenger/conferences/{conferenceId}/invite — Пригласить участников в конференцию, POST /api/messenger/conferences/{conferenceId}/participants/{userId}/remove — Удалить участника из конференции
- calls (1): Медиа-звонки и запись на Jitsi — интеграционный гид (messenger)
Связанные документы
- Сущности: call-session.md (1:1,
CallStatus/CallEndReason,INV-CALL-*), conference-session.md (ConferenceStatus/ConferenceEndReason,INV-CONF-*), call-participant.md (ростер,State/ConferenceRole/LeaveReason,INV-CPART-*). - API 1:1: start-call, answer-call, decline-call, end-call, media-token.
- API конференции: start-conference, join-conference, invite-participant, decline-conference, remove-participant, leave-conference, end-conference, get-conference, conference-media-token.
- Внутренние вебхуки media-стека: jitsi-reservation (admission), jitsi-events (presence — авторитет ростера).
- Realtime-контракт (
MediaJoinModel,CallSignal/ConferenceSignal/MediaParticipant): messenger-hub.md. - Фоновые воркеры: call-timeout-reaper, conference-room-reaper.
- События: conference-ended.md, call-recording-available.md.
- Интеграция media-стека (Jitsi): README, jitsi-rooms.md.
- Решение: ADR-0062 медиа-стек и запись, ADR-0055 свой мини-мессенджер, ADR-0056 simplicity-first.