feat(dm): ручки и события звонков в беседах (Фаза 7)

Звонки 1:1 и в групповых беседах до 10 участников поверх той же голосовой
инфраструктуры LiveKit: те же токены, что у серверных комнат, но без
серверных ролей — позвонить может участник беседы.

Ручки: POST /channels/{id}/call (создание, в ответе токен и адрес
сигналинга), accept, decline, end, PATCH /call/@me для флагов микрофона,
камеры и шаринга, GET /call (текущий звонок) и GET /calls (история: кто
звонил, когда, сколько длился, кто пропустил). Постороннему — 404,
существование чужой беседы не подтверждаем; в беседе больше десяти
участников звонить нельзя (dm.call_limit); выключенный голос отвечает
voice.disabled.

События: DM_CALL_RING приходит приглашённым, DM_CALL_UPDATE — участникам
при смене состояния, DM_CALL_END — с итогом и причиной. Звонок добавлен в
READY и в карточку беседы (active_call), поэтому «идёт звонок» переживает
перезагрузку. Вебхук LiveKit разбирает комнаты dm_call_* и синхронизирует
состояние, сторож голосовых состояний закрывает гудки без ответа и
опустевшие комнаты.

Тесты: жизненный цикл 1:1, отклонение и пропущенный, отказ постороннему
на всех ручках, предел десяти участников, поведение при выключенном
голосе, события Gateway и active_call в READY.
This commit is contained in:
2026-09-26 17:02:55 +03:00
parent 50709396cd
commit 0b55fbdfe2
9 changed files with 1403 additions and 7 deletions
+28
View File
@@ -150,6 +150,34 @@ type ReadyChannel struct {
MemberCount int `json:"member_count,omitempty"`
MemberIDs []string `json:"member_ids,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
// ActiveCall — звонок, идущий в личной беседе (AGENT.md 7.8, Фаза 7):
// список бесед и шапка показывают «идёт звонок» сразу после подключения,
// не дожидаясь события DM_CALL_UPDATE.
ActiveCall *ReadyDMCall `json:"active_call,omitempty"`
}
// ReadyDMCall — звонок в личной беседе в снапшоте. Форма совпадает с
// dmCallPayload REST-ручек и событиями DM_CALL_*, чтобы клиент разбирал
// состояние звонка одним кодом (AGENT.md 8.3).
type ReadyDMCall struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
InitiatorID string `json:"initiator_id"`
Status string `json:"status"`
Media string `json:"media"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at,omitempty"`
Participants []ReadyDMCallParticipant `json:"participants"`
}
// ReadyDMCallParticipant — участник звонка в снапшоте.
type ReadyDMCallParticipant struct {
UserID string `json:"user_id"`
State string `json:"state"`
SelfMute bool `json:"self_mute"`
SelfDeaf bool `json:"self_deaf"`
Camera bool `json:"camera"`
Screen bool `json:"screen"`
}
type ReadyRole struct {