diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl index ed55ff0..1176a35 100644 --- a/deploy/compose.yaml.tpl +++ b/deploy/compose.yaml.tpl @@ -24,6 +24,10 @@ x-app-env: &app-env GOMAXPROCS: "${GOMAXPROCS}" MAX_UPLOAD_SIZE: "${MAX_UPLOAD_SIZE}" SHUTDOWN_TIMEOUT_SECONDS: "20" + # LiveKit: приложение выдаёт клиентам токены доступа к комнатам. + LIVEKIT_API_KEY: "${LIVEKIT_API_KEY}" + LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET}" + LIVEKIT_URL: "${LIVEKIT_PUBLIC_URL}" services: app: diff --git a/deploy/install.sh b/deploy/install.sh index 017c983..6c96b97 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -355,6 +355,14 @@ resolve_derived_values() { GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR}" GLCHAT_CADDY_PROFILE="${CADDY_PROFILE}" GLCHAT_TURN_CERT_DIR="${TURN_CERT_DIR}" + # Публичный адрес LiveKit: клиенты ходят через тот же домен по /rtc. + if [ -z "${LIVEKIT_PUBLIC_URL:-}" ]; then + if [ "${TLS_ENABLED}" = "true" ]; then + LIVEKIT_PUBLIC_URL="wss://${DOMAIN}/rtc" + else + LIVEKIT_PUBLIC_URL="ws://${DOMAIN}/rtc" + fi + fi # TLS считается включённым и когда сертификаты обслуживает внешний прокси # (--external-proxy --tls-terminated-by-proxy): ссылки в API должны быть https/wss. if [ "$SKIP_TLS" = "1" ] && [ "$TLS_BY_PROXY" != "true" ]; then @@ -728,6 +736,8 @@ TURN_EXTERNAL_TLS=@TURN_EXTERNAL_TLS@ TURN_RELAY_START=@TURN_RELAY_START@ TURN_RELAY_END=@TURN_RELAY_END@ TURN_RELAY_HOST=@TURN_RELAY_HOST@ +# Публичный адрес LiveKit для клиентов (через прокси: /rtc). +LIVEKIT_PUBLIC_URL=@LIVEKIT_PUBLIC_URL@ TURN_RELAY_PORT=@TURN_RELAY_PORT@ TURN_RELAY_TLS_PORT=@TURN_RELAY_TLS_PORT@ @@ -828,6 +838,7 @@ TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY} WEBHOOK_SECRET=${WEBHOOK_SECRET} LIVEKIT_API_KEY=${LIVEKIT_API_KEY} LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET} +LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL} AGE_RECIPIENT=${AGE_RECIPIENT:-} SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL} diff --git a/internal/config/config.go b/internal/config/config.go index 853f517..193007f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,11 +37,15 @@ type Config struct { Argon2MemoryKiB int Argon2Iterations int Argon2Parallelism int - Version string - Commit string - BuildDate string - ShutdownTTL time.Duration - Maintenance time.Duration + // LiveKit: голос, видео и шаринг экрана (AGENT.md 7.14). + LiveKitAPIKey string + LiveKitAPISecret string + LiveKitURL string + Version string + Commit string + BuildDate string + ShutdownTTL time.Duration + Maintenance time.Duration } func Load() (Config, error) { @@ -67,6 +71,9 @@ func Load() (Config, error) { Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456), Argon2Iterations: envInt("ARGON2_ITERATIONS", 2), Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1), + LiveKitAPIKey: env("LIVEKIT_API_KEY", ""), + LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""), + LiveKitURL: env("LIVEKIT_URL", ""), Version: env("APP_VERSION", "dev"), Commit: env("APP_COMMIT", "none"), BuildDate: env("APP_BUILD_DATE", "unknown"), @@ -113,6 +120,11 @@ func (c Config) AllowedOrigins() []string { return origins } +// VoiceEnabled сообщает, настроен ли LiveKit: без ключей голос выключен. +func (c Config) VoiceEnabled() bool { + return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" +} + func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } diff --git a/internal/database/migrations/00009_voice_states.sql b/internal/database/migrations/00009_voice_states.sql new file mode 100644 index 0000000..24e720e --- /dev/null +++ b/internal/database/migrations/00009_voice_states.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- Голосовые состояния: кто в какой голосовой комнате и с какими флагами +-- (AGENT.md 7.14). Хранится по одной записи на пользователя в сервере. +CREATE TABLE voice_states ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + session_id TEXT NOT NULL DEFAULT '', + self_mute INTEGER NOT NULL DEFAULT 0, + self_deaf INTEGER NOT NULL DEFAULT 0, + server_mute INTEGER NOT NULL DEFAULT 0, + server_deaf INTEGER NOT NULL DEFAULT 0, + camera INTEGER NOT NULL DEFAULT 0, + screen INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (user_id, guild_id) +); +CREATE INDEX voice_states_channel_idx ON voice_states (channel_id); + +-- +goose Down +DROP TABLE voice_states; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 784146e..b3d8d69 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -79,6 +79,20 @@ type ReadyGuild struct { MyPerms []string `json:"my_permissions"` // Emojis — кастомные эмодзи сервера (AGENT.md 7.12). Emojis []ReadyEmoji `json:"emojis"` + // VoiceStates — кто находится в голосовых комнатах (AGENT.md 7.14). + VoiceStates []ReadyVoiceState `json:"voice_states"` +} + +// ReadyVoiceState — участник голосовой комнаты в снапшоте. +type ReadyVoiceState struct { + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + 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"` } // ReadyEmoji — эмодзи сервера в снапшоте: имя, файл и токен для вставки. diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 0e317af..2032827 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -128,16 +128,17 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) { readyGuild := ReadyGuild{ - ID: formatID(guild.ID), - Name: guild.Name, - OwnerID: formatID(guild.OwnerID), - IsMain: guild.IsMain, - Channels: []ReadyChannel{}, - Roles: []ReadyRole{}, - MemberIDs: []string{}, - MyRoles: []string{}, - MyPerms: []string{}, - Emojis: []ReadyEmoji{}, + ID: formatID(guild.ID), + Name: guild.Name, + OwnerID: formatID(guild.OwnerID), + IsMain: guild.IsMain, + Channels: []ReadyChannel{}, + Roles: []ReadyRole{}, + MemberIDs: []string{}, + MyRoles: []string{}, + MyPerms: []string{}, + Emojis: []ReadyEmoji{}, + VoiceStates: []ReadyVoiceState{}, } // Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте. @@ -159,6 +160,24 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor }) } + // Кто сейчас в голосовых комнатах сервера. + voiceStates, err := s.store.ListVoiceStates(ctx, guild.ID) + if err != nil { + return readyGuild, err + } + for _, state := range voiceStates { + readyGuild.VoiceStates = append(readyGuild.VoiceStates, ReadyVoiceState{ + UserID: formatID(state.UserID), + ChannelID: formatID(state.ChannelID), + SelfMute: state.SelfMute, + SelfDeaf: state.SelfDeaf, + ServerMute: state.ServerMute, + ServerDeaf: state.ServerDeaf, + Camera: state.Camera, + Screen: state.Screen, + }) + } + roles, err := s.store.ListGuildRoles(ctx, guild.ID) if err != nil { return readyGuild, err diff --git a/internal/meta/meta.go b/internal/meta/meta.go index b3132a0..2f0188b 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -8,9 +8,11 @@ type Features struct { RegistrationEnabled bool `json:"registration_enabled"` AntiBotEnabled bool `json:"anti_bot_enabled"` VoiceEnabled bool `json:"voice_enabled"` - WebPushEnabled bool `json:"web_push_enabled"` - OAuthEnabled bool `json:"oauth_enabled"` - PasskeysEnabled bool `json:"passkeys_enabled"` + // VoiceURL — адрес LiveKit для клиента (ws/wss), пусто при выключенном голосе. + VoiceURL string `json:"voice_url,omitempty"` + WebPushEnabled bool `json:"web_push_enabled"` + OAuthEnabled bool `json:"oauth_enabled"` + PasskeysEnabled bool `json:"passkeys_enabled"` } type Response struct { @@ -42,7 +44,8 @@ func New(cfg config.Config) Response { Features: Features{ RegistrationEnabled: true, AntiBotEnabled: false, - VoiceEnabled: false, + VoiceEnabled: cfg.VoiceEnabled(), + VoiceURL: cfg.LiveKitURL, WebPushEnabled: false, OAuthEnabled: false, PasskeysEnabled: false, diff --git a/internal/server/api_voice.go b/internal/server/api_voice.go new file mode 100644 index 0000000..14858a8 --- /dev/null +++ b/internal/server/api_voice.go @@ -0,0 +1,439 @@ +package server + +import ( + "context" + "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, "") + s.dispatchVoiceState(ctx, state) + 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) + } + s.recordAudit(ctx, actor, guildID, "voice.move", "user", &targetID, "") + s.dispatchVoiceState(ctx, state) + return s.voiceStatesOutput(ctx, guildID) + }) +} + +// 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) + } +} diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index b8975b2..1fa1a53 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -10,10 +10,13 @@ import ( "net/textproto" "net/url" "strconv" + "strings" "testing" + "time" "glchat/internal/permissions" "glchat/internal/store" + "glchat/internal/voice" ) // storeOverride скрывает комнату от роли @user. @@ -932,3 +935,121 @@ func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, gui recorder *httptest.ResponseRecorder }{status: resp.StatusCode, body: string(payload), recorder: recorder} } + +// TestVoiceStateFlow проверяет вход в голосовую комнату, флаги и модерацию. +func TestVoiceStateFlow(t *testing.T) { + f := newMessagingFixture(t) + + // Создаём голосовую комнату и добавляем участника. + voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", + `{"name":"Голосовая","type":"voice","user_limit":2}`, f.ownerCookie) + voiceChannel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, voiceRec) + + // Без ключей LiveKit вход недоступен с понятной ошибкой. + join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie) + if join.Code != http.StatusServiceUnavailable { + t.Fatalf("join без LiveKit = %d, want 503", join.Code) + } + + // Включаем голос: ключи подставляем в конфиг тестового сервера. + f.srv.cfg.LiveKitAPIKey = "testkey" + f.srv.cfg.LiveKitAPISecret = "testsecret" + f.srv.cfg.LiveKitURL = "wss://gl.test/rtc" + f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour) + + join = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie) + if join.Code != http.StatusOK { + t.Fatalf("join = %d, body = %s", join.Code, join.Body.String()) + } + payload := decodeResponse[struct { + Token string `json:"token"` + URL string `json:"url"` + Room string `json:"room"` + ChannelID string `json:"channel_id"` + State struct { + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + SelfMute bool `json:"self_mute"` + Camera bool `json:"camera"` + } `json:"state"` + }](t, join) + if payload.Token == "" || payload.URL != "wss://gl.test/rtc" { + t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL) + } + if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID { + t.Fatalf("состояние = %+v", payload.State) + } + if !strings.HasPrefix(payload.Room, "guild_") || !strings.Contains(payload.Room, "channel_") { + t.Fatalf("имя комнаты = %q", payload.Room) + } + + // Свои флаги: микрофон и камера. + flags := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/@me", + `{"self_mute":true,"camera":true}`, f.memberCookie) + if flags.Code != http.StatusOK { + t.Fatalf("flags = %d, body = %s", flags.Code, flags.Body.String()) + } + states := decodeResponse[struct { + States []struct { + UserID string `json:"user_id"` + SelfMute bool `json:"self_mute"` + Camera bool `json:"camera"` + } `json:"voice_states"` + }](t, flags) + if len(states.States) != 1 || !states.States[0].SelfMute || !states.States[0].Camera { + t.Fatalf("states = %+v", states.States) + } + + // Модерация: у участника нет MUTE_MEMBERS — серверный мьют запрещён. + denied := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.ownerID, `{"server_mute":true}`, f.memberCookie) + if denied.Code != http.StatusForbidden { + t.Fatalf("member mute = %d, want 403", denied.Code) + } + muted := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID, `{"server_mute":true}`, f.ownerCookie) + if muted.Code != http.StatusOK { + t.Fatalf("owner mute = %d, body = %s", muted.Code, muted.Body.String()) + } + + // Перемещение и выход. + secondRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", + `{"name":"Вторая","type":"voice"}`, f.ownerCookie) + second := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, secondRec) + move := doJSON(t, f.srv, http.MethodPost, + "/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID+"/move", + `{"channel_id":"`+second.Channel.ID+`"}`, f.ownerCookie) + if move.Code != http.StatusOK { + t.Fatalf("move = %d, body = %s", move.Code, move.Body.String()) + } + moved := decodeResponse[struct { + States []struct { + ChannelID string `json:"channel_id"` + } `json:"voice_states"` + }](t, move) + if len(moved.States) != 1 || moved.States[0].ChannelID != second.Channel.ID { + t.Fatalf("после перемещения = %+v", moved.States) + } + + leave := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+second.Channel.ID+"/voice/leave", "", f.memberCookie) + if leave.Code != http.StatusOK { + t.Fatalf("leave = %d, body = %s", leave.Code, leave.Body.String()) + } + after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) + empty := decodeResponse[struct { + States []struct { + UserID string `json:"user_id"` + } `json:"voice_states"` + }](t, after) + if len(empty.States) != 0 { + t.Fatalf("после выхода = %+v", empty.States) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index edf7864..f9a4125 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "glchat/internal/permissions" "glchat/internal/source" "glchat/internal/store" + "glchat/internal/voice" ) // Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации. @@ -59,6 +60,8 @@ type Server struct { // отправки; словарь ограничен по размеру (AGENT.md 7.5). slowmodeMu sync.Mutex slowmode map[string]time.Time + // voice — подписыватель токенов LiveKit (AGENT.md 7.14). + voice *voice.TokenIssuer // presence — время последнего обновления last_seen по пользователю. presenceMu sync.Mutex presence map[uint64]time.Time @@ -89,6 +92,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), slowmode: map[string]time.Time{}, presence: map[uint64]time.Time{}, + voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour), } switch { case deps.Permissions != nil: @@ -114,6 +118,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerFileRoutes(s.api, apiRouter) s.registerSocialRoutes(s.api) s.registerEmojiRoutes(s.api, apiRouter) + s.registerVoiceRoutes(s.api) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/store/voice.go b/internal/store/voice.go new file mode 100644 index 0000000..d11587e --- /dev/null +++ b/internal/store/voice.go @@ -0,0 +1,139 @@ +package store + +import ( + "context" + "time" +) + +// VoiceState — присутствие пользователя в голосовой комнате (AGENT.md 7.14). +type VoiceState struct { + UserID uint64 + GuildID uint64 + ChannelID uint64 + SessionID string + SelfMute bool + SelfDeaf bool + ServerMute bool + ServerDeaf bool + Camera bool + Screen bool + JoinedAt time.Time + UpdatedAt time.Time +} + +// VoiceStateParams — параметры записи голосового состояния. +type VoiceStateParams struct { + UserID uint64 + GuildID uint64 + ChannelID uint64 + SessionID string + SelfMute bool + SelfDeaf bool + ServerMute bool + ServerDeaf bool + Camera bool + Screen bool +} + +const voiceStateColumns = `user_id, guild_id, channel_id, session_id, self_mute, self_deaf, + server_mute, server_deaf, camera, screen, joined_at, updated_at` + +// UpsertVoiceState записывает состояние: пользователь входит в комнату или +// обновляет флаги (микрофон, камера, шаринг экрана). +func (s *Store) UpsertVoiceState(ctx context.Context, params VoiceStateParams) (*VoiceState, error) { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO voice_states (user_id, guild_id, channel_id, session_id, self_mute, self_deaf, + server_mute, server_deaf, camera, screen, joined_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, guild_id) DO UPDATE SET + channel_id = excluded.channel_id, + session_id = excluded.session_id, + self_mute = excluded.self_mute, + self_deaf = excluded.self_deaf, + server_mute = excluded.server_mute, + server_deaf = excluded.server_deaf, + camera = excluded.camera, + screen = excluded.screen, + updated_at = excluded.updated_at`, + int64(params.UserID), int64(params.GuildID), int64(params.ChannelID), params.SessionID, + boolToInt(params.SelfMute), boolToInt(params.SelfDeaf), boolToInt(params.ServerMute), + boolToInt(params.ServerDeaf), boolToInt(params.Camera), boolToInt(params.Screen), + s.Now(), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetVoiceState(ctx, params.GuildID, params.UserID) +} + +// GetVoiceState возвращает состояние пользователя в сервере. +func (s *Store) GetVoiceState(ctx context.Context, guildID, userID uint64) (*VoiceState, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+voiceStateColumns+` FROM voice_states WHERE guild_id = ? AND user_id = ?`, + int64(guildID), int64(userID)) + return scanVoiceState(row) +} + +// ListVoiceStates отдаёт состояния всех участников сервера. +func (s *Store) ListVoiceStates(ctx context.Context, guildID uint64) ([]VoiceState, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+voiceStateColumns+` FROM voice_states WHERE guild_id = ? ORDER BY joined_at`, int64(guildID)) + if err != nil { + return nil, err + } + defer rows.Close() + + states := make([]VoiceState, 0, 8) + for rows.Next() { + state, err := scanVoiceState(rows) + if err != nil { + return nil, err + } + states = append(states, *state) + } + return states, rows.Err() +} + +// CountChannelVoiceStates считает участников комнаты (лимит user_limit). +func (s *Store) CountChannelVoiceStates(ctx context.Context, channelID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM voice_states WHERE channel_id = ?`, int64(channelID)).Scan(&count) + return count, err +} + +// DeleteVoiceState убирает пользователя из голосовой комнаты. +func (s *Store) DeleteVoiceState(ctx context.Context, guildID, userID uint64) error { + _, err := s.writer.ExecContext(ctx, + `DELETE FROM voice_states WHERE guild_id = ? AND user_id = ?`, int64(guildID), int64(userID)) + return err +} + +// DeleteVoiceStatesForChannel убирает всех из комнаты (при её удалении). +func (s *Store) DeleteVoiceStatesForChannel(ctx context.Context, channelID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM voice_states WHERE channel_id = ?`, int64(channelID)) + return err +} + +func scanVoiceState(scanner interface{ Scan(...any) error }) (*VoiceState, error) { + var ( + state VoiceState + selfMute, selfDeaf int + serverMute, serverDeaf int + camera, screen int + joinedAt, updatedAt string + ) + err := scanner.Scan(&state.UserID, &state.GuildID, &state.ChannelID, &state.SessionID, + &selfMute, &selfDeaf, &serverMute, &serverDeaf, &camera, &screen, &joinedAt, &updatedAt) + if err != nil { + return nil, mapError(err) + } + state.SelfMute = selfMute == 1 + state.SelfDeaf = selfDeaf == 1 + state.ServerMute = serverMute == 1 + state.ServerDeaf = serverDeaf == 1 + state.Camera = camera == 1 + state.Screen = screen == 1 + state.JoinedAt = parseTimestamp(joinedAt) + state.UpdatedAt = parseTimestamp(updatedAt) + return &state, nil +} diff --git a/internal/voice/token.go b/internal/voice/token.go new file mode 100644 index 0000000..b4c2750 --- /dev/null +++ b/internal/voice/token.go @@ -0,0 +1,146 @@ +// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния +// (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей. +package voice + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +// ErrDisabled — LiveKit не настроен: ключи не заданы. +var ErrDisabled = errors.New("voice.disabled") + +// TokenIssuer подписывает токены доступа LiveKit (HS256). +type TokenIssuer struct { + apiKey string + apiSecret string + ttl time.Duration + now func() time.Time +} + +// NewIssuer создаёт подписывателя токенов. +func NewIssuer(apiKey, apiSecret string, ttl time.Duration) *TokenIssuer { + if ttl <= 0 { + ttl = time.Hour + } + return &TokenIssuer{apiKey: apiKey, apiSecret: apiSecret, ttl: ttl, now: time.Now} +} + +// SetClock подменяет источник времени (тесты). +func (i *TokenIssuer) SetClock(now func() time.Time) { i.now = now } + +// Enabled сообщает, настроен ли LiveKit. +func (i *TokenIssuer) Enabled() bool { + return i != nil && i.apiKey != "" && i.apiSecret != "" +} + +// Grants — права участника комнаты LiveKit. +type Grants struct { + RoomJoin bool `json:"roomJoin"` + CanPublish bool `json:"canPublish"` + CanSubscribe bool `json:"canSubscribe"` + CanPublishData bool `json:"canPublishData"` + CanPublishAudio bool `json:"canPublishSources,omitempty"` +} + +// Claims — полезная нагрузка токена LiveKit. +type Claims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Name string `json:"name,omitempty"` + Metadata string `json:"metadata,omitempty"` + Video struct { + Room string `json:"room"` + RoomJoin bool `json:"roomJoin"` + CanPublish bool `json:"canPublish"` + CanSubscribe bool `json:"canSubscribe"` + CanPublishData bool `json:"canPublishData"` + RoomAdmin bool `json:"roomAdmin,omitempty"` + Hidden bool `json:"hidden,omitempty"` + Recorder bool `json:"recorder,omitempty"` + } `json:"video"` + IssuedAt int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + ExpiresAt int64 `json:"exp"` +} + +// Issue собирает и подписывает токен доступа к комнате. +func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (string, error) { + if !i.Enabled() { + return "", ErrDisabled + } + now := i.now().UTC() + claims := Claims{Issuer: i.apiKey, Subject: identity, Name: displayName} + claims.Video.Room = room + claims.Video.RoomJoin = grants.RoomJoin + claims.Video.CanPublish = grants.CanPublish + claims.Video.CanSubscribe = grants.CanSubscribe + claims.Video.CanPublishData = grants.CanPublishData + claims.IssuedAt = now.Unix() + claims.NotBefore = now.Add(-10 * time.Second).Unix() + claims.ExpiresAt = now.Add(i.ttl).Unix() + + header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + if err != nil { + return "", err + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + signingInput := encodeSegment(header) + "." + encodeSegment(payload) + mac := hmac.New(sha256.New, []byte(i.apiSecret)) + if _, err := mac.Write([]byte(signingInput)); err != nil { + return "", err + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +// RoomName собирает имя комнаты LiveKit: комнаты инстанса не пересекаются. +func RoomName(guildID, channelID uint64) string { + return fmt.Sprintf("guild_%d_channel_%d", guildID, channelID) +} + +// ParseRoomName разбирает имя комнаты, если нужно понять, куда подключён клиент. +func ParseRoomName(room string) (guildID, channelID uint64, ok bool) { + if _, err := fmt.Sscanf(room, "guild_%d_channel_%d", &guildID, &channelID); err != nil { + return 0, 0, false + } + return guildID, channelID, true +} + +func encodeSegment(payload []byte) string { + return base64.RawURLEncoding.EncodeToString(payload) +} + +// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется +// тем же доменом, поэтому оставляем как есть, но проверяем схему. +func ResolveLiveKitURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", ErrDisabled + } + if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") { + return "", fmt.Errorf("voice.invalid_url: %s", trimmed) + } + return strings.TrimSuffix(trimmed, "/"), nil +} + +// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать +// клиент: пусто, если голос не настроен. +func (i *TokenIssuer) VoiceURLForClient(publicURL string) string { + if !i.Enabled() { + return "" + } + resolved, err := ResolveLiveKitURL(publicURL) + if err != nil { + return "" + } + return resolved +} diff --git a/internal/voice/token_test.go b/internal/voice/token_test.go new file mode 100644 index 0000000..e6b4256 --- /dev/null +++ b/internal/voice/token_test.go @@ -0,0 +1,91 @@ +package voice_test + +import ( + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "glchat/internal/voice" +) + +func TestIssueTokenHasLiveKitClaims(t *testing.T) { + issuer := voice.NewIssuer("key123", "secret456", time.Hour) + issuer.SetClock(func() time.Time { return time.Unix(1_700_000_000, 0) }) + + token, err := issuer.Issue(voice.RoomName(10, 20), "42", "Алиса", voice.Grants{ + RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true, + }) + if err != nil { + t.Fatalf("Issue: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts)) + } + header, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("decode header: %v", err) + } + if !strings.Contains(string(header), "HS256") { + t.Fatalf("header = %s", header) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var claims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Name string `json:"name"` + Video struct { + Room string `json:"room"` + RoomJoin bool `json:"roomJoin"` + CanPublish bool `json:"canPublish"` + } `json:"video"` + ExpiresAt int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + t.Fatalf("decode claims: %v", err) + } + if claims.Issuer != "key123" || claims.Subject != "42" || claims.Name != "Алиса" { + t.Fatalf("claims = %+v", claims) + } + if claims.Video.Room != "guild_10_channel_20" || !claims.Video.RoomJoin || !claims.Video.CanPublish { + t.Fatalf("video grants = %+v", claims.Video) + } + if claims.ExpiresAt != 1_700_000_000+3600 { + t.Fatalf("exp = %d", claims.ExpiresAt) + } +} + +func TestIssuerDisabledWithoutKeys(t *testing.T) { + issuer := voice.NewIssuer("", "", time.Hour) + if issuer.Enabled() { + t.Fatal("пустые ключи должны означать выключенный голос") + } + if _, err := issuer.Issue("room", "1", "user", voice.Grants{}); err == nil { + t.Fatal("Issue без ключей должен возвращать ошибку") + } +} + +func TestParseRoomName(t *testing.T) { + guildID, channelID, ok := voice.ParseRoomName("guild_123_channel_456") + if !ok || guildID != 123 || channelID != 456 { + t.Fatalf("ParseRoomName = %d/%d/%t", guildID, channelID, ok) + } + if _, _, ok := voice.ParseRoomName("что-то другое"); ok { + t.Fatal("посторонняя строка не должна разбираться как имя комнаты") + } +} + +func TestResolveLiveKitURL(t *testing.T) { + url, err := voice.ResolveLiveKitURL("wss://gl.example/rtc/") + if err != nil || url != "wss://gl.example/rtc" { + t.Fatalf("ResolveLiveKitURL = %q, %v", url, err) + } + if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil { + t.Fatal("http-адрес должен отклоняться") + } +}