feat(voice): модерация через RoomService, саундборд и звуковая палитра

AGENT.md 7.13, 7.14:

- `internal/voice/admin.go` — клиент RoomService (Twirp/JSON, без SDK):
  GetParticipant, ListParticipants, MutePublishedTrack, RemoveParticipant с
  административным токеном; внутренний адрес SFU задаётся LIVEKIT_API_URL;
- серверный мьют теперь применяется и на стороне SFU (клиент не обойдёт),
  кик из голосовой (DELETE /guilds/{id}/voice-states/{user_id}) удаляет
  участника из комнаты через RemoveParticipant, перемещение — тоже;
- саундборд и звуковая палитра: таблица `guild_sounds` (миграция 00010),
  загрузка MP3/OGG/WAV/WebM/M4A до 512 КБ с MANAGE_SOUNDS, отдельные лимиты
  30 + 30, переименование, удаление, список с фильтром по виду;
- проигрывание POST /guilds/{id}/sounds/{sound_id}/play: нужно право
  USE_SOUNDBOARD и присутствие в голосовой комнате, событие SOUNDBOARD_PLAY
  уходит только участникам этой комнаты (звук играют клиенты), лимит 3 звука
  за 10 секунд; набор звуков приходит в READY и обновляется событием
  GUILD_SOUNDS_UPDATE;
- тесты: права на загрузку, требование события для звука интерфейса, запрет
  проигрывания вне комнаты, лимит частоты, переименование и удаление.
This commit is contained in:
2026-09-20 19:01:14 +03:00
parent 3ae03ec515
commit 1552146aa1
13 changed files with 1056 additions and 10 deletions
+1 -1
View File
@@ -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 {
+426
View File
@@ -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: &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)
}
}
+72
View File
@@ -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 {
+156
View File
@@ -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}
}
+16 -9
View File
@@ -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)