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:
@@ -150,6 +150,34 @@ type ReadyChannel struct {
|
|||||||
MemberCount int `json:"member_count,omitempty"`
|
MemberCount int `json:"member_count,omitempty"`
|
||||||
MemberIDs []string `json:"member_ids,omitempty"`
|
MemberIDs []string `json:"member_ids,omitempty"`
|
||||||
OwnerID string `json:"owner_id,omitempty"`
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
|
// ActiveCall — звонок, идущий в личной беседе (AGENT.md 7.8, Фаза 7):
|
||||||
|
// список бесед и шапка показывают «идёт звонок» сразу после подключения,
|
||||||
|
// не дожидаясь события DM_CALL_UPDATE.
|
||||||
|
ActiveCall *ReadyDMCall `json:"active_call,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyDMCall — звонок в личной беседе в снапшоте. Форма совпадает с
|
||||||
|
// dmCallPayload REST-ручек и событиями DM_CALL_*, чтобы клиент разбирал
|
||||||
|
// состояние звонка одним кодом (AGENT.md 8.3).
|
||||||
|
type ReadyDMCall 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"`
|
||||||
|
Participants []ReadyDMCallParticipant `json:"participants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyDMCallParticipant — участник звонка в снапшоте.
|
||||||
|
type ReadyDMCallParticipant 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadyRole struct {
|
type ReadyRole struct {
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
|
|||||||
// У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8).
|
// У 1:1 иконка беседы — аватар собеседника (AGENT.md 7.8).
|
||||||
channel.IconFileID = formatID(*summary.AvatarFileID)
|
channel.IconFileID = formatID(*summary.AvatarFileID)
|
||||||
}
|
}
|
||||||
|
// Звонок в беседе: клиент рисует маркер «идёт звонок» сразу после
|
||||||
|
// подключения, а не только по событию (AGENT.md 7.8).
|
||||||
|
if call, err := s.store.GetLiveDMCall(ctx, summary.Channel.ID); err == nil {
|
||||||
|
channel.ActiveCall = readyDMCall(call)
|
||||||
|
}
|
||||||
if summary.LastMessageID != 0 {
|
if summary.LastMessageID != 0 {
|
||||||
channel.LastMessageID = formatID(summary.LastMessageID)
|
channel.LastMessageID = formatID(summary.LastMessageID)
|
||||||
}
|
}
|
||||||
@@ -427,6 +432,34 @@ func optionalFormatID(id *uint64) string {
|
|||||||
return formatID(*id)
|
return formatID(*id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readyDMCall переводит звонок из хранилища в форму снапшота: та же форма, что
|
||||||
|
// у событий DM_CALL_* и REST-ручек звонков (AGENT.md 8.3).
|
||||||
|
func readyDMCall(call *store.DMCallWithParticipants) *ReadyDMCall {
|
||||||
|
payload := &ReadyDMCall{
|
||||||
|
ID: formatID(call.ID),
|
||||||
|
ChannelID: formatID(call.ChannelID),
|
||||||
|
InitiatorID: formatID(call.InitiatorID),
|
||||||
|
Status: call.Status,
|
||||||
|
Media: call.Media,
|
||||||
|
CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
Participants: make([]ReadyDMCallParticipant, 0, len(call.Participants)),
|
||||||
|
}
|
||||||
|
if call.StartedAt != nil {
|
||||||
|
payload.StartedAt = call.StartedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
for _, participant := range call.Participants {
|
||||||
|
payload.Participants = append(payload.Participants, ReadyDMCallParticipant{
|
||||||
|
UserID: formatID(participant.UserID),
|
||||||
|
State: participant.State,
|
||||||
|
SelfMute: participant.SelfMute,
|
||||||
|
SelfDeaf: participant.SelfDeaf,
|
||||||
|
Camera: participant.Camera,
|
||||||
|
Screen: participant.Screen,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
func formatID(id uint64) string {
|
func formatID(id uint64) string {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -178,5 +178,5 @@ func (s *Server) writeDMChannel(ctx context.Context, w http.ResponseWriter, chan
|
|||||||
writeHumaAPIError(w, err)
|
writeHumaAPIError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(summary)})
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"channel": s.dmChannelPayload(ctx, summary)})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ type dmChannelPayload struct {
|
|||||||
MemberCount int `json:"member_count,omitempty"`
|
MemberCount int `json:"member_count,omitempty"`
|
||||||
OwnerID string `json:"owner_id,omitempty"`
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
|
Recipients []dmRecipientPayload `json:"recipients,omitempty"`
|
||||||
|
// ActiveCall — идущий в беседе звонок (AGENT.md 7.8, Фаза 7): список
|
||||||
|
// бесед и шапка показывают маркер «идёт звонок» и после перезагрузки, не
|
||||||
|
// дожидаясь события DM_CALL_UPDATE.
|
||||||
|
ActiveCall *dmCallPayload `json:"active_call,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dmRecipientPayload — участник групповой беседы.
|
// dmRecipientPayload — участник групповой беседы.
|
||||||
@@ -404,7 +408,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
Timezone: recipient.Timezone,
|
Timezone: recipient.Timezone,
|
||||||
}
|
}
|
||||||
output := &dmChannelOutput{}
|
output := &dmChannelOutput{}
|
||||||
output.Body.Channel = s.dmChannelPayload(summary)
|
output.Body.Channel = s.dmChannelPayload(ctx, summary)
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -427,7 +431,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
output := &dmChannelListOutput{}
|
output := &dmChannelListOutput{}
|
||||||
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
|
output.Body.Channels = make([]dmChannelPayload, 0, len(channels))
|
||||||
for _, summary := range channels {
|
for _, summary := range channels {
|
||||||
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary))
|
output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(ctx, summary))
|
||||||
}
|
}
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
@@ -481,7 +485,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
output := &dmChannelOutput{}
|
output := &dmChannelOutput{}
|
||||||
output.Body.Channel = s.dmChannelPayload(summary)
|
output.Body.Channel = s.dmChannelPayload(ctx, summary)
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -544,7 +548,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
output := &dmChannelOutput{}
|
output := &dmChannelOutput{}
|
||||||
output.Body.Channel = s.dmChannelPayload(summary)
|
output.Body.Channel = s.dmChannelPayload(ctx, summary)
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -639,7 +643,7 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
output := &dmChannelOutput{}
|
output := &dmChannelOutput{}
|
||||||
output.Body.Channel = s.dmChannelPayload(summary)
|
output.Body.Channel = s.dmChannelPayload(ctx, summary)
|
||||||
return output, nil
|
return output, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -926,12 +930,15 @@ func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfil
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dmChannelPayload собирает личную беседу для API.
|
// dmChannelPayload собирает личную беседу для API.
|
||||||
func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload {
|
func (s *Server) dmChannelPayload(ctx context.Context, summary store.DMChannelSummary) dmChannelPayload {
|
||||||
payload := dmChannelPayload{
|
payload := dmChannelPayload{
|
||||||
ID: formatSnowflake(summary.Channel.ID),
|
ID: formatSnowflake(summary.Channel.ID),
|
||||||
Type: string(store.ChannelDM),
|
Type: string(store.ChannelDM),
|
||||||
CanSend: true,
|
CanSend: true,
|
||||||
CanView: true,
|
CanView: true,
|
||||||
|
// Звонок приходит вместе с беседой: клиенту не нужен отдельный запрос,
|
||||||
|
// чтобы показать «идёт звонок» в списке и шапке (AGENT.md 7.8).
|
||||||
|
ActiveCall: s.liveCallPayload(ctx, summary.Channel.ID),
|
||||||
}
|
}
|
||||||
if summary.IsGroup {
|
if summary.IsGroup {
|
||||||
payload.IsGroup = true
|
payload.IsGroup = true
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ func (s *Server) markWebhookProcessed(id string) bool {
|
|||||||
|
|
||||||
// applyLiveKitEvent обновляет голосовые состояния по событию SFU.
|
// applyLiveKitEvent обновляет голосовые состояния по событию SFU.
|
||||||
func (s *Server) applyLiveKitEvent(ctx context.Context, event livekitWebhookEvent) {
|
func (s *Server) applyLiveKitEvent(ctx context.Context, event livekitWebhookEvent) {
|
||||||
|
// Комнаты звонков в беседах живут отдельно от серверных голосовых комнат:
|
||||||
|
// по префиксу имени понятно, куда относить событие (AGENT.md 7.8).
|
||||||
|
if callID, ok := voice.ParseCallRoomName(event.Room.Name); ok {
|
||||||
|
s.applyLiveKitCallEvent(ctx, callID, event)
|
||||||
|
return
|
||||||
|
}
|
||||||
guildID, channelID, ok := voice.ParseRoomName(event.Room.Name)
|
guildID, channelID, ok := voice.ParseRoomName(event.Room.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -194,6 +200,87 @@ func parseVoiceIdentity(identity string) uint64 {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyLiveKitCallEvent синхронизирует звонок беседы с состоянием комнаты:
|
||||||
|
// вебхук исправляет расхождения после перезапуска приложения и закрывает
|
||||||
|
// звонок, когда в комнате никого не осталось (AGENT.md 7.14).
|
||||||
|
func (s *Server) applyLiveKitCallEvent(ctx context.Context, callID uint64, event livekitWebhookEvent) {
|
||||||
|
call, err := s.store.GetDMCall(ctx, callID)
|
||||||
|
if err != nil || call.Status == store.DMCallEnded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := parseVoiceIdentity(event.Participant.Identity)
|
||||||
|
switch event.Event {
|
||||||
|
case "participant_joined":
|
||||||
|
if userID == 0 || dmCallParticipant(call, userID) == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.JoinDMCallParticipant(ctx, callID, userID); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "livekit webhook: join dm call failed", slog.Any("error", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated, err := s.store.GetDMCall(ctx, callID); err == nil {
|
||||||
|
s.dispatchDMCallUpdate(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "participant_left", "participant_connection_aborted":
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := dmCallParticipant(call, userID)
|
||||||
|
if current == nil || current.State != store.DMCallJoined {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := store.DMCallReasonCompleted
|
||||||
|
if call.Status == store.DMCallRinging {
|
||||||
|
reason = store.DMCallReasonMissed
|
||||||
|
}
|
||||||
|
if _, err := s.leaveDMCall(ctx, call, userID, store.DMCallLeft, reason); err != nil {
|
||||||
|
s.logger.DebugContext(ctx, "livekit webhook: leave dm call skipped", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "room_finished":
|
||||||
|
reason := store.DMCallReasonCompleted
|
||||||
|
if call.Status == store.DMCallRinging {
|
||||||
|
reason = store.DMCallReasonMissed
|
||||||
|
}
|
||||||
|
if err := s.finishDMCall(ctx, call, reason, nil); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "livekit webhook: finish dm call failed", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "track_published", "track_unpublished":
|
||||||
|
if userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := dmCallParticipant(call, userID)
|
||||||
|
if current == nil || current.State != store.DMCallJoined {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flags := store.DMCallFlags{
|
||||||
|
SelfMute: current.SelfMute,
|
||||||
|
SelfDeaf: current.SelfDeaf,
|
||||||
|
Camera: current.Camera,
|
||||||
|
Screen: current.Screen,
|
||||||
|
}
|
||||||
|
published := event.Event == "track_published"
|
||||||
|
switch strings.ToUpper(event.Track.Source) {
|
||||||
|
case "MICROPHONE":
|
||||||
|
flags.SelfMute = !published
|
||||||
|
case "CAMERA":
|
||||||
|
flags.Camera = published
|
||||||
|
case "SCREEN_SHARE", "SCREEN_SHARE_AUDIO":
|
||||||
|
flags.Screen = published
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateDMCallFlags(ctx, callID, userID, flags); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated, err := s.store.GetDMCall(ctx, callID); err == nil {
|
||||||
|
s.dispatchDMCallUpdate(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startVoiceWatchdog периодически убирает «призрачные» состояния: записи без
|
// startVoiceWatchdog периодически убирает «призрачные» состояния: записи без
|
||||||
// обновлений дольше шести часов (сервер мог упасть и не получить вебхук).
|
// обновлений дольше шести часов (сервер мог упасть и не получить вебхук).
|
||||||
func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration) {
|
||||||
@@ -209,6 +296,7 @@ func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration)
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
s.cleanupStaleVoiceStates(ctx)
|
s.cleanupStaleVoiceStates(ctx)
|
||||||
|
s.expireStaleDMCalls(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dmCallParticipantResponse — участник звонка в ответе API.
|
||||||
|
type dmCallParticipantResponse 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dmCallResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
InitiatorID string `json:"initiator_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Media string `json:"media"`
|
||||||
|
EndReason string `json:"end_reason"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
Participants []dmCallParticipantResponse `json:"participants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmCallPayloadResponse — разбор ответа ручек звонка.
|
||||||
|
type dmCallPayloadResponse struct {
|
||||||
|
Call dmCallResponse `json:"call"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Room string `json:"room"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceTestServer включает голос в тестовом сервере: ручки звонков без ключей
|
||||||
|
// LiveKit отвечают voice.disabled (AGENT.md 7.14).
|
||||||
|
func voiceTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
srv.cfg.LiveKitAPIKey = "test-key"
|
||||||
|
srv.cfg.LiveKitAPISecret = "test-secret"
|
||||||
|
srv.cfg.LiveKitURL = "wss://gl.mhspx.su/rtc"
|
||||||
|
srv.voice = voice.NewIssuer(srv.cfg.LiveKitAPIKey, srv.cfg.LiveKitAPISecret, srv.cfg.LiveKitTokenTTL)
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// openTestDM заводит личную беседу между двумя пользователями и возвращает её id.
|
||||||
|
func openTestDM(t *testing.T, srv *Server, first *http.Cookie, secondID string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+secondID+`"}`, first)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("open direct channel = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
created := decodeResponse[struct {
|
||||||
|
Channel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"channel"`
|
||||||
|
}](t, rec)
|
||||||
|
if created.Channel.ID == "" {
|
||||||
|
t.Fatalf("direct channel id is empty: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
return created.Channel.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func userIDByEmail(t *testing.T, srv *Server, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
user, err := srv.auth.UserByEmail(t.Context(), email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByEmail(%s): %v", email, err)
|
||||||
|
}
|
||||||
|
return formatSnowflake(user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerManyUsers заводит несколько аккаунтов: лимит регистраций считается
|
||||||
|
// по IP (пять в минуту), поэтому часы лимитера сдвигаются вперёд.
|
||||||
|
func registerManyUsers(t *testing.T, srv *Server, count int, prefix string) []*http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
clock := time.Now()
|
||||||
|
srv.authLimiter.SetClock(func() time.Time { return clock })
|
||||||
|
cookies := make([]*http.Cookie, 0, count)
|
||||||
|
for index := 0; index < count; index++ {
|
||||||
|
clock = clock.Add(30 * time.Second)
|
||||||
|
username := fmt.Sprintf("%s_%02d", prefix, index)
|
||||||
|
cookies = append(cookies, registerAndLogin(t, srv, username, username+"@example.com"))
|
||||||
|
}
|
||||||
|
return cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
func participantOf(t *testing.T, payload dmCallPayloadResponse, userID string) dmCallParticipantResponse {
|
||||||
|
t.Helper()
|
||||||
|
for _, participant := range payload.Call.Participants {
|
||||||
|
if participant.UserID == userID {
|
||||||
|
return participant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("participant %s is missing in %+v", userID, payload.Call.Participants)
|
||||||
|
return dmCallParticipantResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallLifecycle — звонок 1:1 целиком (AGENT.md 7.8, Фаза 7):
|
||||||
|
// создание, гудки, принятие, флаги, завершение и история.
|
||||||
|
func TestDirectCallLifecycle(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_call", "alice-call@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_call", "bob-call@example.com")
|
||||||
|
aliceID := userIDByEmail(t, srv, "alice-call@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-call@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
|
||||||
|
// Звонок начинает Алиса: комната dm_call_*, токен LiveKit выдан, гудки идут.
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{"media":"video"}`, aliceCookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
started := decodeResponse[dmCallPayloadResponse](t, rec)
|
||||||
|
if started.Call.Status != "ringing" || started.Call.Media != "video" {
|
||||||
|
t.Fatalf("call must start ringing: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if started.Call.InitiatorID != aliceID {
|
||||||
|
t.Fatalf("initiator = %s, want %s", started.Call.InitiatorID, aliceID)
|
||||||
|
}
|
||||||
|
if started.Token == "" || started.URL == "" {
|
||||||
|
t.Fatalf("call must return a LiveKit token and url: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if started.Room != voice.CallRoomName(mustParseID(t, started.Call.ID)) {
|
||||||
|
t.Fatalf("room = %q", started.Room)
|
||||||
|
}
|
||||||
|
if len(started.Call.Participants) != 2 {
|
||||||
|
t.Fatalf("participants = %+v", started.Call.Participants)
|
||||||
|
}
|
||||||
|
if state := participantOf(t, started, aliceID).State; state != "joined" {
|
||||||
|
t.Fatalf("initiator state = %q, want joined", state)
|
||||||
|
}
|
||||||
|
if state := participantOf(t, started, bobID).State; state != "invited" {
|
||||||
|
t.Fatalf("invitee state = %q, want invited", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Второй звонок в той же беседе начать нельзя.
|
||||||
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, bobCookie)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second call = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "dm.call_in_progress" {
|
||||||
|
t.Fatalf("error code = %q, want dm.call_in_progress", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Идущий звонок виден второму участнику вместе с беседой: список рисует
|
||||||
|
// маркер «идёт звонок» без отдельного запроса (AGENT.md 7.8).
|
||||||
|
list := decodeResponse[struct {
|
||||||
|
Channels []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ActiveCall *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"active_call"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie))
|
||||||
|
found := false
|
||||||
|
for _, channel := range list.Channels {
|
||||||
|
if channel.ID != channelID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
if channel.ActiveCall == nil || channel.ActiveCall.Status != "ringing" {
|
||||||
|
t.Fatalf("active_call = %+v", channel.ActiveCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("direct channel is missing in the list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Принятие: звонок становится active, длительность считается с ответа.
|
||||||
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", bobCookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("accept call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
accepted := decodeResponse[dmCallPayloadResponse](t, rec)
|
||||||
|
if accepted.Call.Status != "active" {
|
||||||
|
t.Fatalf("call status = %q, want active", accepted.Call.Status)
|
||||||
|
}
|
||||||
|
if accepted.Token == "" {
|
||||||
|
t.Fatal("accept must return a LiveKit token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Флаги микрофона и камеры сохраняются у участника звонка.
|
||||||
|
rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID+"/call/@me",
|
||||||
|
`{"self_mute":true,"camera":true}`, bobCookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update flags = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
flags := decodeResponse[dmCallPayloadResponse](t, rec)
|
||||||
|
participant := participantOf(t, flags, bobID)
|
||||||
|
if !participant.SelfMute || !participant.Camera {
|
||||||
|
t.Fatalf("flags not saved: %+v", participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершает Алиса: в беседе вдвоём уход заканчивает разговор.
|
||||||
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", aliceCookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("end call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
ended := decodeResponse[dmCallPayloadResponse](t, rec)
|
||||||
|
if ended.Call.Status != "ended" || ended.Call.EndReason != "completed" {
|
||||||
|
t.Fatalf("call must be completed: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// После завершения текущего звонка нет, а история содержит запись.
|
||||||
|
live := decodeResponse[struct {
|
||||||
|
Call *dmCallResponse `json:"call"`
|
||||||
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/call", "", bobCookie))
|
||||||
|
if live.Call != nil {
|
||||||
|
t.Fatalf("live call = %+v, want null", live.Call)
|
||||||
|
}
|
||||||
|
history := decodeResponse[struct {
|
||||||
|
Calls []dmCallResponse `json:"calls"`
|
||||||
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/calls", "", bobCookie))
|
||||||
|
if len(history.Calls) != 1 {
|
||||||
|
t.Fatalf("history = %+v", history.Calls)
|
||||||
|
}
|
||||||
|
entry := history.Calls[0]
|
||||||
|
if entry.Status != "ended" || entry.EndReason != "completed" || entry.InitiatorID != aliceID {
|
||||||
|
t.Fatalf("history entry = %+v", entry)
|
||||||
|
}
|
||||||
|
for _, participant := range entry.Participants {
|
||||||
|
if participant.State != "left" {
|
||||||
|
t.Fatalf("history participant = %+v, want left", participant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallMissedHistory — звонок, на который не ответили: у приглашённого
|
||||||
|
// он остаётся отклонённым, а сам звонок закрывается отказом (AGENT.md 7.8).
|
||||||
|
func TestDirectCallMissedHistory(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_miss", "alice-miss@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_miss", "bob-miss@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-miss@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
// Боб отклоняет — ждать больше некого, звонок завершается отказом.
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/decline", "", bobCookie)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("decline call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
declined := decodeResponse[dmCallPayloadResponse](t, rec)
|
||||||
|
if declined.Call.Status != "ended" || declined.Call.EndReason != "declined" {
|
||||||
|
t.Fatalf("call must end as declined: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if state := participantOf(t, declined, bobID).State; state != "declined" {
|
||||||
|
t.Fatalf("bob state = %q, want declined", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallOutsiderDenied — посторонний не видит беседу, не звонит в неё
|
||||||
|
// и не принимает чужой звонок: 404, существование не подтверждаем (D-070).
|
||||||
|
func TestDirectCallOutsiderDenied(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_out", "alice-out@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_out", "bob-out@example.com")
|
||||||
|
eveCookie := registerAndLogin(t, srv, "eve_out", "eve-out@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-out@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, probe := range []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/call", ""},
|
||||||
|
{http.MethodPost, "/call/accept", ""},
|
||||||
|
{http.MethodPost, "/call/decline", ""},
|
||||||
|
{http.MethodPost, "/call/end", ""},
|
||||||
|
{http.MethodPatch, "/call/@me", `{"self_mute":true}`},
|
||||||
|
{http.MethodGet, "/calls", ""},
|
||||||
|
} {
|
||||||
|
rec := doJSON(t, srv, probe.method, "/api/v1/channels/"+channelID+probe.path, probe.body, eveCookie)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("%s %s = %d, want 404, body = %s", probe.method, probe.path, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Чужой звонок всё ещё идёт: посторонний не смог его завершить.
|
||||||
|
live := decodeResponse[struct {
|
||||||
|
Call *dmCallResponse `json:"call"`
|
||||||
|
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/call", "", bobCookie))
|
||||||
|
if live.Call == nil || live.Call.Status != "ringing" {
|
||||||
|
t.Fatalf("call must still be ringing: %+v", live.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallGroupLimit — предел звонка в десять участников (AGENT.md 7.8):
|
||||||
|
// в беседе из одиннадцати человек позвонить нельзя.
|
||||||
|
func TestDirectCallGroupLimit(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
cookies := registerManyUsers(t, srv, maxDMCallParticipants, "call_limit")
|
||||||
|
ids := make([]string, 0, maxDMCallParticipants)
|
||||||
|
for index := 0; index < maxDMCallParticipants; index++ {
|
||||||
|
ids = append(ids, userIDByEmail(t, srv, fmt.Sprintf("call_limit_%02d@example.com", index)))
|
||||||
|
}
|
||||||
|
owner := cookies[0]
|
||||||
|
body := `{"name":"Лимит","recipient_ids":[`
|
||||||
|
for index := 1; index < maxDMCallParticipants; index++ {
|
||||||
|
if index > 1 {
|
||||||
|
body += ","
|
||||||
|
}
|
||||||
|
body += `"` + ids[index] + `"`
|
||||||
|
}
|
||||||
|
body += `]}`
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", body, owner)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
channelID := decodeResponse[struct {
|
||||||
|
Channel struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
MemberCount int `json:"member_count"`
|
||||||
|
} `json:"channel"`
|
||||||
|
}](t, rec).Channel.ID
|
||||||
|
|
||||||
|
// Ровно десять участников — звонок разрешён, состав весь в приглашении.
|
||||||
|
started := decodeResponse[dmCallPayloadResponse](
|
||||||
|
t, doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, owner))
|
||||||
|
if len(started.Call.Participants) != maxDMCallParticipants {
|
||||||
|
t.Fatalf("participants = %d, want %d", len(started.Call.Participants), maxDMCallParticipants)
|
||||||
|
}
|
||||||
|
// Все девять приглашённых принимают: в комнате десять человек.
|
||||||
|
for index := 1; index < maxDMCallParticipants; index++ {
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", cookies[index])
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("accept #%d = %d, body = %s", index, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// В групповой беседе уход одного разговор не заканчивает: звонок живёт,
|
||||||
|
// пока в комнате кто-то остаётся, и закрывается на последнем.
|
||||||
|
for index := 0; index < maxDMCallParticipants-1; index++ {
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", cookies[index])
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("leave #%d = %d, body = %s", index, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if payload := decodeResponse[dmCallPayloadResponse](t, rec); payload.Call.Status != "active" {
|
||||||
|
t.Fatalf("call must stay active after leave #%d: %s", index, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
live := decodeResponse[dmCallPayloadResponse](t, doJSON(t, srv, http.MethodPost,
|
||||||
|
"/api/v1/channels/"+channelID+"/call/end", "", cookies[maxDMCallParticipants-1]))
|
||||||
|
if live.Call.Status != "ended" {
|
||||||
|
t.Fatalf("call status = %q, want ended", live.Call.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Одиннадцатый участник: беседа больше предела — звонить нельзя. Состав
|
||||||
|
// расширяем напрямую в хранилище: ручка добавления держит тот же лимит.
|
||||||
|
extra := registerManyUsers(t, srv, 1, "call_extra")
|
||||||
|
_ = extra
|
||||||
|
if err := srv.store.AddDMParticipant(t.Context(), mustParseID(t, channelID),
|
||||||
|
mustParseID(t, userIDByEmail(t, srv, "call_extra_00@example.com"))); err != nil {
|
||||||
|
t.Fatalf("add extra participant: %v", err)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, owner)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("call in a big conversation = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "dm.call_limit" {
|
||||||
|
t.Fatalf("error code = %q, want dm.call_limit", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallVoiceDisabled — выключенный на инстансе голос даёт понятный
|
||||||
|
// отказ, а не сломанный звонок (AGENT.md 7.14).
|
||||||
|
func TestDirectCallVoiceDisabled(t *testing.T) {
|
||||||
|
srv, _ := newTestServer(t)
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_novoice", "alice-novoice@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_novoice", "bob-novoice@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-novoice@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
_ = bobCookie
|
||||||
|
|
||||||
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("start call without voice = %d, want 503, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code := errorCodeOf(t, rec); code != "voice.disabled" {
|
||||||
|
t.Fatalf("error code = %q, want voice.disabled", code)
|
||||||
|
}
|
||||||
|
// Никакого звонка в базе не осталось.
|
||||||
|
if _, err := srv.store.GetLiveDMCall(t.Context(), mustParseID(t, channelID)); err == nil {
|
||||||
|
t.Fatal("no call must be stored when voice is disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallGatewayEvents — приглашение приходит событием: участники
|
||||||
|
// получают DM_CALL_RING/DM_CALL_UPDATE/DM_CALL_END, посторонний — ничего.
|
||||||
|
func TestDirectCallGatewayEvents(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
httpServer := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(httpServer.Close)
|
||||||
|
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_ev", "alice-ev@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_ev", "bob-ev@example.com")
|
||||||
|
eveCookie := registerAndLogin(t, srv, "eve_ev", "eve-ev@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-ev@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
|
||||||
|
aliceClient := dialGateway(t, httpServer, aliceCookie)
|
||||||
|
bobClient := dialGateway(t, httpServer, bobCookie)
|
||||||
|
eveClient := dialGateway(t, httpServer, eveCookie)
|
||||||
|
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Боб получает приглашение, Алиса — обновление (у неё может быть второе
|
||||||
|
// устройство), посторонний не получает ничего.
|
||||||
|
ring := bobClient.expectEvent("DM_CALL_RING")
|
||||||
|
var ringPayload dmCallResponse
|
||||||
|
if err := json.Unmarshal(ring.D, &ringPayload); err != nil {
|
||||||
|
t.Fatalf("decode DM_CALL_RING: %v", err)
|
||||||
|
}
|
||||||
|
if ringPayload.Status != "ringing" || ringPayload.Media != "audio" || ringPayload.ChannelID != channelID {
|
||||||
|
t.Fatalf("ring payload = %+v", ringPayload)
|
||||||
|
}
|
||||||
|
aliceClient.expectEvent("DM_CALL_UPDATE")
|
||||||
|
|
||||||
|
// Принятие видно инициатору как обновление звонка.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", bobCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("accept = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
update := aliceClient.expectEvent("DM_CALL_UPDATE")
|
||||||
|
var updatePayload dmCallResponse
|
||||||
|
if err := json.Unmarshal(update.D, &updatePayload); err != nil {
|
||||||
|
t.Fatalf("decode DM_CALL_UPDATE: %v", err)
|
||||||
|
}
|
||||||
|
if updatePayload.Status != "active" {
|
||||||
|
t.Fatalf("status = %q, want active", updatePayload.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершение приходит отдельным событием с итогом.
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", aliceCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("end = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
end := bobClient.expectEvent("DM_CALL_END")
|
||||||
|
var endPayload dmCallResponse
|
||||||
|
if err := json.Unmarshal(end.D, &endPayload); err != nil {
|
||||||
|
t.Fatalf("decode DM_CALL_END: %v", err)
|
||||||
|
}
|
||||||
|
if endPayload.Status != "ended" || endPayload.EndReason != "completed" {
|
||||||
|
t.Fatalf("end payload = %+v", endPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Посторонний не получил события о звонке: беседа ему не видна.
|
||||||
|
eveClient.expectNoEvent("DM_CALL_RING", 300*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCallInReadySnapshot — «идёт звонок» переживает перезагрузку: звонок
|
||||||
|
// приходит в READY вместе с беседой (AGENT.md 8.3).
|
||||||
|
func TestDirectCallInReadySnapshot(t *testing.T) {
|
||||||
|
srv := voiceTestServer(t)
|
||||||
|
httpServer := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(httpServer.Close)
|
||||||
|
|
||||||
|
aliceCookie := registerAndLogin(t, srv, "alice_ready", "alice-ready@example.com")
|
||||||
|
bobCookie := registerAndLogin(t, srv, "bob_ready", "bob-ready@example.com")
|
||||||
|
bobID := userIDByEmail(t, srv, "bob-ready@example.com")
|
||||||
|
channelID := openTestDM(t, srv, aliceCookie, bobID)
|
||||||
|
|
||||||
|
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
client := dialGateway(t, httpServer, bobCookie)
|
||||||
|
var ready struct {
|
||||||
|
DMChannels []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ActiveCall *dmCallResponse `json:"active_call"`
|
||||||
|
} `json:"dm_channels"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(client.ready.D, &ready); err != nil {
|
||||||
|
t.Fatalf("decode READY: %v", err)
|
||||||
|
}
|
||||||
|
for _, channel := range ready.DMChannels {
|
||||||
|
if channel.ID != channelID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if channel.ActiveCall == nil || channel.ActiveCall.Status != "ringing" {
|
||||||
|
t.Fatalf("active_call = %+v", channel.ActiveCall)
|
||||||
|
}
|
||||||
|
if len(channel.ActiveCall.Participants) != 2 {
|
||||||
|
t.Fatalf("participants = %+v", channel.ActiveCall.Participants)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("direct channel is missing in READY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseID переводит строковый идентификатор в uint64.
|
||||||
|
func mustParseID(t *testing.T, value string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
id, err := parseID("id", value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse id %q: %v", value, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -58,6 +58,9 @@ type Server struct {
|
|||||||
editLimiter *httpx.RateLimiter
|
editLimiter *httpx.RateLimiter
|
||||||
// soundboardLimiter — не чаще 3 звуков в 10 секунд на пользователя.
|
// soundboardLimiter — не чаще 3 звуков в 10 секунд на пользователя.
|
||||||
soundboardLimiter *httpx.RateLimiter
|
soundboardLimiter *httpx.RateLimiter
|
||||||
|
// dmCallLimiter — 10 новых звонков в минуту на пользователя (Фаза 7):
|
||||||
|
// гудки в беседе не должны превращаться в спам.
|
||||||
|
dmCallLimiter *httpx.RateLimiter
|
||||||
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
// inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6).
|
||||||
inviteLimiter *httpx.RateLimiter
|
inviteLimiter *httpx.RateLimiter
|
||||||
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
||||||
@@ -136,6 +139,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
searchLimiter: httpx.NewRateLimiter(10, 10),
|
searchLimiter: httpx.NewRateLimiter(10, 10),
|
||||||
editLimiter: httpx.NewRateLimiter(10, 10),
|
editLimiter: httpx.NewRateLimiter(10, 10),
|
||||||
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
||||||
|
dmCallLimiter: httpx.NewRateLimiterWindow(dmCallLimitCount, dmCallLimitWindow, dmCallLimitCount),
|
||||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
||||||
// Второй аргумент NewRateLimiter — запас (burst), а не окно.
|
// Второй аргумент NewRateLimiter — запас (burst), а не окно.
|
||||||
@@ -216,6 +220,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerDMIconRoutes(apiRouter)
|
s.registerDMIconRoutes(apiRouter)
|
||||||
s.registerEmojiRoutes(s.api, apiRouter)
|
s.registerEmojiRoutes(s.api, apiRouter)
|
||||||
s.registerVoiceRoutes(s.api)
|
s.registerVoiceRoutes(s.api)
|
||||||
|
s.registerDMCallRoutes(s.api)
|
||||||
s.registerSoundsRoutes(s.api, apiRouter)
|
s.registerSoundsRoutes(s.api, apiRouter)
|
||||||
s.registerWebhookRoutes(s.api, apiRouter)
|
s.registerWebhookRoutes(s.api, apiRouter)
|
||||||
s.registerCosmeticRoutes(s.api, apiRouter)
|
s.registerCosmeticRoutes(s.api, apiRouter)
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ func ParseRoomName(room string) (guildID, channelID uint64, ok bool) {
|
|||||||
return guildID, channelID, true
|
return guildID, channelID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallRoomName собирает имя комнаты звонка в личной беседе. Отдельный префикс
|
||||||
|
// нужен и вебхуку LiveKit, и сторожу: по имени комнаты видно, серверная это
|
||||||
|
// комната или звонок в DM (AGENT.md 7.8).
|
||||||
|
func CallRoomName(callID uint64) string {
|
||||||
|
return fmt.Sprintf("dm_call_%d", callID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCallRoomName разбирает имя комнаты звонка.
|
||||||
|
func ParseCallRoomName(room string) (callID uint64, ok bool) {
|
||||||
|
if !strings.HasPrefix(room, "dm_call_") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if _, err := fmt.Sscanf(room, "dm_call_%d", &callID); err != nil || callID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return callID, true
|
||||||
|
}
|
||||||
|
|
||||||
func encodeSegment(payload []byte) string {
|
func encodeSegment(payload []byte) string {
|
||||||
return base64.RawURLEncoding.EncodeToString(payload)
|
return base64.RawURLEncoding.EncodeToString(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user