package server import ( "context" "log/slog" "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", "голос на инстансе не настроен") } // Сохраняем состояние: пользователь мог перейти из другой комнаты. 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 output.Body.URL = s.cfg.LiveKitURL 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, "") // Мьют применяется на стороне SFU: клиент не может его обойти. if input.Body.ServerMute != nil { s.applyServerMute(ctx, state, *input.Body.ServerMute) } s.dispatchVoiceState(ctx, state) return s.voiceStatesOutput(ctx, guildID) }) 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) }) 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) } // На стороне SFU участник должен переподключиться в новую комнату. s.removeFromLiveKit(ctx, guildID, current.ChannelID, targetID) s.recordAudit(ctx, actor, guildID, "voice.move", "user", &targetID, "") s.dispatchVoiceState(ctx, state) return s.voiceStatesOutput(ctx, guildID) }) } // 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)) } } // 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) } }