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:
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
@@ -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 — участник голосовой комнаты в снапшоте.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -92,11 +96,13 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user