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 ( 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 } sizeLimit := s.mediaLimit(ctx, mediaSound) r.Body = http.MaxBytesReader(w, r.Body, sizeLimit+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 > sizeLimit { httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "звук больше 512 КБ") return } data, err := io.ReadAll(io.LimitReader(file, sizeLimit+1)) if err != nil || int64(len(data)) > sizeLimit { 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) } }