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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user