0b55fbdfe2
Звонки 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.
703 lines
25 KiB
Go
703 lines
25 KiB
Go
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) }
|