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:
@@ -0,0 +1,91 @@
|
||||
package voice_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/voice"
|
||||
)
|
||||
|
||||
func TestIssueTokenHasLiveKitClaims(t *testing.T) {
|
||||
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
||||
issuer.SetClock(func() time.Time { return time.Unix(1_700_000_000, 0) })
|
||||
|
||||
token, err := issuer.Issue(voice.RoomName(10, 20), "42", "Алиса", voice.Grants{
|
||||
RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts))
|
||||
}
|
||||
header, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("decode header: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(header), "HS256") {
|
||||
t.Fatalf("header = %s", header)
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
var claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Video struct {
|
||||
Room string `json:"room"`
|
||||
RoomJoin bool `json:"roomJoin"`
|
||||
CanPublish bool `json:"canPublish"`
|
||||
} `json:"video"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatalf("decode claims: %v", err)
|
||||
}
|
||||
if claims.Issuer != "key123" || claims.Subject != "42" || claims.Name != "Алиса" {
|
||||
t.Fatalf("claims = %+v", claims)
|
||||
}
|
||||
if claims.Video.Room != "guild_10_channel_20" || !claims.Video.RoomJoin || !claims.Video.CanPublish {
|
||||
t.Fatalf("video grants = %+v", claims.Video)
|
||||
}
|
||||
if claims.ExpiresAt != 1_700_000_000+3600 {
|
||||
t.Fatalf("exp = %d", claims.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerDisabledWithoutKeys(t *testing.T) {
|
||||
issuer := voice.NewIssuer("", "", time.Hour)
|
||||
if issuer.Enabled() {
|
||||
t.Fatal("пустые ключи должны означать выключенный голос")
|
||||
}
|
||||
if _, err := issuer.Issue("room", "1", "user", voice.Grants{}); err == nil {
|
||||
t.Fatal("Issue без ключей должен возвращать ошибку")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoomName(t *testing.T) {
|
||||
guildID, channelID, ok := voice.ParseRoomName("guild_123_channel_456")
|
||||
if !ok || guildID != 123 || channelID != 456 {
|
||||
t.Fatalf("ParseRoomName = %d/%d/%t", guildID, channelID, ok)
|
||||
}
|
||||
if _, _, ok := voice.ParseRoomName("что-то другое"); ok {
|
||||
t.Fatal("посторонняя строка не должна разбираться как имя комнаты")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLiveKitURL(t *testing.T) {
|
||||
url, err := voice.ResolveLiveKitURL("wss://gl.example/rtc/")
|
||||
if err != nil || url != "wss://gl.example/rtc" {
|
||||
t.Fatalf("ResolveLiveKitURL = %q, %v", url, err)
|
||||
}
|
||||
if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil {
|
||||
t.Fatal("http-адрес должен отклоняться")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user