feat(voice): вход в голосовые комнаты, voice states и модерация (Фаза 3)

AGENT.md 7.14, начало Фазы 3:

- `internal/voice`: собственный подписыватель токенов LiveKit (HS256, grants
  roomJoin/canPublish/canSubscribe/canPublishData) и адрес комнаты
  `guild_{g}_channel_{c}` — без внешних зависимостей;
- таблица `voice_states` (миграция 00009) и store-методы: вход, флаги
  (микрофон, звук, камера, экран), серверный мьют и глушение, перемещение,
  выход, подсчёт участников комнаты;
- ручки: POST /channels/{id}/voice/join (проверяет CONNECT_VOICE, тип комнаты,
  лимит участников и выдаёт токен), POST /channels/{id}/voice/leave,
  PATCH /guilds/{id}/voice-states/@me, GET /guilds/{id}/voice-states,
  PATCH /guilds/{id}/voice-states/{user_id} (MUTE_MEMBERS/DEAFEN_MEMBERS,
  аудит), POST /guilds/{id}/voice-states/{user_id}/move (MOVE_MEMBERS);
- события VOICE_STATE_UPDATE всем участникам сервера, системные записи о входе
  в голосовую комнату, READY отдаёт голосовые состояния серверов;
- конфиг: LIVEKIT_API_KEY/SECRET/URL приложению, публичный адрес
  `LIVEKIT_PUBLIC_URL` (по умолчанию ws(s)://<домен>/rtc) в установщике и
  compose; `/api/v1/meta` сообщает voice_enabled и voice_url;
- тесты: claims токена LiveKit и подпись, выключенный голос, имя комнаты, вход
  и флаги, запрет мьюта без прав, перемещение и выход.
This commit is contained in:
2026-09-20 18:04:59 +03:00
parent 435c4deb9c
commit ecd52c11d3
13 changed files with 1045 additions and 19 deletions
+121
View File
@@ -10,10 +10,13 @@ import (
"net/textproto"
"net/url"
"strconv"
"strings"
"testing"
"time"
"glchat/internal/permissions"
"glchat/internal/store"
"glchat/internal/voice"
)
// storeOverride скрывает комнату от роли @user.
@@ -932,3 +935,121 @@ func uploadEmoji(t *testing.T, server *httptest.Server, cookie *http.Cookie, gui
recorder *httptest.ResponseRecorder
}{status: resp.StatusCode, body: string(payload), recorder: recorder}
}
// TestVoiceStateFlow проверяет вход в голосовую комнату, флаги и модерацию.
func TestVoiceStateFlow(t *testing.T) {
f := newMessagingFixture(t)
// Создаём голосовую комнату и добавляем участника.
voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
`{"name":"Голосовая","type":"voice","user_limit":2}`, f.ownerCookie)
voiceChannel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, voiceRec)
// Без ключей LiveKit вход недоступен с понятной ошибкой.
join := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie)
if join.Code != http.StatusServiceUnavailable {
t.Fatalf("join без LiveKit = %d, want 503", join.Code)
}
// Включаем голос: ключи подставляем в конфиг тестового сервера.
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)
join = doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+voiceChannel.Channel.ID+"/voice/join", "", f.memberCookie)
if join.Code != http.StatusOK {
t.Fatalf("join = %d, body = %s", join.Code, join.Body.String())
}
payload := decodeResponse[struct {
Token string `json:"token"`
URL string `json:"url"`
Room string `json:"room"`
ChannelID string `json:"channel_id"`
State struct {
UserID string `json:"user_id"`
ChannelID string `json:"channel_id"`
SelfMute bool `json:"self_mute"`
Camera bool `json:"camera"`
} `json:"state"`
}](t, join)
if payload.Token == "" || payload.URL != "wss://gl.test/rtc" {
t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL)
}
if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID {
t.Fatalf("состояние = %+v", payload.State)
}
if !strings.HasPrefix(payload.Room, "guild_") || !strings.Contains(payload.Room, "channel_") {
t.Fatalf("имя комнаты = %q", payload.Room)
}
// Свои флаги: микрофон и камера.
flags := doJSON(t, f.srv, http.MethodPatch, "/api/v1/guilds/"+f.guildID+"/voice-states/@me",
`{"self_mute":true,"camera":true}`, f.memberCookie)
if flags.Code != http.StatusOK {
t.Fatalf("flags = %d, body = %s", flags.Code, flags.Body.String())
}
states := decodeResponse[struct {
States []struct {
UserID string `json:"user_id"`
SelfMute bool `json:"self_mute"`
Camera bool `json:"camera"`
} `json:"voice_states"`
}](t, flags)
if len(states.States) != 1 || !states.States[0].SelfMute || !states.States[0].Camera {
t.Fatalf("states = %+v", states.States)
}
// Модерация: у участника нет MUTE_MEMBERS — серверный мьют запрещён.
denied := doJSON(t, f.srv, http.MethodPatch,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.ownerID, `{"server_mute":true}`, f.memberCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("member mute = %d, want 403", denied.Code)
}
muted := doJSON(t, f.srv, http.MethodPatch,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID, `{"server_mute":true}`, f.ownerCookie)
if muted.Code != http.StatusOK {
t.Fatalf("owner mute = %d, body = %s", muted.Code, muted.Body.String())
}
// Перемещение и выход.
secondRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels",
`{"name":"Вторая","type":"voice"}`, f.ownerCookie)
second := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, secondRec)
move := doJSON(t, f.srv, http.MethodPost,
"/api/v1/guilds/"+f.guildID+"/voice-states/"+f.memberID+"/move",
`{"channel_id":"`+second.Channel.ID+`"}`, f.ownerCookie)
if move.Code != http.StatusOK {
t.Fatalf("move = %d, body = %s", move.Code, move.Body.String())
}
moved := decodeResponse[struct {
States []struct {
ChannelID string `json:"channel_id"`
} `json:"voice_states"`
}](t, move)
if len(moved.States) != 1 || moved.States[0].ChannelID != second.Channel.ID {
t.Fatalf("после перемещения = %+v", moved.States)
}
leave := doJSON(t, f.srv, http.MethodPost, "/api/v1/channels/"+second.Channel.ID+"/voice/leave", "", f.memberCookie)
if leave.Code != http.StatusOK {
t.Fatalf("leave = %d, body = %s", leave.Code, leave.Body.String())
}
after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie)
empty := decodeResponse[struct {
States []struct {
UserID string `json:"user_id"`
} `json:"voice_states"`
}](t, after)
if len(empty.States) != 0 {
t.Fatalf("после выхода = %+v", empty.States)
}
}