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