SignalR Hub /hubs/messenger — MessengerHub (realtime-контракт)
MessengerHub — единственная realtime-точка контекста messenger (ADR-0055 — собственный мини-мессенджер
PIN). Хаб — транспорт доставки, не источник истины: любое сохранение (сообщение, правка, read-cursor,
контакт, звонок) идёт через REST-операции с транзакционной публикацией, и только после фиксации фоновый
диспетчер realtime-dispatch пушит проекцию в хаб (persist-then-push, chat.md
INV-CHAT-5, participant.md). При переподключении клиент добирает состояние по
REST — потерянный push не критичен.
Назначение
Двунаправленный realtime-канал портала инвестора/агента и админ-панели. Направление сервер → клиент —
доставка новых сообщений, правок/удалений, read-receipt, изменений состава/статуса чата, заявок в контакты и
сигналинга звонков/конференций: приглашения (комната + токен доступа), lifecycle, вход/выход участников,
состояние записи и демонстрации экрана. Направление клиент → сервер — только эфемерные операции
(подписка на группу чата, typing). Медиа (звук/видео/демонстрация экрана) идёт клиент ↔ SFU (ADR-0062) —
бэкенд не релеит SDP/ICE. Этот контракт — то, ради чего фронт подключается к хабу: он заменяет устаревший
GET /me/chat-token (jid/websocketUrl/mucService, ADR-0055).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | messenger |
| API area | realtime (SignalR hub) |
| Feature group | hub |
| Статус | draft |
| Документ | docs/06-services/messenger/api/hub/messenger-hub.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (клиент подключения); System (realtime-dispatch — push сервер → клиент) |
| Auth policy / Permissions | JWT bearer (KeyCloak); connect требует messenger.chats.view; per-операция права — на REST-операциях (.write/.calls.use/.conferences.use/.recordings.view/.contacts.manage) |
| Scope (RBAC) | не применяется (доступ — по членству, не по scope) |
| Record-level | активный ParticipantEntity (RemovedAt is null) — гейт группы chat:{chatId}; звонок 1:1 — пара Caller/Callee; конференция — членство в Deal-чате |
| Tenant isolation | нет (кросс-tenant, chat.md): изоляция по членству/паре |
| Audit | хаб не сохраняет данные; аудит — на REST-операциях и ChatEvent/MessageEdit |
Hub-контракт
| Поле | Значение |
|---|---|
| Endpoint | /hubs/messenger |
| Транспорт | WebSockets (fallback SSE/LongPolling); JWT — заголовок Authorization: Bearer либо ?access_token= |
| Аутентификация | JWT-контекст соединения при подключении; при отсутствии валидного контекста — соединение закрывается |
| Группы | user:{UserId} — авто-подписка при подключении (адресные пуши: контакты, звонки, доставка «во все чаты»); chat:{ChatId} — подписка по JoinChat после проверки членства |
| Backplane | Redis (REALTIME_BACKPLANE_ENABLED) — опционально на 1 инстансе (ADR-0056), обязателен при multi-replica (ADR-0043) |
| Лимиты | размер сообщения ≤ 32 КБ, keep-alive 15 с, тайм-аут клиента 30 с; rate-limit relay ≤ 10 msg/с на пользователя |
Хаб не принимает отправку/правку сообщений: они идут по REST (send-text-message, edit-message, …).
Серверные методы хаба — только эфемерные, без записи в хранилище.
Серверные методы хаба (клиент → сервер)
| Метод | Назначение | Гейт |
|---|---|---|
JoinChat(chatId) | подписать соединение на группу chat:{chatId} (при открытии чата) | активное членство актора в чате; иначе — ошибка MSG_CHAT_ACCESS_DENIED |
LeaveChat(chatId) | отписать соединение от группы chat:{chatId} | — |
Typing(chatId, isTyping) | эфемерный typing-индикатор в chat:{chatId} (кроме автора) | активное членство (как JoinChat); не сохраняется |
Метод SendCallSignal (relay SDP/ICE) удалён: с нативным клиентом и SFU (ADR-0062) медиа-негоциация идёт
клиент ↔ SFU, хаб её не переносит. Вход в звонок/конференцию раздаётся приглашением (CallInvite/
ConferenceInvite с комнатой и токеном), обновляется методом refresh-call-media-token по REST.
Входные данные / параметры подключения
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
access_token | query / header | JWT | Да | подпись KeyCloak, messenger.chats.view | не логируется |
chatId | JoinChat/LeaveChat/Typing | Guid | Да | существует + активное членство актора | — |
Клиентские события (сервер → клиент)
Имя метода = имя события на проводе; каждое несёт типизированный payload.
| Событие | Payload | Когда |
|---|---|---|
ReceiveMessage | RealtimeMessage | новое сообщение (домен: MessagePosted) |
MessageEdited | RealtimeMessage | правка в окне ≤ 60 с |
MessageDeleted | RealtimeMessageDeleted | мягкое удаление в окне |
ChatRead | RealtimeReadReceipt | read-cursor продвинут (домен: ReadStateChanged) |
ChatUpdated | RealtimeChatUpdate | состав/переименование/статус (ParticipantAdded/Removed/RoleChanged) |
ContactRequest | RealtimeContactRequest | заявка/подтверждение/отклонение/удаление контакта |
CallInvite | RealtimeCallInvite | приглашение в 1:1-звонок (комната + токен доступа) → user:{Callee} |
CallSignal | RealtimeCallSignal | lifecycle 1:1-звонка (Answered/Declined/Cancelled/Ended/Missed), без медиа |
ConferenceInvite | RealtimeConferenceInvite | приглашение в конференцию (комната + токен) → broadcast в chat:{ChatId} при старте; адресно в user:{UserId} при invite-participant |
ConferenceSignal | RealtimeConferenceSignal | lifecycle конференции (Started/Ended, смена модератора) |
MediaParticipant | RealtimeMediaParticipant | вход/выход участника (1:1 и конференция) |
RecordingState | RealtimeRecordingState | состояние записи (Started/Stopped/Available/Failed) |
ScreenShareState | RealtimeScreenShare | старт/стоп демонстрации экрана участником |
Typing | RealtimeTyping | эфемерный typing |
Модель payload RealtimeMessage (для ReceiveMessage/MessageEdited)
Проекция сообщения адресована участникам чата (авторизованы членством) → несёт тело (в отличие от
интеграционного события pin.messenger.message-posted.v1, где тела нет). Sensitive-поля IpAddress/
UserAgent исключены (message.md INV-MSG-6). Строгая типизация: под-модели по
Kind.
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
ChatId | Guid | Да | Чат. |
MessageId | Guid | Да | Сообщение. |
Seq | long | Да | Per-chat монотонный порядковый; ключ дедупликации/порядка. |
Kind | MessageKind | Да | Text / Attachment / EventCard. |
SenderUserId | Guid? | Нет | null — система/EventCard. |
SenderRole | ParticipantRole? | Нет | Роль автора на момент отправки. |
IsSystem | bool | Да | true — системное/event-card. |
SentAt | DateTimeOffset | Да | Момент отправки. |
EditedAt | DateTimeOffset? | Нет | Момент правки. |
Text | RealtimeTextContent? | Нет | Для Kind == Text: { Body: string }. |
Attachment | RealtimeAttachmentContent? | Нет | Для Kind == Attachment. |
EventCard | RealtimeEventCardContent? | Нет | Для Kind == EventCard (per-participant текст уже применён). |
Под-модель RealtimeAttachmentContent: { FileId: Guid, FileName: string, ContentType: string, SizeBytes: long, AttachmentKind: AttachmentType, Caption: string? }. Под-модель RealtimeEventCardContent:
{ CardType: EventCardType, Category: EventCardCategory, ActionStatus: EventCardActionStatus, Text: string (= TextOverride ?? DefaultText, INV-MSG-5), DealId: Guid? }.
Модель MediaJoinModel (дескриптор входа в медиа-комнату)
Раздаётся в приглашениях (CallInvite/ConferenceInvite) и возвращается REST-методами start/answer/join/
media-token (../calls/start-call.md и смежные). Несёт всё, что нужно нативному
клиенту, чтобы войти в комнату SFU. AccessToken — Sensitive: короткоживущий токен доступа, минтится
в момент доставки (realtime-dispatch), в realtime_outbox/БД не хранится, в логи не пишется.
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
RoomName | string | Да | Имя медиа-комнаты (MediaRoom.RoomName); маршрутный id, не медиа. |
AccessToken | string | Да | Токен доступа в комнату (Sensitive, короткий TTL). |
SignalingUrl | string | Да | Endpoint сигналинга media-стека (secure websocket). |
MediaDomain | string | Да | Домен/поддомен медиа-развёртывания (MediaRoom.Domain). |
IceServers | MediaIceServerEntry[] | Да | STUN/TURN для обхода NAT (SFU/coturn). |
ExpiresAt | DateTimeOffset | Да | Истечение AccessToken; клиент обновляет заранее (media-token). |
Под-модель MediaIceServerEntry: { Urls: string[], Username: string?, Credential: string? } (Credential —
короткоживущий TURN-креденшл, Sensitive, не логируется).
Прочие payload-модели
| Модель | Поля (v1, строго типизированные) | Событие |
|---|---|---|
RealtimeMessageDeleted | { ChatId: Guid, MessageId: Guid, Seq: long, DeletedAt: DateTimeOffset } | MessageDeleted |
RealtimeReadReceipt | { ChatId: Guid, UserId: Guid, LastReadSeq: long, LastReadAt: DateTimeOffset } | ChatRead |
RealtimeChatUpdate | { ChatId: Guid, Kind: ChatUpdateKind, UserId: Guid?, Role: ParticipantRole?, Title: string?, Status: ChatStatus? } | ChatUpdated |
RealtimeContactRequest | { ContactId: Guid, Kind: ContactEventKind, RequesterUserId: Guid, AddresseeUserId: Guid, RequestMessage: string?, OccurredAt: DateTimeOffset } | ContactRequest |
RealtimeCallInvite | { CallId: Guid, MediaType: CallMediaType, CallerUserId: Guid, Room: MediaJoinModel } | CallInvite |
RealtimeCallSignal | { CallId: Guid, Kind: CallSignalKind, FromUserId: Guid?, ToUserId: Guid?, EndReason: CallEndReason?, DurationSeconds: int? } | CallSignal |
RealtimeConferenceInvite | { ConferenceId: Guid, ChatId: Guid, DealId: Guid?, MediaType: CallMediaType, StartedByUserId: Guid, Title: string?, Room: MediaJoinModel } | ConferenceInvite |
RealtimeConferenceSignal | { ConferenceId: Guid, ChatId: Guid, Kind: ConferenceSignalKind, Status: ConferenceStatus?, ActiveParticipantCount: int? } | ConferenceSignal |
RealtimeMediaParticipant | { SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, UserId: Guid, Kind: ParticipantEventKind, Role: ParticipantRole?, OccurredAt: DateTimeOffset } | MediaParticipant |
RealtimeRecordingState | { SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, RecordingId: Guid, Kind: RecordingEventKind, FileId: Guid?, DurationSeconds: int?, OccurredAt: DateTimeOffset } | RecordingState |
RealtimeScreenShare | { SessionKind: SessionKind, SessionId: Guid, ChatId: Guid?, UserId: Guid, Kind: ScreenShareEventKind, OccurredAt: DateTimeOffset } | ScreenShareState |
RealtimeTyping | { ChatId: Guid, UserId: Guid, IsTyping: bool } | Typing |
RealtimeRecordingState несёт FileId только при Kind == Available и не несёт ссылку/URL — клиент
получает ссылку на скачивание отдельным методом (../recordings/get-recording-download.md).
Enum'ы контракта: ChatUpdateKind { MemberAdded, MemberRemoved, RoleChanged, Renamed, StatusChanged },
ContactEventKind { Received, Accepted, Declined, Removed },
CallSignalKind { Invite, RoomReady, Answered, Declined, Cancelled, Hangup, Ended, Missed } (все — без медиа;
вход раздаётся CallInvite с MediaJoinModel, а не relay-ом SDP),
ConferenceSignalKind { Invited, Started, Ended }, ParticipantEventKind { Joined, Left },
RecordingEventKind { Started, Stopped, Available, Failed }, ScreenShareEventKind { Started, Stopped }.
Домен-enum'ы (MessageKind, ParticipantRole, AttachmentType, EventCard*, ChatStatus, CallMediaType,
CallEndReason, SessionKind, ConferenceStatus) — из domain-доков, не дублируются.
Алгоритм
Канонический путь «отправка → доставка» (persist-then-push); эфемерные server-методы — вне хранилища.
- Приём и контекст. Клиент вызывает REST
POST /chats/{chatId}/messages(JWT → актор/trace); hub-соединение уже подписано на группыuser:{actor}(при подключении) иchat:{chatId}(JoinChat). - Проверка. REST-операция проверяет: активное членство (INV-CHAT-4), коммуникационную политику (актор не
в чёрном списке),
Kind != EventCard, длину тела1..8000, чат неClosed. - Что читаем. Загружается чат с активными участниками; для карточек — идемпотентная обработка события в
консюмере
crmпоSourceEventId. - Бизнес-правила и переход. Чат выдаёт
Seq(per-chat монотонный); из агрегата собирается проекцияRealtimeMessage(без IP/UA; текст EventCard =TextOverride ?? DefaultText). - Что меняется. Появляется сообщение, чат обновляет денормализацию (последнее сообщение,
LastMessageSeq); одновременно записывается намерение доставки push. - Факты и события. Доменный факт
MessagePostedи интеграционное событиеMessagePostedEvent(pin.messenger.message-posted.v1) публикуются атомарно с сохранением сообщения. - Согласованность. Сообщение и намерение push фиксируются в одном согласованном изменении до любого push (persist-then-push).
- Доставка. REST отвечает
201сRealtimeMessage; диспетчерrealtime-dispatchчитает зафиксированный факт и вызываетReceiveMessage(RealtimeMessage)в группеchat:{chatId}. Потеря push при разрыве → клиент добираетlist-messagesпоSeq.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Realtime push | клиентские события в группы chat:{ChatId} (сообщения/read/typing/состав/конференции/запись) и user:{UserId} (контакты/1:1-звонки) |
| Токены доступа | MediaJoinModel.AccessToken/TURN-креды минтятся при доставке инвайта (realtime-dispatch), не хранятся в realtime_outbox/БД, не логируются |
| Интеграционные события | доставляются из транзакционной публикации: pin.messenger.message-posted.v1, …contact-request-created/accepted.v1, …deal-chat-status-changed.v1, …conference-ended.v1, …call-recording-available.v1 (хаб их не публикует — только доставляет проекции) |
| Внешние вызовы | нет (медиа-негоциация SDP/ICE идёт клиент ↔ SFU, вне хаба и БД, INV-CALL-4) |
| Проекции | Redis backplane (при multi-replica) — транспорт групп; не кэш данных |
| Уведомления | offline push/email — notifications (консюмер Kafka-событий); хаб — только in-app realtime |
Ошибки и failure modes
| Код | Канал | Условие | Сообщение | Retry |
|---|---|---|---|---|
MSG_CHAT_ACCESS_DENIED | ошибка хаба (JoinChat/Typing) | нет активного участия | «Нет доступа к чату» | нет |
Unauthorized / abort | connect | JWT невалиден / нет messenger.chats.view | close 401 | reconnect после refresh |
| потеря push | транспорт | разрыв / отсутствие backplane при multi-replica | — (не источник истины) | клиент добирает REST по Seq |
| rate-limit | relay | > 10 msg/с на пользователя | throttle | клиент снижает частоту |
Acceptance Criteria
- Подключение с правами. Дано валидный JWT с
messenger.chats.view. Когда клиент открывает соединение. Тогда соединение установлено и авто-подписано на группуuser:{UserId}(адресные пуши контактов/1:1-звонков). - Подключение без прав. Дано невалидный JWT или отсутствует
messenger.chats.view. Когдаconnect. Тогда соединение закрывается (401/abort); клиент переподключается после refresh токена. - Подписка на чат. Дано активное членство актора в чате. Когда
JoinChat(chatId). Тогда соединение подписано наchat:{chatId}; при отсутствии активного участия — ошибка хабаMSG_CHAT_ACCESS_DENIED. - Доставка нового сообщения (persist-then-push). Дано участник подписан на
chat:{chatId}. Когда другой участник отправил сообщение REST-операцией. Тогда только после фиксации диспетчерrealtime-dispatchвызываетReceiveMessage(RealtimeMessage)в группе; проекция несёт тело, но неIpAddress/UserAgent. - Потеря push при разрыве. Дано соединение разорвано в момент доставки. Когда push потерян. Тогда это не
критично (хаб не источник истины): при переподключении клиент добирает
list-messagesпоSeq. - Приглашение в 1:1-звонок. Дано инициатор стартовал звонок. Когда звонок раздаётся. Тогда
CallInviteсMediaJoinModelуходит вuser:{Callee};AccessToken/TURN-креды минтятся при доставке, вrealtime_outbox/ БД не хранятся и в логи не пишутся. - Состояние записи без URL. Дано запись сессии стала
Available. Когда pushRecordingState. Тогда payload несётFileId(приKind == Available) и не несёт ссылку — клиент берёт её отдельным методомget-recording-download. - Эфемерный typing. Дано активное членство. Когда
Typing(chatId, true). Тогда остальным участникамchat:{chatId}уходитTyping; в хранилище ничего не сохраняется. - Rate-limit. Дано клиент шлёт эфемерные relay-события чаще 10/с. Когда лимит превышен. Тогда throttle; клиент снижает частоту.
- Медиа вне хаба. Дано идёт медиа-звонок. Когда клиент договаривается о SDP/ICE. Тогда негоциация идёт
клиент ↔ SFU (INV-CALL-4); хаб SDP/ICE не переносит (метод
SendCallSignalудалён).
Совместимость и наблюдаемость
- Новые optional-поля payload и новые клиентские события (
CallInvite/ConferenceInvite/MediaParticipant/RecordingState/ScreenShareState) — не breaking. Удаление методаSendCallSignalи полейSdp/IceCandidate/MediaTypeизRealtimeCallSignal, сжатиеCallSignalKind(убраныOffer/Answer/Ice) — breaking: бамп версии hub-контракта + согласованный rollout фронта (переход на нативный клиент + SFU, ADR-0062). - Метрики: hub connect/disconnect rate, lag/ошибки
realtime-dispatch, backlog доставки; correlation черезtraceparent. PII/тела/IpAddressв логи не пишутся.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (12): POST /api/messenger/calls — Начать звонок, POST /api/messenger/calls/{callId}/answer — Принять звонок, POST /api/messenger/calls/{callId}/media-token — Обновить токен входа в звонок, POST /api/messenger/chats/{chatId}/conferences — Начать конференцию в чате сделки, POST /api/messenger/conferences/{conferenceId}/decline — Отклонить приглашение в конференцию, POST /api/messenger/conferences/{conferenceId}/invite — Пригласить участников в конференцию, POST /api/messenger/conferences/{conferenceId}/join — Присоединиться к конференции, POST /api/messenger/conferences/{conferenceId}/media-token — Обновить токен входа в конференцию, POST /api/messenger/conferences/{conferenceId}/participants/{userId}/remove — Удалить участника из конференции, POST /internal/call-recordings/{sessionId}/ingest — Финализация записи (jibri → file storage), POST /internal/jitsi/events — Presence-вебхук media-стека (авторитет ростера), POST/DELETE /internal/jitsi/reservations — Резервирование медиа-комнаты (prosody admission)
- Use Cases (3): UC-MSG-005. Сигналинг аудио/видео-звонка, UC-MSG-006. Групповой звонок-конференция в чате сделки, UC-MSG-007. Авто-запись звонка по сделке и её выдача участнику
- adr (1): ADR-0062: Медиа-стек звонков и запись на Jitsi (JVB SFU + coturn + jibri → file storage; JWT из messenger; нативный lib-jitsi-meet)
- calls (9): Жизненный цикл сессии звонка — 1:1 и конференция (messenger), Медиа-звонки и запись на Jitsi — интеграционный гид (messenger), Медиа-звонки на Jitsi: план поставки (playbook аналитик → разработчик), Backend-реализация медиа-звонков: спека → код (messenger), Jitsi-звонки: план верификации и приёмки, Jitsi: инвентарь изменений в thirdparty/jitsi, Jitsi: минтинг JWT входа в .NET, Jitsi: нативный клиент lib-jitsi-meet на Next.js 16, Jitsi: k8s-топология и взаимодействие с бэкендом
- registries (1): Реестр API-методов PIN
Связанные документы
- Карта контекста: ../../README.md; домены: chat.md, participant.md, message.md, contact.md, call-session.md, conference-session.md, call-participant.md, call-recording.md.
- Методы, раздающие
MediaJoinModel: ../calls/start-call.md, ../calls/media-token.md, ../conferences/start-conference.md, ../conferences/join-conference.md; скачивание записи — ../recordings/get-recording-download.md. - Событие проекции: ../../events/deal-chat-status-changed.md,
../../events/conference-ended.md, ../../events/call-recording-available.md;
исходящее —
../../events/message-posted.md(будущий путь). - Интеграция media-стека: ../../calls/README.md.
- ADR: 0055 свой мини-мессенджер, 0043 SignalR backplane, 0062 медиа-стек и запись на Jitsi, 0056 simplicity-first.