2026-09-20 18:04:59 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-09-20 19:01:14 +03:00
|
|
|
"log/slog"
|
2026-09-20 18:04:59 +03:00
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
|
|
|
|
|
|
"glchat/internal/permissions"
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
"glchat/internal/voice"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type voiceStatePayload struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
ChannelID string `json:"channel_id"`
|
|
|
|
|
GuildID string `json:"guild_id"`
|
|
|
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
|
SelfMute bool `json:"self_mute"`
|
|
|
|
|
SelfDeaf bool `json:"self_deaf"`
|
|
|
|
|
ServerMute bool `json:"server_mute"`
|
|
|
|
|
ServerDeaf bool `json:"server_deaf"`
|
|
|
|
|
Camera bool `json:"camera"`
|
|
|
|
|
Screen bool `json:"screen"`
|
|
|
|
|
JoinedAt string `json:"joined_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type voiceStateListOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
States []voiceStatePayload `json:"voice_states"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type voiceJoinOutput struct {
|
|
|
|
|
Body struct {
|
|
|
|
|
// Token — токен доступа LiveKit, URL — публичный адрес сервиса.
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
Room string `json:"room"`
|
|
|
|
|
ChannelID string `json:"channel_id"`
|
|
|
|
|
GuildID string `json:"guild_id"`
|
|
|
|
|
State voiceStatePayload `json:"state"`
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// registerVoiceRoutes описывает голосовые комнаты (AGENT.md 7.14): вход,
|
|
|
|
|
// выход, флаги микрофона и камеры, модерация и перемещение участников.
|
|
|
|
|
func (s *Server) registerVoiceRoutes(api huma.API) {
|
|
|
|
|
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "joinVoiceChannel",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/channels/{channel_id}/voice/join",
|
|
|
|
|
Summary: "Войти в голосовую комнату (выдаёт токен LiveKit)",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
ChannelID string `path:"channel_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*voiceJoinOutput, error) {
|
|
|
|
|
user, session, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
channelID, resolved, channel, err := s.requireChannelPermission(ctx, input.ChannelID, user, permissions.ConnectVoice)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if channel.Type != store.ChannelVoice {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "канал не является голосовым")
|
|
|
|
|
}
|
|
|
|
|
if err := s.ensureVoiceCapacity(ctx, channel, user); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if !s.voice.Enabled() {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
|
|
|
|
|
}
|
2026-09-20 19:29:19 +03:00
|
|
|
// Клиенту отдаём базовый адрес SDK: сигнальный путь `/rtc` SDK дописывает сам.
|
|
|
|
|
clientURL := s.voice.VoiceURLForClient(s.cfg.LiveKitURL)
|
|
|
|
|
if clientURL == "" {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
|
|
|
|
|
}
|
2026-09-20 18:04:59 +03:00
|
|
|
|
|
|
|
|
// Сохраняем состояние: пользователь мог перейти из другой комнаты.
|
|
|
|
|
state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
GuildID: *channel.GuildID,
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
SessionID: formatSnowflake(session.ID),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
token, err := s.voice.Issue(voice.RoomName(*channel.GuildID, channelID), formatSnowflake(user.ID), user.DisplayName, voice.Grants{
|
|
|
|
|
RoomJoin: true, CanPublish: resolved.Can(permissions.Speak), CanSubscribe: true, CanPublishData: true,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.dispatchVoiceState(ctx, state)
|
|
|
|
|
s.postSystemMessage(ctx, channelID, "voice_join", map[string]any{"user_id": formatSnowflake(user.ID)})
|
|
|
|
|
|
|
|
|
|
output := &voiceJoinOutput{}
|
|
|
|
|
output.Body.Token = token
|
2026-09-20 19:29:19 +03:00
|
|
|
output.Body.URL = clientURL
|
2026-09-20 18:04:59 +03:00
|
|
|
output.Body.Room = voice.RoomName(*channel.GuildID, channelID)
|
|
|
|
|
output.Body.ChannelID = formatSnowflake(channelID)
|
|
|
|
|
output.Body.GuildID = formatSnowflake(*channel.GuildID)
|
|
|
|
|
output.Body.State = s.voiceStatePayload(state)
|
|
|
|
|
return output, nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "leaveVoiceChannel",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/channels/{channel_id}/voice/leave",
|
|
|
|
|
Summary: "Покинуть голосовую комнату",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
ChannelID string `path:"channel_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*okOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
channelID, err := parseID("channel_id", input.ChannelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
channel, err := s.store.GetChannel(ctx, channelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if channel.GuildID == nil {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found")
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.DeleteVoiceState(ctx, *channel.GuildID, user.ID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.dispatchVoiceLeave(ctx, *channel.GuildID, channelID, user.ID)
|
|
|
|
|
return newOKOutput(), nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "updateMyVoiceState",
|
|
|
|
|
Method: http.MethodPatch,
|
|
|
|
|
Path: "/guilds/{guild_id}/voice-states/@me",
|
|
|
|
|
Summary: "Обновить свои флаги голоса (микрофон, звук, камера, экран)",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_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"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*voiceStateListOutput, error) {
|
|
|
|
|
user, session, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
current, err := s.store.GetVoiceState(ctx, guildID, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
params := store.VoiceStateParams{
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
GuildID: guildID,
|
|
|
|
|
ChannelID: current.ChannelID,
|
|
|
|
|
SessionID: formatSnowflake(session.ID),
|
|
|
|
|
SelfMute: current.SelfMute,
|
|
|
|
|
SelfDeaf: current.SelfDeaf,
|
|
|
|
|
ServerMute: current.ServerMute,
|
|
|
|
|
ServerDeaf: current.ServerDeaf,
|
|
|
|
|
Camera: current.Camera,
|
|
|
|
|
Screen: current.Screen,
|
|
|
|
|
}
|
|
|
|
|
if input.Body.SelfMute != nil {
|
|
|
|
|
params.SelfMute = *input.Body.SelfMute
|
|
|
|
|
}
|
|
|
|
|
if input.Body.SelfDeaf != nil {
|
|
|
|
|
params.SelfDeaf = *input.Body.SelfDeaf
|
|
|
|
|
}
|
|
|
|
|
if input.Body.Camera != nil {
|
|
|
|
|
params.Camera = *input.Body.Camera
|
|
|
|
|
}
|
|
|
|
|
if input.Body.Screen != nil {
|
|
|
|
|
params.Screen = *input.Body.Screen
|
|
|
|
|
}
|
|
|
|
|
state, err := s.store.UpsertVoiceState(ctx, params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.dispatchVoiceState(ctx, state)
|
|
|
|
|
return s.voiceStatesOutput(ctx, guildID)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "listVoiceStates",
|
|
|
|
|
Method: http.MethodGet,
|
|
|
|
|
Path: "/guilds/{guild_id}/voice-states",
|
|
|
|
|
Summary: "Кто в голосовых комнатах сервера",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*voiceStateListOutput, error) {
|
|
|
|
|
user, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return s.voiceStatesOutput(ctx, guildID)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "moderateVoiceState",
|
|
|
|
|
Method: http.MethodPatch,
|
|
|
|
|
Path: "/guilds/{guild_id}/voice-states/{user_id}",
|
|
|
|
|
Summary: "Серверный мьют и глушение участника",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
ServerMute *bool `json:"server_mute,omitempty"`
|
|
|
|
|
ServerDeaf *bool `json:"server_deaf,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*voiceStateListOutput, error) {
|
|
|
|
|
actor, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.ViewGuild)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if input.Body.ServerMute != nil && !resolved.Has(permissions.MuteMembers) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MUTE_MEMBERS is required")
|
|
|
|
|
}
|
|
|
|
|
if input.Body.ServerDeaf != nil && !resolved.Has(permissions.DeafenMembers) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "DEAFEN_MEMBERS is required")
|
|
|
|
|
}
|
|
|
|
|
current, err := s.store.GetVoiceState(ctx, guildID, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
params := store.VoiceStateParams{
|
|
|
|
|
UserID: targetID, GuildID: guildID, ChannelID: current.ChannelID, SessionID: current.SessionID,
|
|
|
|
|
SelfMute: current.SelfMute, SelfDeaf: current.SelfDeaf,
|
|
|
|
|
ServerMute: current.ServerMute, ServerDeaf: current.ServerDeaf,
|
|
|
|
|
Camera: current.Camera, Screen: current.Screen,
|
|
|
|
|
}
|
|
|
|
|
if input.Body.ServerMute != nil {
|
|
|
|
|
params.ServerMute = *input.Body.ServerMute
|
|
|
|
|
}
|
|
|
|
|
if input.Body.ServerDeaf != nil {
|
|
|
|
|
params.ServerDeaf = *input.Body.ServerDeaf
|
|
|
|
|
}
|
|
|
|
|
state, err := s.store.UpsertVoiceState(ctx, params)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
action := "voice.mute"
|
|
|
|
|
if input.Body.ServerMute != nil && !*input.Body.ServerMute {
|
|
|
|
|
action = "voice.unmute"
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, actor, guildID, action, "user", &targetID, "")
|
2026-09-20 19:01:14 +03:00
|
|
|
// Мьют применяется на стороне SFU: клиент не может его обойти.
|
|
|
|
|
if input.Body.ServerMute != nil {
|
|
|
|
|
s.applyServerMute(ctx, state, *input.Body.ServerMute)
|
|
|
|
|
}
|
2026-09-20 18:04:59 +03:00
|
|
|
s.dispatchVoiceState(ctx, state)
|
|
|
|
|
return s.voiceStatesOutput(ctx, guildID)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-20 19:01:14 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "disconnectVoiceMember",
|
|
|
|
|
Method: http.MethodDelete,
|
|
|
|
|
Path: "/guilds/{guild_id}/voice-states/{user_id}",
|
|
|
|
|
Summary: "Отключить участника от голосовой комнаты",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
},
|
|
|
|
|
) (*voiceStateListOutput, error) {
|
|
|
|
|
actor, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.ViewGuild)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if !resolved.Has(permissions.MuteMembers) {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MUTE_MEMBERS is required")
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
state, err := s.store.GetVoiceState(ctx, guildID, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Убираем участника из комнаты на стороне SFU и в БД.
|
|
|
|
|
s.removeFromLiveKit(ctx, guildID, state.ChannelID, targetID)
|
|
|
|
|
if err := s.store.DeleteVoiceState(ctx, guildID, targetID); err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
s.recordAudit(ctx, actor, guildID, "voice.disconnect", "user", &targetID, "")
|
|
|
|
|
s.dispatchVoiceLeave(ctx, guildID, state.ChannelID, targetID)
|
|
|
|
|
return s.voiceStatesOutput(ctx, guildID)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-20 18:04:59 +03:00
|
|
|
huma.Register(api, huma.Operation{
|
|
|
|
|
OperationID: "moveVoiceMember",
|
|
|
|
|
Method: http.MethodPost,
|
|
|
|
|
Path: "/guilds/{guild_id}/voice-states/{user_id}/move",
|
|
|
|
|
Summary: "Переместить участника в другую голосовую комнату",
|
|
|
|
|
Tags: []string{"Voice"},
|
|
|
|
|
Security: security,
|
|
|
|
|
}, func(ctx context.Context, input *struct {
|
|
|
|
|
GuildID string `path:"guild_id"`
|
|
|
|
|
UserID string `path:"user_id"`
|
|
|
|
|
Body struct {
|
|
|
|
|
ChannelID string `json:"channel_id"`
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
) (*voiceStateListOutput, error) {
|
|
|
|
|
actor, _, err := requireUser(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, actor, permissions.MoveMembers)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
targetID, err := parseID("user_id", input.UserID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
channelID, err := parseID("channel_id", input.Body.ChannelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
channel, err := s.store.GetChannel(ctx, channelID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if channel.Type != store.ChannelVoice {
|
|
|
|
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "целевой канал не голосовой")
|
|
|
|
|
}
|
|
|
|
|
target, err := s.store.GetUser(ctx, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.ensureVoiceCapacity(ctx, channel, target); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
current, err := s.store.GetVoiceState(ctx, guildID, targetID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{
|
|
|
|
|
UserID: targetID, GuildID: guildID, ChannelID: channelID, SessionID: current.SessionID,
|
|
|
|
|
SelfMute: current.SelfMute, SelfDeaf: current.SelfDeaf,
|
|
|
|
|
ServerMute: current.ServerMute, ServerDeaf: current.ServerDeaf,
|
|
|
|
|
Camera: current.Camera, Screen: current.Screen,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
2026-09-20 19:01:14 +03:00
|
|
|
// На стороне SFU участник должен переподключиться в новую комнату.
|
|
|
|
|
s.removeFromLiveKit(ctx, guildID, current.ChannelID, targetID)
|
2026-09-20 18:04:59 +03:00
|
|
|
s.recordAudit(ctx, actor, guildID, "voice.move", "user", &targetID, "")
|
|
|
|
|
s.dispatchVoiceState(ctx, state)
|
|
|
|
|
return s.voiceStatesOutput(ctx, guildID)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 19:01:14 +03:00
|
|
|
// applyServerMute применяет мьют микрофона на стороне SFU.
|
|
|
|
|
func (s *Server) applyServerMute(ctx context.Context, state *store.VoiceState, muted bool) {
|
|
|
|
|
if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
room := voice.RoomName(state.GuildID, state.ChannelID)
|
|
|
|
|
if err := s.voiceAdmin.SetMicrophoneMuted(ctx, room, formatSnowflake(state.UserID), muted); err != nil {
|
|
|
|
|
// Участник мог уже отключиться: это не ошибка операции.
|
|
|
|
|
s.logger.DebugContext(ctx, "livekit mute skipped", slog.Any("error", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// removeFromLiveKit убирает участника из комнаты на стороне SFU: клиент
|
|
|
|
|
// получит Disconnected и вернётся в список комнат.
|
|
|
|
|
func (s *Server) removeFromLiveKit(ctx context.Context, guildID, channelID, userID uint64) {
|
|
|
|
|
if s.voiceAdmin == nil || !s.voiceAdmin.Enabled() {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
room := voice.RoomName(guildID, channelID)
|
|
|
|
|
if err := s.voiceAdmin.RemoveParticipant(ctx, room, formatSnowflake(userID)); err != nil {
|
|
|
|
|
s.logger.DebugContext(ctx, "livekit remove skipped", slog.Any("error", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 18:04:59 +03:00
|
|
|
// ensureVoiceCapacity проверяет лимит участников голосовой комнаты (AGENT.md 7.14).
|
|
|
|
|
func (s *Server) ensureVoiceCapacity(ctx context.Context, channel *store.Channel, user *store.User) error {
|
|
|
|
|
if channel.UserLimit <= 0 || user.IsInstanceAdmin {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
count, err := s.store.CountChannelVoiceStates(ctx, channel.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return humaError(err)
|
|
|
|
|
}
|
|
|
|
|
// Если пользователь уже в этой комнате, лимит на него не действует.
|
|
|
|
|
current, err := s.store.GetVoiceState(ctx, *channel.GuildID, user.ID)
|
|
|
|
|
if err == nil && current.ChannelID == channel.ID {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if count >= channel.UserLimit {
|
|
|
|
|
return humaErrorStatus(http.StatusForbidden, "voice.channel_full", "в голосовой комнате нет свободных мест")
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// voiceStatesOutput собирает список голосовых состояний сервера.
|
|
|
|
|
func (s *Server) voiceStatesOutput(ctx context.Context, guildID uint64) (*voiceStateListOutput, error) {
|
|
|
|
|
states, err := s.store.ListVoiceStates(ctx, guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, humaError(err)
|
|
|
|
|
}
|
|
|
|
|
output := &voiceStateListOutput{}
|
|
|
|
|
output.Body.States = make([]voiceStatePayload, 0, len(states))
|
|
|
|
|
for i := range states {
|
|
|
|
|
output.Body.States = append(output.Body.States, s.voiceStatePayload(&states[i]))
|
|
|
|
|
}
|
|
|
|
|
return output, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) voiceStatePayload(state *store.VoiceState) voiceStatePayload {
|
|
|
|
|
return voiceStatePayload{
|
|
|
|
|
UserID: formatSnowflake(state.UserID),
|
|
|
|
|
ChannelID: formatSnowflake(state.ChannelID),
|
|
|
|
|
GuildID: formatSnowflake(state.GuildID),
|
|
|
|
|
SessionID: state.SessionID,
|
|
|
|
|
SelfMute: state.SelfMute,
|
|
|
|
|
SelfDeaf: state.SelfDeaf,
|
|
|
|
|
ServerMute: state.ServerMute,
|
|
|
|
|
ServerDeaf: state.ServerDeaf,
|
|
|
|
|
Camera: state.Camera,
|
|
|
|
|
Screen: state.Screen,
|
|
|
|
|
JoinedAt: state.JoinedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchVoiceState рассылает событие VOICE_STATE_UPDATE участникам сервера.
|
|
|
|
|
func (s *Server) dispatchVoiceState(ctx context.Context, state *store.VoiceState) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
members, err := s.store.ListGuildMembers(ctx, state.GuildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := s.voiceStatePayload(state)
|
|
|
|
|
for _, member := range members {
|
|
|
|
|
s.gateway.SendToUser(member.UserID, "VOICE_STATE_UPDATE", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dispatchVoiceLeave сообщает об уходе из голосовой комнаты.
|
|
|
|
|
func (s *Server) dispatchVoiceLeave(ctx context.Context, guildID, channelID, userID uint64) {
|
|
|
|
|
if s.gateway == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
members, err := s.store.ListGuildMembers(ctx, guildID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"guild_id": formatSnowflake(guildID),
|
|
|
|
|
"channel_id": formatSnowflake(channelID),
|
|
|
|
|
"user_id": formatSnowflake(userID),
|
|
|
|
|
"left": true,
|
|
|
|
|
}
|
|
|
|
|
for _, member := range members {
|
|
|
|
|
s.gateway.SendToUser(member.UserID, "VOICE_STATE_UPDATE", payload)
|
|
|
|
|
}
|
|
|
|
|
}
|