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:
@@ -0,0 +1,702 @@
|
||||
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) }
|
||||
Reference in New Issue
Block a user