POST /internal/jitsi/events — Presence-вебхук media-стека (авторитет ростера)
Граница интеграции media-стека: небольшой кастомный prosody-модуль шлёт messenger server-side события
присутствия (создание/уничтожение комнаты, вход/выход участника, смена состояния записи). Это авторитетный
источник активного ростера (call-participant.md, INV-CPART-5): серверные
presence-события надёжнее клиентских и разрешают расхождения с командами join/leave.
Граница именования. Внутренний вебхук от media-стека — имена Jitsi/prosody/jibri предметны.
Наружу media-стек нейтрален (SFU, ADR-0062).
Назначение
messenger принимает поток presence-событий MUC-комнаты и приводит к ним свою модель: обновляет ростер
конференции (вход/выход/демонстрация экрана), переводит запись Pending → Recording при подтверждённом старте
захвата и обрабатывает уничтожение комнаты так же, как DELETE резервирования. Каждое принятое событие
рождает соответствующий realtime-push участникам. События идемпотентны и упорядочиваются по монотонному
OccurredAt.
| Поле | Значение |
|---|
| Сервис/контекст | messenger |
| API area | internal (вебхук media-стека) |
| Feature group | media (jitsi-интеграция) |
| Статус | draft |
| Документ | docs/06-services/messenger/api/internal/jitsi-events.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | ExternalSystem — кастомный prosody presence-модуль |
| Auth policy / Permissions | подписанный вебхук: X-Pin-Signature = HMAC над сырым телом + X-Pin-Timestamp, окно replay ≤ 5 мин, общий секрет; либо mTLS |
| Scope (RBAC) | не применяется (доверенная граница инфраструктуры) |
| Record-level | сессия резолвится по RoomName = Room.RoomName; несовпадение → deny (fail-closed) |
| Tenant isolation | не применяется; резолв по имени комнаты, не по TenantId |
| Audit | update: ростер (Joined/Left), запись (→ Recording) — иммутабельные фазовые метки/soft-leave |
| Trusted boundary | вход подписан/mTLS; полная валидация подписи → совпадения имени → формы события |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/messenger/internal/jitsi/events |
| Success status | 200 (тело JitsiEventAckModel) |
| Idempotency | ключ (RoomName, EventType, OccupantJid, OccurredAt); повтор — no-op ack; порядок — по монотонному OccurredAt |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса — JitsiMucEvent
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
X-Pin-Signature | header | string | Да | HMAC над сырым телом; несовпадение → MSG_JITSI_SIGNATURE_INVALID | секрет не логируется |
X-Pin-Timestamp | header | DateTimeOffset | Да | окно replay ≤ 5 мин | — |
EventType | body | JitsiMucEventType | Да | RoomCreated/RoomDestroyed/OccupantJoined/OccupantLeft/RecordingStatusChanged | — |
RoomName | body | string | Да | сверка с Room.RoomName сессии; несовпадение → MSG_JITSI_ROOM_UNKNOWN | маршрутный id |
OccupantUserId | body | Guid? | Нет | из token-контекста context.user.id; обязателен для OccupantJoined/OccupantLeft | логическая ссылка identity.users |
OccupantJid | body | string? | Нет | JID участника; часть ключа идемпотентности | не PII (эфемерный jid) |
Role | body | string? | Нет | роль участника в MUC (moderator/participant) | — |
OccurredAt | body | DateTimeOffset | Да | момент события у media-стека; порядок применения | — |
RecordingState | body | string? | Нет | для RecordingStatusChanged (on/off) | — |
Enum JitsiMucEventType
| Значение | Описание |
|---|
RoomCreated | Комната поднята (после allow резервирования). |
RoomDestroyed | Комната уничтожена (эквивалент DELETE резервирования). |
OccupantJoined | Участник вошёл в комнату. |
OccupantLeft | Участник вышел/отключился. |
RecordingStatusChanged | Изменилось состояние захвата (RecordingState = on/off). |
Модель ответа JitsiEventAckModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
Accepted | bool | Да | computed | — | Событие принято (или идемпотентно проигнорировано как повтор/устаревшее). |
Алгоритм
- Контекст и доступ. Вызывает prosody presence-модуль (
ExternalSystem). Проверяется подпись
X-Pin-Signature над сырым телом + свежесть X-Pin-Timestamp; невалидно → MSG_JITSI_SIGNATURE_INVALID.
- Резолв и сверка комнаты. По
RoomName резолвится сессия-владелец (конференция —
conference-session.md; 1:1 — call-session.md);
RoomName должен точно совпасть с Room.RoomName, иначе → MSG_JITSI_ROOM_UNKNOWN (fail-closed).
- Идемпотентность и порядок. Ключ дедупликации —
(RoomName, EventType, OccupantJid, OccurredAt);
уже виденное или более старое по OccurredAt, чем текущее состояние строки, — принимается как no-op
(Accepted = true), состояние не откатывается.
OccupantJoined/OccupantLeft → ростер. Для конференции upsert-ится CallParticipantEntity
(авторитетный ростер, INV-CPART-1/5): Joined — реактивация/создание строки с JoinedAt; Left —
soft-leave (LeftAt, LeaveReason, DurationSeconds). Пересчитываются ActiveParticipantCount/
PeakParticipantCount, обновляется LastActivityAt. Каждый переход рождает push MediaParticipant
(Joined/Left) в hub-группу chat:{ChatId} (конференция) либо user:{peer} (1:1). Если событие несёт
videoType = desktop — обновляется ScreenShareState (Sharing/None) с push ScreenShareState.
RecordingStatusChanged(on) → запись. Активная запись сессии переводится Pending → Recording
(call-recording.md, INV-REC-2/4: NoticeState уже Announced),
StartedAt фиксируется; push RecordingState(Started). off — фиксируется как остановка захвата, финал
записи приходит из jibri-recording-ingest.md.
RoomDestroyed → опустошение. Обрабатывается как DELETE резервирования
(jitsi-reservation.md, шаг 7): активный ростер помечается вышедшим, LastActivityAt
обновляется, финальное завершение сессии отдаётся conference-room-reaper (без форс-terminate).
RoomCreated — отметка presence-активности (сверка с admission), состав не меняет.
- Согласованность и результат. Обновление ростера/записи и запись намерения push фиксируются в одном
согласованном изменении (persist-then-push); диспетчер
realtime-dispatch доставляет push после фиксации.
Ответ — JitsiEventAckModel { Accepted } → 200 OK.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Realtime push (hub) | MediaParticipant(Joined/Left) → chat:{ChatId} (конференция) / user:{peer} (1:1); ScreenShareState при videoType=desktop; RecordingState(Started) при on |
| Integration events | none напрямую; завершение сессии (через reaper) публикует pin.messenger.conference-ended.v1 из своего пути |
| External calls | none (входящий вебхук) |
| Cache / projections | партиал ix_call_participants_active, ix_conf_active, ix_conf_last_activity |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
MSG_JITSI_SIGNATURE_INVALID | 401 | подпись не сошлась / timestamp вне окна replay | «Подпись запроса недействительна» | no retry (переподписать) |
MSG_JITSI_ROOM_UNKNOWN | 403 | RoomName не резолвится / не совпадает с Room.RoomName | «Комната неизвестна» | no retry |
VALIDATION_FAILED | 400 | неизвестный EventType, пустой RoomName, отсутствует OccupantUserId для join/leave | «Проверьте параметры запроса» | no retry |
Устаревшее/повторное событие (OccurredAt не новее состояния) — не ошибка: 200 {Accepted:true} как no-op.
Acceptance Criteria
- Вход участника. Дано провизированная конференция и валидная подпись. Когда
OccupantJoined. Тогда
CallParticipantEntity upsert-ится Joined (JoinedAt), пересчитываются ActiveParticipantCount/
PeakParticipantCount и LastActivityAt, push MediaParticipant(Joined) в chat:{ChatId}, 200 {Accepted:true}.
- Выход участника. Дано участник в ростере. Когда
OccupantLeft. Тогда soft-leave (LeftAt,
LeaveReason, DurationSeconds), ActiveParticipantCount--, push MediaParticipant(Left).
- Демонстрация экрана. Дано событие с
videoType = desktop. Когда обработано. Тогда ScreenShareState
(Sharing/None) обновлён, push ScreenShareState.
- Старт записи. Дано активная запись сессии в
Pending (NoticeState = Announced, INV-REC-4). Когда
RecordingStatusChanged(on). Тогда запись Pending → Recording (StartedAt), push RecordingState(Started);
финал записи придёт отдельным вебхуком финализации.
- Устаревшее/повторное событие. Дано
OccurredAt не новее текущего состояния строки (или ключ уже
виден). Когда событие приходит. Тогда no-op, 200 {Accepted:true}, состояние не откатывается (авторитет
ростера, INV-CPART-5).
- Уничтожение комнаты. Дано
RoomDestroyed. Когда обработано. Тогда активный ростер помечается вышедшим,
LastActivityAt обновляется, финальное завершение сессии отдаётся conference-room-reaper (без
форс-terminate).
- Неверная подпись. Дано подпись не сошлась / timestamp вне окна. Когда событие. Тогда
MSG_JITSI_SIGNATURE_INVALID (401).
- Неизвестная комната. Дано
RoomName не совпал с Room.RoomName сессии. Когда событие. Тогда
MSG_JITSI_ROOM_UNKNOWN (403, fail-closed).
- Неполный payload. Дано
OccupantJoined/OccupantLeft без OccupantUserId (или неизвестный
EventType). Когда событие. Тогда VALIDATION_FAILED (400).
Совместимость и наблюдаемость
- Новые значения
JitsiMucEventType и optional-поля payload — additive (неизвестный EventType → VALIDATION_FAILED,
presence-модуль не шлёт неподдержанное); смена ключа идемпотентности/семантики — breaking (rollout модуля).
- Logs:
presence event (RoomName, EventType, OccurredAt, result), дедуп-пропуски — без токенов/PII;
metrics: messenger_jitsi_event_total{type,result}, roster drift (расхождение presence vs команды);
traces — сквозной traceparent; alert на всплеск room-unknown.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы