package server import ( "context" "errors" "log/slog" "net/http" "time" "github.com/danielgtaylor/huma/v2" "glchat/internal/store" "glchat/internal/voice" ) // Звонки в личных и групповых беседах (AGENT.md 7.8, 7.14). Голос тот же, что // в серверных комнатах — те же токены LiveKit и те же лимиты, — но серверных // ролей здесь нет: позвонить может участник беседы, приглашение приходит // событием DM_CALL_RING, а состояние звонка живёт в dm_calls. const ( // maxDMCallParticipants — предел участников звонка (AGENT.md 7.8). maxDMCallParticipants = 10 // dmCallRingTimeout — сколько звоним, прежде чем звонок станет пропущенным. dmCallRingTimeout = time.Minute // dmCallLimitWindow и dmCallLimitCount — не больше десяти новых звонков в // минуту на пользователя: гудки не должны превращаться в спам. dmCallLimitWindow = time.Minute dmCallLimitCount = 10 ) type dmCallParticipantPayload 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"` JoinedAt string `json:"joined_at,omitempty"` LeftAt string `json:"left_at,omitempty"` } // dmCallPayload — звонок для API и Gateway: состояние, состав и длительность. type dmCallPayload 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"` EndedAt string `json:"ended_at,omitempty"` EndedBy string `json:"ended_by,omitempty"` EndReason string `json:"end_reason,omitempty"` DurationSeconds int `json:"duration_seconds"` Participants []dmCallParticipantPayload `json:"participants"` } type dmCallOutput struct { Body struct { Call dmCallPayload `json:"call"` } } // dmLiveCallOutput — текущий звонок беседы: null, если никто не звонит. type dmLiveCallOutput struct { Body struct { Call *dmCallPayload `json:"call"` } } // dmCallJoinOutput — ответ на создание и принятие звонка: токен LiveKit и адрес // сигналинга, как у входа в серверную голосовую комнату (AGENT.md 7.14). type dmCallJoinOutput struct { Body struct { Call dmCallPayload `json:"call"` Token string `json:"token"` URL string `json:"url"` Room string `json:"room"` } } type dmCallHistoryOutput struct { Body struct { Calls []dmCallPayload `json:"calls"` } } // registerDMCallRoutes описывает звонки бесед: начало, принятие, отклонение, // выход, флаги микрофона и камеры, текущий звонок и историю. func (s *Server) registerDMCallRoutes(api huma.API) { security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} huma.Register(api, huma.Operation{ OperationID: "startDirectCall", Method: http.MethodPost, Path: "/channels/{channel_id}/call", Summary: "Позвонить в личной беседе (выдаёт токен LiveKit)", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Body struct { Media string `json:"media,omitempty" enum:"audio,video"` } }, ) (*dmCallJoinOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } // Новые звонки ограничены: гудки не должны превращаться в спам. if !user.IsInstanceAdmin { if allowed, retryAfter := s.dmCallLimiter.Allow(dmCallUserKey(user.ID)); !allowed { return nil, rateLimitedError(retryAfter) } } clientURL, err := s.requireVoice() if err != nil { return nil, err } media := input.Body.Media if media == "" { media = dmCallMediaAudio } if media != dmCallMediaAudio && media != dmCallMediaVideo { return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown call media") } // Кто получит приглашение: все участники беседы, кроме звонящего. // Заблокировавшие его не приглашаются — блокировка запрещает личные // сообщения в обе стороны (AGENT.md 7.2). participants, err := s.store.DMParticipantProfiles(ctx, channel.ID, user.ID) if err != nil { return nil, humaError(err) } if len(participants) > maxDMCallParticipants { return nil, humaErrorStatus(http.StatusConflict, "dm.call_limit", "too many participants for a call") } invitees := make([]uint64, 0, len(participants)) for _, participant := range participants { if participant.UserID == user.ID { continue } blocked, err := s.store.IsBlocked(ctx, user.ID, participant.UserID) if err != nil { return nil, humaError(err) } if blocked { continue } invitees = append(invitees, participant.UserID) } call, err := s.store.CreateDMCall(ctx, channel.ID, user.ID, media, invitees) if err != nil { if errors.Is(err, store.ErrConflict) { return nil, humaErrorStatus(http.StatusConflict, "dm.call_in_progress", "a call is already running") } return nil, humaError(err) } token, err := s.issueDMCallToken(user, call.ID) if err != nil { return nil, err } s.dispatchDMCallStart(call, user.ID) output := &dmCallJoinOutput{} output.Body.Call = s.dmCallPayload(call) output.Body.Token = token output.Body.URL = clientURL output.Body.Room = voice.CallRoomName(call.ID) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "acceptDirectCall", Method: http.MethodPost, Path: "/channels/{channel_id}/call/accept", Summary: "Принять звонок в личной беседе", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*dmCallJoinOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } clientURL, err := s.requireVoice() if err != nil { return nil, err } call, err := s.store.GetLiveDMCall(ctx, channel.ID) if err != nil { return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running") } invited, err := s.store.IsDMCallParticipant(ctx, call.ID, user.ID) if err != nil { return nil, humaError(err) } if !invited { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you were not invited to this call") } // Лимит десяти участников: одиннадцатому в комнате места нет. if joined, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallJoined); err != nil { return nil, humaError(err) } else if joined >= maxDMCallParticipants { return nil, humaErrorStatus(http.StatusConflict, "dm.call_limit", "the call is full") } if _, err := s.store.JoinDMCallParticipant(ctx, call.ID, user.ID); err != nil { return nil, humaError(err) } call, err = s.store.GetDMCall(ctx, call.ID) if err != nil { return nil, humaError(err) } token, err := s.issueDMCallToken(user, call.ID) if err != nil { return nil, err } s.dispatchDMCallUpdate(call) output := &dmCallJoinOutput{} output.Body.Call = s.dmCallPayload(call) output.Body.Token = token output.Body.URL = clientURL output.Body.Room = voice.CallRoomName(call.ID) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "declineDirectCall", Method: http.MethodPost, Path: "/channels/{channel_id}/call/decline", Summary: "Отклонить звонок в личной беседе", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*dmCallOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } call, err := s.store.GetLiveDMCall(ctx, channel.ID) if err != nil { return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running") } // Отклонение инициатора — это отмена звонка: остальные получают конец. reason := store.DMCallReasonDeclined if call.InitiatorID == user.ID { reason = store.DMCallReasonCanceled } call, err = s.leaveDMCall(ctx, call, user.ID, store.DMCallDeclined, reason) if err != nil { return nil, err } output := &dmCallOutput{} output.Body.Call = s.dmCallPayload(call) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "endDirectCall", Method: http.MethodPost, Path: "/channels/{channel_id}/call/end", Summary: "Выйти из звонка или завершить его", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*dmCallOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } call, err := s.store.GetLiveDMCall(ctx, channel.ID) if err != nil { return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running") } reason := store.DMCallReasonCompleted if call.Status == store.DMCallRinging { reason = store.DMCallReasonCanceled if call.InitiatorID != user.ID { reason = store.DMCallReasonDeclined } } call, err = s.leaveDMCall(ctx, call, user.ID, store.DMCallLeft, reason) if err != nil { return nil, err } output := &dmCallOutput{} output.Body.Call = s.dmCallPayload(call) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "updateMyDirectCallState", Method: http.MethodPatch, Path: "/channels/{channel_id}/call/@me", Summary: "Обновить свои флаги в звонке (микрофон, звук, камера, экран)", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Body struct { SelfMute *bool `json:"self_mute,omitempty"` SelfDeaf *bool `json:"self_deaf,omitempty"` Camera *bool `json:"camera,omitempty"` Screen *bool `json:"screen,omitempty"` } }, ) (*dmCallOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } call, err := s.store.GetLiveDMCall(ctx, channel.ID) if err != nil { return nil, humaErrorStatus(http.StatusNotFound, "dm.call_not_found", "no call is running") } current := dmCallParticipant(call, user.ID) if current == nil || current.State != store.DMCallJoined { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you are not in this call") } flags := store.DMCallFlags{ SelfMute: current.SelfMute, SelfDeaf: current.SelfDeaf, Camera: current.Camera, Screen: current.Screen, } if input.Body.SelfMute != nil { flags.SelfMute = *input.Body.SelfMute } if input.Body.SelfDeaf != nil { flags.SelfDeaf = *input.Body.SelfDeaf } if input.Body.Camera != nil { flags.Camera = *input.Body.Camera } if input.Body.Screen != nil { flags.Screen = *input.Body.Screen } if err := s.store.UpdateDMCallFlags(ctx, call.ID, user.ID, flags); err != nil { return nil, humaError(err) } updated, err := s.store.GetDMCall(ctx, call.ID) if err != nil { return nil, humaError(err) } s.dispatchDMCallUpdate(updated) output := &dmCallOutput{} output.Body.Call = s.dmCallPayload(updated) return output, nil }) huma.Register(api, huma.Operation{ OperationID: "getDirectCall", Method: http.MethodGet, Path: "/channels/{channel_id}/call", Summary: "Текущий звонок беседы", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` }, ) (*dmLiveCallOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } output := &dmLiveCallOutput{} call, err := s.store.GetLiveDMCall(ctx, channel.ID) if errors.Is(err, store.ErrNotFound) { return output, nil } if err != nil { return nil, humaError(err) } payload := s.dmCallPayload(call) output.Body.Call = &payload return output, nil }) huma.Register(api, huma.Operation{ OperationID: "listDirectCalls", Method: http.MethodGet, Path: "/channels/{channel_id}/calls", Summary: "История звонков беседы", Tags: []string{"DirectCalls"}, Security: security, }, func(ctx context.Context, input *struct { ChannelID string `path:"channel_id"` Limit int `query:"limit" default:"50" minimum:"1" maximum:"100"` Before string `query:"before,omitempty"` }, ) (*dmCallHistoryOutput, error) { user, _, err := requireUser(ctx) if err != nil { return nil, err } channel, err := s.groupDMForMember(ctx, input.ChannelID, user.ID) if err != nil { return nil, err } var beforeID uint64 if input.Before != "" { if beforeID, err = parseID("before", input.Before); err != nil { return nil, err } } calls, err := s.store.ListDMCalls(ctx, channel.ID, input.Limit, beforeID) if err != nil { return nil, humaError(err) } output := &dmCallHistoryOutput{} output.Body.Calls = make([]dmCallPayload, 0, len(calls)) for index := range calls { output.Body.Calls = append(output.Body.Calls, s.dmCallPayload(&calls[index])) } return output, nil }) } // Принятые значения media. const ( dmCallMediaAudio = "audio" dmCallMediaVideo = "video" ) // dmCallUserKey ключует лимит звонков по пользователю. func dmCallUserKey(userID uint64) string { return "user:" + formatSnowflake(userID) } // requireVoice проверяет, что голос на инстансе включён, и отдаёт адрес // сигналинга для клиента. Выключенный голос — понятный отказ voice.disabled, // а не сломанный интерфейс звонка (AGENT.md 7.14). func (s *Server) requireVoice() (string, error) { if !s.voice.Enabled() { return "", humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен") } clientURL := s.voice.VoiceURLForClient(s.cfg.LiveKitURL) if clientURL == "" { return "", humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен") } return clientURL, nil } // issueDMCallToken выдаёт участнику токен LiveKit той же формы, что и вход в // серверную голосовую комнату: серверных ролей в беседе нет, поэтому права на // публикацию есть у каждого участника звонка. func (s *Server) issueDMCallToken(user *store.User, callID uint64) (string, error) { return s.voice.Issue(voice.CallRoomName(callID), formatSnowflake(user.ID), user.DisplayName, voice.Grants{ RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true, }) } // dmCallParticipant ищет участника звонка в составе. func dmCallParticipant(call *store.DMCallWithParticipants, userID uint64) *store.DMCallParticipant { for index := range call.Participants { if call.Participants[index].UserID == userID { return &call.Participants[index] } } return nil } // leaveDMCall помечает участника вышедшим или отклонившим и завершает звонок, // когда в комнате больше никого не осталось. В беседе вдвоём уход любого // заканчивает звонок; в групповой — только когда вышли все. func (s *Server) leaveDMCall(ctx context.Context, call *store.DMCallWithParticipants, userID uint64, state, reason string) (*store.DMCallWithParticipants, error) { if dmCallParticipant(call, userID) == nil { return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you were not invited to this call") } if err := s.store.LeaveDMCallParticipant(ctx, call.ID, userID, state); err != nil { return nil, humaError(err) } s.removeFromLiveKitRoom(ctx, voice.CallRoomName(call.ID), userID) joined, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallJoined) if err != nil { return nil, humaError(err) } lastOne := joined == 0 || len(call.Participants) <= 2 // Гудки, на которые никто не ответил, тоже заканчиваются: ждать больше некого. if call.Status == store.DMCallRinging { waiting, err := s.store.CountDMCallParticipants(ctx, call.ID, store.DMCallInvited) if err != nil { return nil, humaError(err) } if waiting == 0 { lastOne = true } } if lastOne { if err := s.store.EndDMCall(ctx, call.ID, &userID, reason); err != nil { return nil, humaError(err) } call, err = s.store.GetDMCall(ctx, call.ID) if err != nil { return nil, humaError(err) } // Комнату закрываем со стороны SFU: иначе участники, чьи клиенты не // успели получить событие, остались бы говорить. s.removeCallParticipantsFromLiveKit(ctx, call) s.dispatchDMCallEnd(call) return call, nil } call, err = s.store.GetDMCall(ctx, call.ID) if err != nil { return nil, humaError(err) } s.dispatchDMCallUpdate(call) return call, nil } // finishDMCall завершает звонок целиком (сторож и вебхук LiveKit). func (s *Server) finishDMCall(ctx context.Context, call *store.DMCallWithParticipants, reason string, endedBy *uint64) error { if err := s.store.EndDMCall(ctx, call.ID, endedBy, reason); err != nil { return err } finished, err := s.store.GetDMCall(ctx, call.ID) if err != nil { return err } s.removeCallParticipantsFromLiveKit(ctx, finished) s.dispatchDMCallEnd(finished) return nil } // removeCallParticipantsFromLiveKit убирает из комнаты всех, кто в ней был. func (s *Server) removeCallParticipantsFromLiveKit(ctx context.Context, call *store.DMCallWithParticipants) { if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() { return } room := voice.CallRoomName(call.ID) for _, participant := range call.Participants { if participant.State != store.DMCallJoined { continue } if err := s.voiceAdmin.RemoveParticipant(ctx, room, formatSnowflake(participant.UserID)); err != nil { s.logger.DebugContext(ctx, "livekit call remove skipped", slogAnyError(err)) } } } // removeFromLiveKitRoom убирает одного участника из произвольной комнаты. func (s *Server) removeFromLiveKitRoom(ctx context.Context, room string, userID uint64) { if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() { return } if err := s.voiceAdmin.RemoveParticipant(ctx, room, formatSnowflake(userID)); err != nil { s.logger.DebugContext(ctx, "livekit remove skipped", slogAnyError(err)) } } // dispatchDMCallStart рассылает приглашение остальным участникам беседы, а // инициатору — обновление: у него может быть открыто второе устройство. func (s *Server) dispatchDMCallStart(call *store.DMCallWithParticipants, initiatorID uint64) { if s.gateway == nil { return } payload := s.dmCallPayload(call) for _, participant := range call.Participants { if participant.UserID == initiatorID { s.gateway.SendToUser(participant.UserID, "DM_CALL_UPDATE", payload) continue } s.gateway.SendToUser(participant.UserID, "DM_CALL_RING", payload) } } // dispatchDMCallUpdate сообщает участникам звонка об изменении состояния. func (s *Server) dispatchDMCallUpdate(call *store.DMCallWithParticipants) { if s.gateway == nil { return } payload := s.dmCallPayload(call) for _, participant := range call.Participants { s.gateway.SendToUser(participant.UserID, "DM_CALL_UPDATE", payload) } } // dispatchDMCallEnd сообщает о завершении звонка: клиенты гасят рингтон, // убирают маркер «идёт звонок» и показывают итог. func (s *Server) dispatchDMCallEnd(call *store.DMCallWithParticipants) { if s.gateway == nil { return } payload := s.dmCallPayload(call) for _, participant := range call.Participants { s.gateway.SendToUser(participant.UserID, "DM_CALL_END", payload) } } // dmCallPayload собирает представление звонка для API и Gateway. func (s *Server) dmCallPayload(call *store.DMCallWithParticipants) dmCallPayload { payload := dmCallPayload{ ID: formatSnowflake(call.ID), ChannelID: formatSnowflake(call.ChannelID), InitiatorID: formatSnowflake(call.InitiatorID), Status: call.Status, Media: call.Media, CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339), EndReason: call.EndReason, DurationSeconds: s.store.DMCallDurationSeconds(&call.DMCall), Participants: make([]dmCallParticipantPayload, 0, len(call.Participants)), } if call.StartedAt != nil { payload.StartedAt = call.StartedAt.UTC().Format(time.RFC3339) } if call.EndedAt != nil { payload.EndedAt = call.EndedAt.UTC().Format(time.RFC3339) } if call.EndedBy != nil { payload.EndedBy = formatSnowflake(*call.EndedBy) } for _, participant := range call.Participants { entry := dmCallParticipantPayload{ UserID: formatSnowflake(participant.UserID), State: participant.State, SelfMute: participant.SelfMute, SelfDeaf: participant.SelfDeaf, Camera: participant.Camera, Screen: participant.Screen, } if participant.JoinedAt != nil { entry.JoinedAt = participant.JoinedAt.UTC().Format(time.RFC3339) } if participant.LeftAt != nil { entry.LeftAt = participant.LeftAt.UTC().Format(time.RFC3339) } payload.Participants = append(payload.Participants, entry) } return payload } // liveCallPayload отдаёт текущий звонок беседы для READY-снапшота: nil, если // никто не звонит (AGENT.md 8.3). func (s *Server) liveCallPayload(ctx context.Context, channelID uint64) *dmCallPayload { call, err := s.store.GetLiveDMCall(ctx, channelID) if err != nil { return nil } payload := s.dmCallPayload(call) return &payload } // expireStaleDMCalls завершает звонки, о которых уже никто не помнит: гудки, // на которые минуту никто не ответил, и комнаты, из которых все вышли // (вебхук LiveKit мог потеряться). Вызывается сторожем голосовых состояний. func (s *Server) expireStaleDMCalls(ctx context.Context) { now := time.Now().UTC() calls, err := s.store.ListStaleDMCalls(ctx, now.Add(-dmCallRingTimeout), now.Add(-time.Minute)) if err != nil { s.logger.WarnContext(ctx, "failed to list stale dm calls", slog.Any("error", err)) return } for index := range calls { call, err := s.store.GetDMCall(ctx, calls[index].ID) if err != nil { continue } reason := store.DMCallReasonMissed if calls[index].Status == store.DMCallActive { reason = store.DMCallReasonCompleted } if err := s.finishDMCall(ctx, call, reason, nil); err != nil { s.logger.WarnContext(ctx, "failed to finish stale dm call", slog.Any("error", err)) continue } s.logger.InfoContext(ctx, "stale dm call finished", slog.String("call_id", formatSnowflake(call.ID)), slog.String("reason", reason)) } } // slogAnyError прячет ошибку RoomService в атрибут лога. func slogAnyError(err error) slog.Attr { return slog.Any("error", err) }