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:
@@ -98,6 +98,12 @@ func (s *Server) markWebhookProcessed(id string) bool {
|
||||
|
||||
// applyLiveKitEvent обновляет голосовые состояния по событию SFU.
|
||||
func (s *Server) applyLiveKitEvent(ctx context.Context, event livekitWebhookEvent) {
|
||||
// Комнаты звонков в беседах живут отдельно от серверных голосовых комнат:
|
||||
// по префиксу имени понятно, куда относить событие (AGENT.md 7.8).
|
||||
if callID, ok := voice.ParseCallRoomName(event.Room.Name); ok {
|
||||
s.applyLiveKitCallEvent(ctx, callID, event)
|
||||
return
|
||||
}
|
||||
guildID, channelID, ok := voice.ParseRoomName(event.Room.Name)
|
||||
if !ok {
|
||||
return
|
||||
@@ -194,6 +200,87 @@ func parseVoiceIdentity(identity string) uint64 {
|
||||
return id
|
||||
}
|
||||
|
||||
// applyLiveKitCallEvent синхронизирует звонок беседы с состоянием комнаты:
|
||||
// вебхук исправляет расхождения после перезапуска приложения и закрывает
|
||||
// звонок, когда в комнате никого не осталось (AGENT.md 7.14).
|
||||
func (s *Server) applyLiveKitCallEvent(ctx context.Context, callID uint64, event livekitWebhookEvent) {
|
||||
call, err := s.store.GetDMCall(ctx, callID)
|
||||
if err != nil || call.Status == store.DMCallEnded {
|
||||
return
|
||||
}
|
||||
userID := parseVoiceIdentity(event.Participant.Identity)
|
||||
switch event.Event {
|
||||
case "participant_joined":
|
||||
if userID == 0 || dmCallParticipant(call, userID) == nil {
|
||||
return
|
||||
}
|
||||
if _, err := s.store.JoinDMCallParticipant(ctx, callID, userID); err != nil {
|
||||
s.logger.WarnContext(ctx, "livekit webhook: join dm call failed", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
if updated, err := s.store.GetDMCall(ctx, callID); err == nil {
|
||||
s.dispatchDMCallUpdate(updated)
|
||||
}
|
||||
|
||||
case "participant_left", "participant_connection_aborted":
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
current := dmCallParticipant(call, userID)
|
||||
if current == nil || current.State != store.DMCallJoined {
|
||||
return
|
||||
}
|
||||
reason := store.DMCallReasonCompleted
|
||||
if call.Status == store.DMCallRinging {
|
||||
reason = store.DMCallReasonMissed
|
||||
}
|
||||
if _, err := s.leaveDMCall(ctx, call, userID, store.DMCallLeft, reason); err != nil {
|
||||
s.logger.DebugContext(ctx, "livekit webhook: leave dm call skipped", slog.Any("error", err))
|
||||
}
|
||||
|
||||
case "room_finished":
|
||||
reason := store.DMCallReasonCompleted
|
||||
if call.Status == store.DMCallRinging {
|
||||
reason = store.DMCallReasonMissed
|
||||
}
|
||||
if err := s.finishDMCall(ctx, call, reason, nil); err != nil {
|
||||
s.logger.WarnContext(ctx, "livekit webhook: finish dm call failed", slog.Any("error", err))
|
||||
}
|
||||
|
||||
case "track_published", "track_unpublished":
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
current := dmCallParticipant(call, userID)
|
||||
if current == nil || current.State != store.DMCallJoined {
|
||||
return
|
||||
}
|
||||
flags := store.DMCallFlags{
|
||||
SelfMute: current.SelfMute,
|
||||
SelfDeaf: current.SelfDeaf,
|
||||
Camera: current.Camera,
|
||||
Screen: current.Screen,
|
||||
}
|
||||
published := event.Event == "track_published"
|
||||
switch strings.ToUpper(event.Track.Source) {
|
||||
case "MICROPHONE":
|
||||
flags.SelfMute = !published
|
||||
case "CAMERA":
|
||||
flags.Camera = published
|
||||
case "SCREEN_SHARE", "SCREEN_SHARE_AUDIO":
|
||||
flags.Screen = published
|
||||
default:
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateDMCallFlags(ctx, callID, userID, flags); err != nil {
|
||||
return
|
||||
}
|
||||
if updated, err := s.store.GetDMCall(ctx, callID); err == nil {
|
||||
s.dispatchDMCallUpdate(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startVoiceWatchdog периодически убирает «призрачные» состояния: записи без
|
||||
// обновлений дольше шести часов (сервер мог упасть и не получить вебхук).
|
||||
func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
||||
@@ -209,6 +296,7 @@ func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration)
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.cleanupStaleVoiceStates(ctx)
|
||||
s.expireStaleDMCalls(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
Reference in New Issue
Block a user