Files
grendervill 0b55fbdfe2 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.
2026-09-26 17:02:55 +03:00

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) }