diff --git a/deploy/compose.yaml.tpl b/deploy/compose.yaml.tpl index 1176a35..c68dcc0 100644 --- a/deploy/compose.yaml.tpl +++ b/deploy/compose.yaml.tpl @@ -28,6 +28,8 @@ x-app-env: &app-env LIVEKIT_API_KEY: "${LIVEKIT_API_KEY}" LIVEKIT_API_SECRET: "${LIVEKIT_API_SECRET}" LIVEKIT_URL: "${LIVEKIT_PUBLIC_URL}" + # Внутренний адрес SFU для RoomService (модерация голоса). + LIVEKIT_API_URL: "http://livekit:${LIVEKIT_WS_PORT}" services: app: diff --git a/internal/config/config.go b/internal/config/config.go index 00c898b..7555da4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,9 @@ type Config struct { LiveKitAPIKey string LiveKitAPISecret string LiveKitURL string + // LiveKitAPIURL — внутренний HTTP-адрес SFU для RoomService (модерация): + // в compose это http://livekit:7880, снаружи не публикуется. + LiveKitAPIURL string // LiveKitTokenTTL — время жизни токена доступа к комнате (AGENT.md 7.14: // по умолчанию 10 минут, клиент переиздаёт токен до истечения). LiveKitTokenTTL time.Duration @@ -77,6 +80,7 @@ func Load() (Config, error) { LiveKitAPIKey: env("LIVEKIT_API_KEY", ""), LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""), LiveKitURL: env("LIVEKIT_URL", ""), + LiveKitAPIURL: env("LIVEKIT_API_URL", "http://livekit:7880"), Version: env("APP_VERSION", "dev"), Commit: env("APP_COMMIT", "none"), BuildDate: env("APP_BUILD_DATE", "unknown"), diff --git a/internal/database/migrations/00010_guild_sounds.sql b/internal/database/migrations/00010_guild_sounds.sql new file mode 100644 index 0000000..eecfc20 --- /dev/null +++ b/internal/database/migrations/00010_guild_sounds.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- Звуки сервера (AGENT.md 7.13): саундборд для голосовых комнат и звуковая +-- палитра интерфейса (звук на событие). +CREATE TABLE guild_sounds ( + id INTEGER PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + name TEXT NOT NULL, + file_id INTEGER NOT NULL REFERENCES files (id) ON DELETE CASCADE, + kind TEXT NOT NULL DEFAULT 'soundboard', + event TEXT NOT NULL DEFAULT '', + emoji TEXT NOT NULL DEFAULT '', + creator_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE UNIQUE INDEX guild_sounds_name_idx ON guild_sounds (guild_id, kind, name); +CREATE INDEX guild_sounds_guild_idx ON guild_sounds (guild_id, kind); + +-- +goose Down +DROP TABLE guild_sounds; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index b3d8d69..32b5f9a 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -81,6 +81,18 @@ type ReadyGuild struct { Emojis []ReadyEmoji `json:"emojis"` // VoiceStates — кто находится в голосовых комнатах (AGENT.md 7.14). VoiceStates []ReadyVoiceState `json:"voice_states"` + // Sounds — саундборд и звуковая палитра сервера (AGENT.md 7.13). + Sounds []ReadySound `json:"sounds"` +} + +// ReadySound — звук сервера в снапшоте. +type ReadySound struct { + ID string `json:"id"` + Name string `json:"name"` + FileID string `json:"file_id"` + Kind string `json:"kind"` + Event string `json:"event,omitempty"` + Emoji string `json:"emoji,omitempty"` } // ReadyVoiceState — участник голосовой комнаты в снапшоте. diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 2032827..143b6f6 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -139,6 +139,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor MyPerms: []string{}, Emojis: []ReadyEmoji{}, VoiceStates: []ReadyVoiceState{}, + Sounds: []ReadySound{}, } // Кастомные эмодзи сервера: клиент показывает их в пикере и в тексте. @@ -160,6 +161,22 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor }) } + // Звуки сервера: саундборд и палитра интерфейса. + sounds, err := s.store.ListGuildSounds(ctx, guild.ID, "", 100) + if err != nil { + return readyGuild, err + } + for _, sound := range sounds { + readyGuild.Sounds = append(readyGuild.Sounds, ReadySound{ + ID: formatID(sound.ID), + Name: sound.Name, + FileID: formatID(sound.FileID), + Kind: string(sound.Kind), + Event: sound.Event, + Emoji: sound.Emoji, + }) + } + // Кто сейчас в голосовых комнатах сервера. voiceStates, err := s.store.ListVoiceStates(ctx, guild.ID) if err != nil { diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 9d9d307..63c9375 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -298,7 +298,7 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user * } // Аватары и баннеры видны всем авторизованным: они показываются в списках // участников и друзей (AGENT.md 7.2). - if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" { + if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" || file.Purpose == "sound" { return file, nil } if file.ChannelID != nil { diff --git a/internal/server/api_sounds.go b/internal/server/api_sounds.go new file mode 100644 index 0000000..2b19183 --- /dev/null +++ b/internal/server/api_sounds.go @@ -0,0 +1,426 @@ +package server + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// Лимиты звуков сервера (AGENT.md 7.13). +const ( + maxSoundSize = 512 << 10 + maxSoundboardSounds = 30 + maxUISounds = 30 +) + +// soundEventPattern — допустимые имена событий звуковой палитры. +var soundEventPattern = regexp.MustCompile(`^[a-z_]{3,32}$`) + +// soundNamePattern — имя звука (латиница, цифры, подчёркивания). +var soundNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_]{2,32}$`) + +type soundPayload struct { + ID string `json:"id"` + Name string `json:"name"` + FileID string `json:"file_id"` + Kind string `json:"kind"` + Event string `json:"event,omitempty"` + Emoji string `json:"emoji,omitempty"` + URL string `json:"url"` +} + +type soundListOutput struct { + Body struct { + Sounds []soundPayload `json:"sounds"` + } +} + +type soundOutput struct { + Body struct { + Sound soundPayload `json:"sound"` + } +} + +// registerSoundsRoutes описывает саундборд и звуковую палитру (AGENT.md 7.13). +func (s *Server) registerSoundsRoutes(api huma.API, router chi.Router) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildSounds", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/sounds", + Summary: "Звуки сервера (саундборд и палитра интерфейса)", + Tags: []string{"Sounds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Kind string `query:"kind,omitempty" enum:",soundboard,ui"` + }, + ) (*soundListOutput, 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 + } + sounds, err := s.store.ListGuildSounds(ctx, guildID, store.SoundKind(input.Kind), 100) + if err != nil { + return nil, humaError(err) + } + output := &soundListOutput{} + output.Body.Sounds = make([]soundPayload, 0, len(sounds)) + for i := range sounds { + output.Body.Sounds = append(output.Body.Sounds, s.soundPayload(&sounds[i])) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateGuildSound", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/sounds/{sound_id}", + Summary: "Переименовать звук", + Tags: []string{"Sounds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + SoundID string `path:"sound_id"` + Body struct { + Name string `json:"name" minLength:"2" maxLength:"32"` + Emoji string `json:"emoji,omitempty" maxLength:"8"` + } + }, + ) (*soundOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageSounds) + if err != nil { + return nil, err + } + soundID, err := parseID("sound_id", input.SoundID) + if err != nil { + return nil, err + } + sound, err := s.store.GetGuildSound(ctx, soundID) + if err != nil { + return nil, humaError(err) + } + if sound.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found") + } + name := strings.TrimSpace(input.Body.Name) + if !soundNamePattern.MatchString(name) { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "имя звука: латиница, цифры и подчёркивания (2–32)") + } + updated, err := s.store.RenameGuildSound(ctx, soundID, name, input.Body.Emoji) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return nil, humaErrorStatus(http.StatusConflict, "sound.name_taken", "звук с таким именем уже есть") + } + return nil, humaError(err) + } + s.recordAudit(ctx, user, guildID, "sound.update", "sound", &soundID, "") + s.dispatchSoundsUpdate(ctx, guildID) + output := &soundOutput{} + output.Body.Sound = s.soundPayload(updated) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteGuildSound", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/sounds/{sound_id}", + Summary: "Удалить звук", + Tags: []string{"Sounds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + SoundID string `path:"sound_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageSounds) + if err != nil { + return nil, err + } + soundID, err := parseID("sound_id", input.SoundID) + if err != nil { + return nil, err + } + sound, err := s.store.GetGuildSound(ctx, soundID) + if err != nil { + return nil, humaError(err) + } + if sound.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found") + } + if err := s.store.DeleteGuildSound(ctx, soundID); err != nil { + return nil, humaError(err) + } + s.deleteStoredFile(ctx, sound.FileID) + s.recordAudit(ctx, user, guildID, "sound.delete", "sound", &soundID, "") + s.dispatchSoundsUpdate(ctx, guildID) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "playSoundboardSound", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/sounds/{sound_id}/play", + Summary: "Проиграть звук саундборда в своей голосовой комнате", + Tags: []string{"Sounds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + SoundID string `path:"sound_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.UseSoundboard) + if err != nil { + return nil, err + } + soundID, err := parseID("sound_id", input.SoundID) + if err != nil { + return nil, err + } + sound, err := s.store.GetGuildSound(ctx, soundID) + if err != nil { + return nil, humaError(err) + } + if sound.GuildID != guildID || sound.Kind != store.SoundKindSoundboard { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "sound not found") + } + // Играть можно только из голосовой комнаты: звук слышат её участники. + voiceState, err := s.store.GetVoiceState(ctx, guildID, user.ID) + if err != nil { + return nil, humaErrorStatus(http.StatusForbidden, "voice.not_connected", "сначала войдите в голосовую комнату") + } + _ = resolved + if err := s.checkSoundboardRate(user.ID); err != nil { + return nil, err + } + payload := map[string]any{ + "guild_id": formatSnowflake(guildID), + "channel_id": formatSnowflake(voiceState.ChannelID), + "sound_id": formatSnowflake(sound.ID), + "file_id": formatSnowflake(sound.FileID), + "name": sound.Name, + "user_id": formatSnowflake(user.ID), + "played_at": time.Now().UTC().Format(time.RFC3339), + } + // Событие получают только участники этой голосовой комнаты. + s.dispatchSoundboardPlay(ctx, guildID, voiceState.ChannelID, payload) + return newOKOutput(), nil + }) + + // Загрузка — multipart, поэтому chi-ручка. + router.Post("/guilds/{guild_id}/sounds", s.handleSoundUpload) +} + +// handleSoundUpload принимает звук саундборда или палитры (AGENT.md 7.13). +func (s *Server) handleSoundUpload(w http.ResponseWriter, r *http.Request) { + currentUser, _, ok := s.authenticate(w, r) + if !ok { + return + } + ctx := r.Context() + guildID, _, err := s.requireGuildPermission(ctx, chi.URLParam(r, "guild_id"), currentUser, permissions.ManageSounds) + if err != nil { + writeHumaAPIError(w, err) + return + } + + kind := store.SoundKindSoundboard + if value := strings.TrimSpace(r.URL.Query().Get("kind")); value != "" { + kind = store.SoundKind(value) + } + if kind != store.SoundKindSoundboard && kind != store.SoundKindUI { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", "kind: soundboard или ui") + return + } + limit := maxSoundboardSounds + if kind == store.SoundKindUI { + limit = maxUISounds + } + count, err := s.store.CountGuildSounds(ctx, guildID, kind) + if err != nil { + writeHumaAPIError(w, humaError(err)) + return + } + if count >= limit { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "sounds.limit_reached", + "на сервере достигнут лимит звуков") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxSoundSize+maxMultipartOverhead) + if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed multipart body") + return + } + defer func() { + if r.MultipartForm != nil { + _ = r.MultipartForm.RemoveAll() + } + }() + + name := strings.TrimSpace(r.FormValue("name")) + if !soundNamePattern.MatchString(name) { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", + "имя звука: латиница, цифры и подчёркивания (2–32)") + return + } + event := strings.TrimSpace(r.FormValue("event")) + if kind == store.SoundKindUI { + if !soundEventPattern.MatchString(event) { + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", + "для звука интерфейса укажите событие (например member_join)") + return + } + } + file, header, err := r.FormFile("file") + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", `multipart field "file" is required`) + return + } + defer func() { _ = file.Close() }() + if header.Size > maxSoundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ") + return + } + data, err := io.ReadAll(io.LimitReader(file, maxSoundSize+1)) + if err != nil || len(data) > maxSoundSize { + httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ") + return + } + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "audio/") && !strings.HasPrefix(contentType, "video/ogg") { + contentType = http.DetectContentType(data) + } + switch { + case strings.HasPrefix(contentType, "audio/"), + strings.HasPrefix(contentType, "video/ogg"), + strings.HasPrefix(contentType, "application/ogg"): + default: + httpxWriteJSONError(w, http.StatusUnprocessableEntity, "validation.failed", + "поддерживаются MP3, OGG, WAV, WebM и M4A") + return + } + + stored, err := s.saveUpload(ctx, store.File{ + UploaderID: ¤tUser.ID, + GuildID: &guildID, + Filename: sanitizeFilename(header.Filename), + ContentType: contentType, + Purpose: "sound", + }, bytes.NewReader(data)) + if err != nil { + writeHumaAPIError(w, err) + return + } + sound, err := s.store.CreateGuildSound(ctx, store.CreateGuildSoundParams{ + GuildID: guildID, + Name: name, + FileID: stored.ID, + Kind: kind, + Event: event, + Emoji: strings.TrimSpace(r.FormValue("emoji")), + CreatorID: currentUser.ID, + }) + if err != nil { + s.deleteStoredFile(ctx, stored.ID) + if errors.Is(err, store.ErrConflict) { + httpxWriteJSONError(w, http.StatusConflict, "sound.name_taken", "звук с таким именем уже есть") + return + } + writeHumaAPIError(w, humaError(err)) + return + } + s.recordAudit(ctx, currentUser, guildID, "sound.create", "sound", &sound.ID, "") + s.dispatchSoundsUpdate(ctx, guildID) + httpxWriteJSON(w, http.StatusOK, map[string]any{"sound": s.soundPayload(sound)}) +} + +// soundPayload собирает звук для API. +func (s *Server) soundPayload(sound *store.GuildSound) soundPayload { + return soundPayload{ + ID: formatSnowflake(sound.ID), + Name: sound.Name, + FileID: formatSnowflake(sound.FileID), + Kind: string(sound.Kind), + Event: sound.Event, + Emoji: sound.Emoji, + URL: s.cfg.FilesURL() + "/" + formatSnowflake(sound.FileID), + } +} + +// checkSoundboardRate ограничивает частоту проигрывания (AGENT.md 8.6). +func (s *Server) checkSoundboardRate(userID uint64) error { + if allowed, retryAfter := s.soundboardLimiter.Allow("sound:" + formatSnowflake(userID)); !allowed { + return rateLimitedError(retryAfter) + } + return nil +} + +// dispatchSoundboardPlay отправляет событие только участникам голосовой комнаты: +// звук воспроизводится локально, нагрузка на сервер нулевая (AGENT.md 7.13). +func (s *Server) dispatchSoundboardPlay(ctx context.Context, guildID, channelID uint64, payload map[string]any) { + if s.gateway == nil { + return + } + states, err := s.store.ListVoiceStates(ctx, guildID) + if err != nil { + return + } + for _, state := range states { + if state.ChannelID != channelID { + continue + } + s.gateway.SendToUser(state.UserID, "SOUNDBOARD_PLAY", payload) + } +} + +// dispatchSoundsUpdate сообщает участникам сервера об изменении набора звуков. +func (s *Server) dispatchSoundsUpdate(ctx context.Context, guildID uint64) { + if s.gateway == nil { + return + } + sounds, err := s.store.ListGuildSounds(ctx, guildID, "", 100) + if err != nil { + return + } + payload := make([]soundPayload, 0, len(sounds)) + for i := range sounds { + payload = append(payload, s.soundPayload(&sounds[i])) + } + members, err := s.store.ListGuildMembers(ctx, guildID) + if err != nil { + return + } + event := map[string]any{"guild_id": formatSnowflake(guildID), "sounds": payload} + for _, member := range members { + s.gateway.SendToUser(member.UserID, "GUILD_SOUNDS_UPDATE", event) + } +} diff --git a/internal/server/api_voice.go b/internal/server/api_voice.go index 14858a8..6bfbc6f 100644 --- a/internal/server/api_voice.go +++ b/internal/server/api_voice.go @@ -2,6 +2,7 @@ package server import ( "context" + "log/slog" "net/http" "time" @@ -282,10 +283,55 @@ func (s *Server) registerVoiceRoutes(api huma.API) { 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, @@ -347,12 +393,38 @@ func (s *Server) registerVoiceRoutes(api huma.API) { 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 { diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index 4e39a31..e1f4da7 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -1155,3 +1155,159 @@ func TestLiveKitWebhook(t *testing.T) { t.Fatalf("после participant_left = %+v", empty.States) } } + +// TestSoundboard проверяет загрузку звуков, права, лимит и событие проигрывания. +func TestSoundboard(t *testing.T) { + f := newMessagingFixture(t) + httpServer := httptest.NewServer(f.srv.Handler()) + t.Cleanup(httpServer.Close) + 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) + + voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", + `{"name":"Голос","type":"voice"}`, f.ownerCookie) + voiceChannel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, voiceRec) + + // Участник без MANAGE_SOUNDS не может загрузить звук. + denied := uploadSound(t, httpServer, f.memberCookie, f.guildID, "soundboard", "boo", "", []byte("OggS\x00звук")) + if denied.status != http.StatusForbidden { + t.Fatalf("member upload sound = %d, want 403", denied.status) + } + // Владелец загружает звук саундборда. + created := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "soundboard", "boo", "", []byte("OggS\x00звук")) + if created.status != http.StatusOK { + t.Fatalf("owner upload sound = %d, body = %s", created.status, created.body) + } + sound := decodeResponse[struct { + Sound struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + FileID string `json:"file_id"` + } `json:"sound"` + }](t, created.recorder) + if sound.Sound.Name != "boo" || sound.Sound.Kind != "soundboard" { + t.Fatalf("unexpected sound: %+v", sound.Sound) + } + // Звук интерфейса требует событие. + uiMissing := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "ui", "join_sound", "", []byte("OggS\x00звук")) + if uiMissing.status != http.StatusUnprocessableEntity { + t.Fatalf("ui sound без события = %d, want 422", uiMissing.status) + } + ui := uploadSound(t, httpServer, f.ownerCookie, f.guildID, "ui", "join_sound", "member_join", []byte("OggS\x00звук")) + if ui.status != http.StatusOK { + t.Fatalf("ui sound = %d, body = %s", ui.status, ui.body) + } + + // Список и фильтр по виду. + list := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/sounds?kind=soundboard", "", f.memberCookie) + sounds := decodeResponse[struct { + Sounds []struct { + Name string `json:"name"` + Kind string `json:"kind"` + } `json:"sounds"` + }](t, list) + if len(sounds.Sounds) != 1 || sounds.Sounds[0].Name != "boo" { + t.Fatalf("soundboard list = %+v", sounds.Sounds) + } + + // Проигрывание вне голосовой комнаты запрещено. + notConnected := doJSON(t, f.srv, http.MethodPost, + "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.memberCookie) + if notConnected.Code != http.StatusForbidden { + t.Fatalf("play вне комнаты = %d, want 403", notConnected.Code) + } + + // Владелец входит в голосовую комнату и играет звук. + join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.ownerCookie) + if join.Code != http.StatusOK { + t.Fatalf("join voice = %d, body = %s", join.Code, join.Body.String()) + } + play := doJSON(t, f.srv, http.MethodPost, + "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.ownerCookie) + if play.Code != http.StatusOK { + t.Fatalf("play = %d, body = %s", play.Code, play.Body.String()) + } + // Лимит частоты: 3 звука за 10 секунд. + blocked := (*httptest.ResponseRecorder)(nil) + for range 4 { + blocked = doJSON(t, f.srv, http.MethodPost, + "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID+"/play", "", f.ownerCookie) + } + if blocked.Code != http.StatusTooManyRequests { + t.Fatalf("четвёртый звук подряд = %d, want 429", blocked.Code) + } + + // Переименование и удаление. + renamed := doJSON(t, f.srv, http.MethodPatch, + "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID, `{"name":"boo2","emoji":"👻"}`, f.ownerCookie) + if renamed.Code != http.StatusOK { + t.Fatalf("rename sound = %d, body = %s", renamed.Code, renamed.Body.String()) + } + deleted := doJSON(t, f.srv, http.MethodDelete, + "/api/v1/guilds/"+f.guildID+"/sounds/"+sound.Sound.ID, "", f.ownerCookie) + if deleted.Code != http.StatusOK { + t.Fatalf("delete sound = %d", deleted.Code) + } +} + +// uploadSound загружает звук через multipart. +func uploadSound(t *testing.T, server *httptest.Server, cookie *http.Cookie, guildID, kind, name, event string, content []byte) struct { + status int + body string + recorder *httptest.ResponseRecorder +} { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.WriteField("name", name); err != nil { + t.Fatalf("write name: %v", err) + } + if event != "" { + if err := writer.WriteField("event", event); err != nil { + t.Fatalf("write event: %v", err) + } + } + header := textproto.MIMEHeader{} + header.Set("Content-Disposition", `form-data; name="file"; filename="`+name+`.ogg"`) + header.Set("Content-Type", "audio/ogg") + part, err := writer.CreatePart(header) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write sound: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + url := server.URL + "/api/v1/guilds/" + guildID + "/sounds?kind=" + kind + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(cookie) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("upload sound: %v", err) + } + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + recorder := httptest.NewRecorder() + recorder.WriteHeader(resp.StatusCode) + if _, err := recorder.Body.Write(payload); err != nil { + t.Fatalf("copy body: %v", err) + } + return struct { + status int + body string + recorder *httptest.ResponseRecorder + }{status: resp.StatusCode, body: string(payload), recorder: recorder} +} diff --git a/internal/server/server.go b/internal/server/server.go index f13519f..563d808 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -54,6 +54,8 @@ type Server struct { searchLimiter *httpx.RateLimiter // editLimiter — 10 правок сообщений в минуту (AGENT.md 7.6). editLimiter *httpx.RateLimiter + // soundboardLimiter — не чаще 3 звуков в 10 секунд на пользователя. + soundboardLimiter *httpx.RateLimiter // inviteLimiter — 10 приглашений в сутки на пользователя (AGENT.md 8.6). inviteLimiter *httpx.RateLimiter // slowmode — время последней отправки в комнату для режима медленной @@ -62,6 +64,8 @@ type Server struct { slowmode map[string]time.Time // voice — подписыватель токенов LiveKit (AGENT.md 7.14). voice *voice.TokenIssuer + // voiceAdmin — RoomService для модерации на стороне SFU. + voiceAdmin *voice.AdminClient // presence — время последнего обновления last_seen по пользователю. presenceMu sync.Mutex presence map[uint64]time.Time @@ -88,15 +92,17 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se authLimiter: httpx.NewRateLimiter(5, 5), apiLimiter: httpx.NewRateLimiter(120, 60), // 5 сообщений за 5 секунд (burst 10), typing 1/3 c, поиск 10/мин. - messageLimiter: httpx.NewRateLimiter(60, 10), - typingLimiter: httpx.NewRateLimiter(20, 1), - searchLimiter: httpx.NewRateLimiter(10, 10), - editLimiter: httpx.NewRateLimiter(10, 10), - inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), - slowmode: map[string]time.Time{}, - presence: map[uint64]time.Time{}, - webhookSeen: map[string]time.Time{}, - voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), + messageLimiter: httpx.NewRateLimiter(60, 10), + typingLimiter: httpx.NewRateLimiter(20, 1), + searchLimiter: httpx.NewRateLimiter(10, 10), + editLimiter: httpx.NewRateLimiter(10, 10), + soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3), + inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), + slowmode: map[string]time.Time{}, + presence: map[uint64]time.Time{}, + webhookSeen: map[string]time.Time{}, + voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), + voiceAdmin: voice.NewAdminClient(voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, cfg.LiveKitTokenTTL), cfg.LiveKitAPIURL), } switch { case deps.Permissions != nil: @@ -123,6 +129,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerSocialRoutes(s.api) s.registerEmojiRoutes(s.api, apiRouter) s.registerVoiceRoutes(s.api) + s.registerSoundsRoutes(s.api, apiRouter) s.registerVoiceWebhook(apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) diff --git a/internal/store/sounds.go b/internal/store/sounds.go new file mode 100644 index 0000000..761d979 --- /dev/null +++ b/internal/store/sounds.go @@ -0,0 +1,147 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// SoundKind — назначение звука: саундборд или звук интерфейса (AGENT.md 7.13). +type SoundKind string + +const ( + SoundKindSoundboard SoundKind = "soundboard" + SoundKindUI SoundKind = "ui" +) + +// GuildSound — звук сервера. +type GuildSound struct { + ID uint64 + GuildID uint64 + Name string + FileID uint64 + Kind SoundKind + Event string + Emoji string + CreatorID *uint64 + CreatedAt time.Time +} + +// CreateGuildSoundParams — параметры нового звука. +type CreateGuildSoundParams struct { + ID uint64 + GuildID uint64 + Name string + FileID uint64 + Kind SoundKind + Event string + Emoji string + CreatorID uint64 +} + +const guildSoundColumns = `id, guild_id, name, file_id, kind, event, emoji, creator_id, created_at` + +// CreateGuildSound регистрирует звук сервера. +func (s *Store) CreateGuildSound(ctx context.Context, params CreateGuildSoundParams) (*GuildSound, error) { + if params.ID == 0 { + params.ID = s.NextID() + } + if params.Kind == "" { + params.Kind = SoundKindSoundboard + } + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO guild_sounds (id, guild_id, name, file_id, kind, event, emoji, creator_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(params.ID), int64(params.GuildID), params.Name, int64(params.FileID), + string(params.Kind), params.Event, params.Emoji, int64(params.CreatorID), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetGuildSound(ctx, params.ID) +} + +func (s *Store) GetGuildSound(ctx context.Context, id uint64) (*GuildSound, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+guildSoundColumns+` FROM guild_sounds WHERE id = ?`, int64(id)) + return scanGuildSound(row) +} + +// ListGuildSounds отдаёт звуки сервера: all — оба вида, иначе только указанный. +func (s *Store) ListGuildSounds(ctx context.Context, guildID uint64, kind SoundKind, limit int) ([]GuildSound, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + query := `SELECT ` + guildSoundColumns + ` FROM guild_sounds WHERE guild_id = ?` + args := []any{int64(guildID)} + if kind != "" { + query += ` AND kind = ?` + args = append(args, string(kind)) + } + query += ` ORDER BY name LIMIT ?` + args = append(args, limit) + + rows, err := s.reader.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + sounds := make([]GuildSound, 0, 16) + for rows.Next() { + sound, err := scanGuildSound(rows) + if err != nil { + return nil, err + } + sounds = append(sounds, *sound) + } + return sounds, rows.Err() +} + +// CountGuildSounds считает звуки указанного вида (лимиты 30 + 30). +func (s *Store) CountGuildSounds(ctx context.Context, guildID uint64, kind SoundKind) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guild_sounds WHERE guild_id = ? AND kind = ?`, + int64(guildID), string(kind)).Scan(&count) + return count, err +} + +// RenameGuildSound меняет имя звука. +func (s *Store) RenameGuildSound(ctx context.Context, id uint64, name string, emoji string) (*GuildSound, error) { + result, err := s.writer.ExecContext(ctx, + `UPDATE guild_sounds SET name = ?, emoji = ? WHERE id = ?`, name, emoji, int64(id)) + if err != nil { + return nil, mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return nil, ErrNotFound + } + return s.GetGuildSound(ctx, id) +} + +// DeleteGuildSound удаляет звук сервера. +func (s *Store) DeleteGuildSound(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, `DELETE FROM guild_sounds WHERE id = ?`, int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +func scanGuildSound(scanner interface{ Scan(...any) error }) (*GuildSound, error) { + var ( + sound GuildSound + creatorID sql.NullInt64 + createdAt string + ) + err := scanner.Scan(&sound.ID, &sound.GuildID, &sound.Name, &sound.FileID, &sound.Kind, + &sound.Event, &sound.Emoji, &creatorID, &createdAt) + if err != nil { + return nil, mapError(err) + } + sound.CreatorID = optionalID(creatorID) + sound.CreatedAt = parseTimestamp(createdAt) + return &sound, nil +} diff --git a/internal/voice/admin.go b/internal/voice/admin.go new file mode 100644 index 0000000..7066c05 --- /dev/null +++ b/internal/voice/admin.go @@ -0,0 +1,148 @@ +package voice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// AdminClient обращается к RoomService LiveKit (Twirp/JSON) для модерации: +// серверный мьют дорожки и удаление участника из комнаты (AGENT.md 7.14). +// Собственный HTTP-клиент вместо SDK: нужны три метода из всего API. +type AdminClient struct { + issuer *TokenIssuer + baseURL string + http *http.Client +} + +// NewAdminClient создаёт клиента RoomService. Пустой baseURL означает, что +// модерация на стороне SFU недоступна (например, в тестах). +func NewAdminClient(issuer *TokenIssuer, baseURL string) *AdminClient { + return &AdminClient{ + issuer: issuer, + baseURL: strings.TrimSuffix(strings.TrimSpace(baseURL), "/"), + http: &http.Client{Timeout: 5 * time.Second}, + } +} + +// Enabled сообщает, настроен ли доступ к RoomService. +func (c *AdminClient) Enabled() bool { + return c != nil && c.baseURL != "" && c.issuer.Enabled() +} + +// Participant — участник комнаты на стороне SFU. +type Participant struct { + Identity string `json:"identity"` + SID string `json:"sid"` + Tracks []struct { + SID string `json:"sid"` + Type string `json:"type"` + Source string `json:"source"` + Muted bool `json:"muted"` + Name string `json:"name"` + } `json:"tracks"` +} + +// ParticipantInfo отдаёт данные участника комнаты. +func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) { + var response struct { + Participant Participant `json:"participant"` + } + err := c.call(ctx, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response) + if err != nil { + return nil, err + } + return &response.Participant, nil +} + +// SetMicrophoneMuted включает или выключает микрофон участника на стороне SFU: +// клиент физически не может обойти серверный мьют (AGENT.md 7.14). +func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity string, muted bool) error { + participant, err := c.ParticipantInfo(ctx, room, identity) + if err != nil { + return err + } + trackSID := "" + for _, track := range participant.Tracks { + if strings.EqualFold(track.Source, "MICROPHONE") || strings.EqualFold(track.Type, "AUDIO") { + trackSID = track.SID + break + } + } + if trackSID == "" { + // Участник без микрофона: мьютить нечего, это не ошибка. + return nil + } + return c.call(ctx, "MutePublishedTrack", map[string]any{ + "room": room, "identity": identity, "track_sid": trackSID, "muted": muted, + }, nil) +} + +// RemoveParticipant удаляет участника из комнаты (кик из голосовой). +func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error { + return c.call(ctx, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil) +} + +// ListParticipants перечисляет участников комнаты: используется для проверки +// расхождений между БД и SFU. +func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Participant, error) { + var response struct { + Participants []Participant `json:"participants"` + } + if err := c.call(ctx, "ListParticipants", map[string]any{"room": room}, &response); err != nil { + return nil, err + } + return response.Participants, nil +} + +// call выполняет запрос к Twirp-методу RoomService с административным токеном. +func (c *AdminClient) call(ctx context.Context, method string, body any, out any) error { + if !c.Enabled() { + return ErrDisabled + } + payload, err := json.Marshal(body) + if err != nil { + return err + } + token, err := c.adminToken() + if err != nil { + return err + } + endpoint := fmt.Sprintf("%s/twirp/livekit.RoomService/%s", c.baseURL, method) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + + response, err := c.http.Do(request) + if err != nil { + return fmt.Errorf("voice.api_unavailable: %w", err) + } + defer func() { _ = response.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return err + } + if response.StatusCode != http.StatusOK { + return fmt.Errorf("voice.api_error: %s: %s", response.Status, strings.TrimSpace(string(raw))) + } + if out == nil || len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("voice.api_decode: %w", err) + } + return nil +} + +// adminToken подписывает административный токен RoomService. +func (c *AdminClient) adminToken() (string, error) { + return c.issuer.IssueAdmin() +} diff --git a/internal/voice/token.go b/internal/voice/token.go index 313f278..748ab6b 100644 --- a/internal/voice/token.go +++ b/internal/voice/token.go @@ -119,6 +119,42 @@ func encodeSegment(payload []byte) string { return base64.RawURLEncoding.EncodeToString(payload) } +// IssueAdmin подписывает административный токен RoomService: полные права на +// комнаты (модерация, удаление участников), но не на запись медиа. +func (i *TokenIssuer) IssueAdmin() (string, error) { + if !i.Enabled() { + return "", ErrDisabled + } + now := i.now().UTC() + claims := map[string]any{ + "iss": i.apiKey, + "sub": i.apiKey, + "nbf": now.Add(-10 * time.Second).Unix(), + "exp": now.Add(10 * time.Minute).Unix(), + "video": map[string]any{ + "roomCreate": true, + "roomList": true, + "roomAdmin": true, + "roomRecord": false, + "ingressAdmin": false, + }, + } + 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 +} + // VerifyWebhook проверяет подпись вебхука LiveKit: это JWT, подписанный тем же // API-секретом, а в payload лежит base64(sha256(тело запроса)) (AGENT.md 7.14). func (i *TokenIssuer) VerifyWebhook(authorization string, body []byte) error {