Files
glchat/internal/server/api_sounds.go
T

427 lines
14 KiB
Go
Raw Normal View History

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: &currentUser.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)
}
}