POST /api/messenger/calls — Начать звонок
Инициатор создаёт сессию 1:1-звонка контакту, получает дескриптор входа в медиа-комнату и в реальном времени
доставляет callee приглашение. Создаётся CallSessionEntity в статусе
Ringing (SessionKind = Direct) с медиа-комнатой (Room, owned VO MediaRoom); звонок разрешён только
между Accepted-контактами вне чёрного списка (гейт CommunicationPolicy.CanCall, INV-CALL-1). Дом полей,
статусы и инварианты — call-session.md.
Media-стек — внешний SFU (ADR-0062). Медиа (звук/видео) идёт клиент ↔ SFU; бэкенд не в медиа-плоскости и не релеит SDP/ICE. Вход в комнату раздаётся дескриптором
MediaJoinModel(комната + короткоживущий токен доступа); токены/SDP/ICE не персистятся (INV-CALL-4/6). Медиа-звонки за флагомCALLS_MEDIA_ENABLED; иначе —MSG_CALLS_DISABLED.
Назначение
Дать пользователю (инвестор / агент / менеджер) позвонить подтверждённому контакту: завести журнал-запись
звонка, зарезервировать медиа-комнату и вернуть инициатору токен входа, а callee — доставить приглашение в
реальном времени. При звонке в контексте сделки запись стартует автоматически по политике. Owning-сервис —
messenger, feature group calls, сценарий «звонок поверх контактов».
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | messenger |
| API area | calls |
| Feature group | calls |
| Статус | draft |
| Документ | docs/06-services/messenger/api/calls/start-call.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User — инициатор звонка (CallerUserId = actor.UserId) |
| Auth policy / Permissions | permission messenger.calls.use |
| Scope (RBAC) | платформа (звонок person-level, вне организаций) |
| Record-level | доступ по членству пары: CallerUserId = actor; CalleeUserId — существующий Accepted-контакт |
| Tenant isolation | не применяется — мессенджер платформенный (ADR-0052); изоляция — по Caller/Callee + гейт контакта |
| Audit | create: CallSessionEntity (Ringing), метка StartedAt; иммутабельный факт лога |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/messenger/calls |
| Success status | 201 (Created) |
| Idempotency header | не применяется — повтор ловит guard активного звонка (INV-CALL-5): второй Ringing/Answered на пару → MSG_CALL_ALREADY_ACTIVE |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
CallerUserId | identity claims | Guid | Да | из JWT; = CallSession.CallerUserId | — |
CalleeUserId | body | Guid | Да | != Empty, != CallerUserId; существующий Accepted-контакт (шаги 3–4) | — |
MediaType | body | CallMediaType | Да | enum строкой Audio/Video (call-session.md); unknown → ошибка валидации | — |
Тело запроса — только { CalleeUserId, MediaType }. SDP/ICE в контракте нет: медиа-негоциация идёт клиент ↔
SFU вне REST и вне БД (INV-CALL-4); вход в комнату инициатор получает дескриптором MediaJoinModel в ответе.
Модель ответа CallSessionModel
Проекция CallSessionEntity (дом полей — сущность; здесь состав строки).
Дельта к журналу: несёт Room — дескриптор входа инициатора в медиа-комнату (только в start/answer/media-token,
никогда в списке/логе).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
CallId | Guid | Да | CallSessionEntity.Id | — | Идентификатор сессии (адресация answer/decline/end/media-token). |
SessionKind | SessionKind | Да | computed | — | Всегда Direct для 1:1-звонка. |
CallerUserId | Guid | Да | CallSessionEntity.CallerUserId | — | Инициатор. |
CalleeUserId | Guid | Да | CallSessionEntity.CalleeUserId | — | Вызываемый. |
ContactId | Guid | Да | CallSessionEntity.ContactId | — | Контакт-разрешение пары (contact.md). |
MediaType | CallMediaType | Да | CallSessionEntity.MediaType | — | Audio/Video. |
Status | CallStatus | Да | CallSessionEntity.Status | — | Всегда Ringing на успехе создания. |
StartedAt | DateTimeOffset | Да | CallSessionEntity.StartedAt | — | Момент StartCall. |
RecordingStatus | RecordingStatus? | Нет | CallRecordingEntity.Status | — | Pending при авто-записи; null если запись не стартовала. |
RecordingId | Guid? | Нет | CallRecordingEntity.Id | — | Ссылка на запись; null без записи. |
Room | MediaJoinModel? | Нет | computed | AccessToken/Credential — Sensitive | Дескриптор входа инициатора в комнату SFU (messenger-hub.md). Только в start/answer/media-token. |
AnsweredAt/EndedAt/EndReason/DurationSeconds — null на этом шаге. Room.AccessToken (и TURN
Credential) — Sensitive: короткоживущий токен, минтится при формировании ответа, в БД не хранится, в
логи не пишется (INV-CALL-6).
Алгоритм
- Контекст и доступ. Операцию выполняет инициатор звонка (
User),CallerUserId— из JWT; требуется правоmessenger.calls.use(иначе — отказ доступа). Медиа-звонки должны быть включены флагомCALLS_MEDIA_ENABLED; иначе —MSG_CALLS_DISABLED. - Проверка входа.
CalleeUserIdнепустой и!= CallerUserId(иначеMSG_CALL_SELF);MediaType— известное значение enum (Audio/Video), иначе ошибка валидации. - Проверки предусловий. Вызываемый существует и активен — иначе
MSG_CALL_USER_NOT_FOUND. Между инициатором и вызываемым есть контакт-разрешение (contact.md) со статусомAccepted— иначеMSG_CALL_NOT_CONTACT; ни одна сторона не в чёрном списке другой — иначеMSG_CALL_BLOCKED(гейтCommunicationPolicy.CanCall,INV-CALL-1). Активного звонка пары в статусеRinging/Answeredнет — иначеMSG_CALL_ALREADY_ACTIVE(INV-CALL-5). - Резерв комнаты. Резервируется медиа-комната (
Room, owned VOMediaRoom): детерминированноеRoomNameиз id сессии + nonce (уникально среди активных,INV-CALL-6) иDomainмедиа-развёртывания. Если медиа-стек не смог зарезервировать комнату —MSG_RECORDING_PROVISION_FAILED. - Оценка записи. Типизированная политика
RecordingPolicy.ShouldAutoRecord(chatKind, SessionKind.Direct)решает, писать ли звонок. Если да и запись включена (RECORDINGS_ENABLED) — заводитсяCallRecordingEntity(Status = Pending,Trigger = AutoPolicy,InitiatedByUserId = null,INV-REC-3) в контексте сделки; иначе запись не создаётся. Сбой провизии рекордера —MSG_RECORDING_PROVISION_FAILED. - Создание сессии. Заводится
CallSessionEntity:Status = Ringing,SessionKind = Direct, зафиксированыCallerUserId,CalleeUserId,ContactId,MediaType,Room,StartedAt. Гонку двух параллельныхStartCallна пару закрывает партиал-индекс активных звонковix_call_active+ guardINV-CALL-5. - Намерение доставки. В той же транзакции, что и создание сессии, пишется строка
realtime_outboxCallInvited(адресатuser:{CalleeUserId}) — persist-then-push. Токен входа callee (MediaJoinModel) минтится диспетчеромrealtime-dispatchв момент доставки, в outbox/БД не хранится. Интеграционных Kafka-событий 1:1-звонок не публикует. - Результат. Карточка
CallSessionModel(Status = Ringing,Room— токен инициатора,RecordingStatus/RecordingIdпри авто-записи) → HTTP201 Created,Location: /api/messenger/calls/{CallId}.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none — 1:1-звонок Kafka-события не публикует (call-session.md) |
| Realtime push (hub) | CallInvite → user:{CalleeUserId} (комната + токен доступа, токен минтится при доставке, не хранится) |
| External calls | резерв медиа-комнаты в SFU (ADR-0062); при авто-записи — провизия рекордера |
| Cache / projections / search | сессия попадает в partial ix_call_active (guard) и журнал ix_call_caller/ix_call_callee; при записи — ix_recording_active |
| Notifications | offline/пропущенный-звонок push — вне MVP |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | неизвестный MediaType, CalleeUserId = Empty | «Проверьте параметры звонка» | no retry |
MSG_CALL_SELF | 400 | CalleeUserId == CallerUserId | «Нельзя позвонить самому себе» | no retry |
MSG_CALL_USER_NOT_FOUND | 404 | callee не найден или неактивен | «Пользователь недоступен» | no retry |
MSG_CALL_NOT_CONTACT | 403 | пара не в Accepted-контакте (INV-CALL-1) | «Звонок доступен только подтверждённым контактам» | no retry |
MSG_CALL_BLOCKED | 403 | чёрный список любой стороны (INV-CALL-1) | «Звонок недоступен» | no retry |
MSG_CALL_ALREADY_ACTIVE | 409 | активный Ringing/Answered звонок пары (INV-CALL-5) | «Звонок с этим пользователем уже идёт» | retry после завершения |
MSG_RECORDING_PROVISION_FAILED | 503 | медиа-стек не зарезервировал комнату или рекордер недоступен при авто-записи | «Не удалось подготовить звонок, попробуйте позже» | retry позже |
MSG_CALLS_DISABLED | 503 | CALLS_MEDIA_ENABLED = false | «Звонки временно недоступны» | retry позже |
Совместимость и наблюдаемость
- Новые optional поля ответа (
Room,RecordingStatus,RecordingId,SessionKind) — additive; смена маршрута/семантики статуса — breaking. Kafka-контракта у 1:1-звонков нет, версионируется только REST. - Logs:
call start requested/created/blocked(CallId,CallerUserId,CalleeUserId,MediaType; токен доступа/Credential— никогда в логи); metrics:messenger_call_start_total{result}, latency p95; traces — сквозной trace (traceparent); dashboardpin-messenger.
Acceptance Criteria
- Happy path. Given
Accepted-контакт вне чёрного списка,CALLS_MEDIA_ENABLED, нет активного звонка пары; WhenPOST {calleeUserId, mediaType}; Then201, сессияRinging/Direct,Room— токен инициатора, строкаCallInvitedвrealtime_outboxадресована callee,StartedAtпроставлен. - Авто-запись в сделке. Given
RecordingPolicy.ShouldAutoRecord = trueиRECORDINGS_ENABLED; WhenPOST; Then заводитсяCallRecordingEntityPending(Trigger = AutoPolicy,InitiatedByUserId = null,INV-REC-3),RecordingStatus = Pendingв ответе; вне сделки/политики — записи нет, поляnull. - Не контакт. Given пара не в
Accepted-контакте; WhenPOST; Then403 MSG_CALL_NOT_CONTACT(INV-CALL-1). - Чёрный список. Given любая сторона в ЧС другой; When
POST; Then403 MSG_CALL_BLOCKED(INV-CALL-1). - Нельзя себе. Given
CalleeUserId == CallerUserId; WhenPOST; Then400 MSG_CALL_SELF. - Callee недоступен. Given callee не найден/неактивен; When
POST; Then404 MSG_CALL_USER_NOT_FOUND. - Активный звонок / гонка. Given уже есть
Ringing/Answeredзвонок пары; WhenPOST; Then409 MSG_CALL_ALREADY_ACTIVE(INV-CALL-5); две параллельныеStartCall→ ровно одинRinging(ix_call_active), второй → тот же код. - Медиа/рекордер недоступны. Given SFU не зарезервировал комнату или рекордер недоступен при авто-записи;
When
POST; Then503 MSG_RECORDING_PROVISION_FAILED(retry позже). - Флаг выключен. Given
CALLS_MEDIA_ENABLED = false; WhenPOST; Then503 MSG_CALLS_DISABLED. - Sensitive. На любом исходе
Room.AccessToken/TURNCredentialне персистятся и не пишутся в логи (INV-CALL-6); токен callee минтится в момент доставки, а не при создании.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (6): GET /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 — Обновить токен входа в звонок, SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)
- Use Cases (1): UC-MSG-005. Сигналинг аудио/видео-звонка
- calls (4): Жизненный цикл сессии звонка — 1:1 и конференция (messenger), Медиа-звонки и запись на Jitsi — интеграционный гид (messenger), Backend-реализация медиа-звонков: спека → код (messenger), Jitsi-звонки: план верификации и приёмки
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность (дом полей, статусы,
INV-CALL-1/4/5/6,MediaRoom, методStart): call-session.md; запись: call-recording.md; гейт контакта: contact.md; чёрный список: blacklist.md; реплика пользователей: messenger-user.md. - Дескриптор входа
MediaJoinModel: messenger-hub.md. - Соседние операции: answer-call.md, decline-call.md, end-call.md, list-calls.md, media-token.md; контекст: README.
- ADR: 0055 свой мини-мессенджер, 0062 медиа-стек и запись, 0052 tenancy; SDK realtime: 10-signalr-realtime.md.