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
+13 -6
View File
@@ -78,6 +78,10 @@ type dmChannelPayload struct {
MemberCount int `json:"member_count,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
// ActiveCall — идущий в беседе звонок (AGENT.md 7.8, Фаза 7): список
// бесед и шапка показывают маркер «идёт звонок» и после перезагрузки, не
// дожидаясь события DM_CALL_UPDATE.
ActiveCall *dmCallPayload `json:"active_call,omitempty"`
}
// dmRecipientPayload — участник групповой беседы.
@@ -404,7 +408,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
Timezone: recipient.Timezone,
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(summary)
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
@@ -427,7 +431,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
output := &dmChannelListOutput{}
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
for _, summary := range channels {
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary))
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(ctx, summary))
}
return output, nil
})
@@ -481,7 +485,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(summary)
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
@@ -544,7 +548,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(summary)
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
@@ -639,7 +643,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
return nil, err
}
output := &dmChannelOutput{}
output.Body.Channel = s.dmChannelPayload(summary)
output.Body.Channel = s.dmChannelPayload(ctx, summary)
return output, nil
})
}
@@ -926,12 +930,15 @@ func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfil
}
// dmChannelPayload собирает личную беседу для API.
func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload {
func (s *Server) dmChannelPayload(ctx context.Context, summary store.DMChannelSummary) dmChannelPayload {
payload := dmChannelPayload{
ID: formatSnowflake(summary.Channel.ID),
Type: string(store.ChannelDM),
CanSend: true,
CanView: true,
// Звонок приходит вместе с беседой: клиенту не нужен отдельный запрос,
// чтобы показать «идёт звонок» в списке и шапке (AGENT.md 7.8).
ActiveCall: s.liveCallPayload(ctx, summary.Channel.ID),
}
if summary.IsGroup {
payload.IsGroup = true