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,146 @@
|
||||
// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния
|
||||
// (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей.
|
||||
package voice
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrDisabled — LiveKit не настроен: ключи не заданы.
|
||||
var ErrDisabled = errors.New("voice.disabled")
|
||||
|
||||
// TokenIssuer подписывает токены доступа LiveKit (HS256).
|
||||
type TokenIssuer struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
ttl time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewIssuer создаёт подписывателя токенов.
|
||||
func NewIssuer(apiKey, apiSecret string, ttl time.Duration) *TokenIssuer {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
return &TokenIssuer{apiKey: apiKey, apiSecret: apiSecret, ttl: ttl, now: time.Now}
|
||||
}
|
||||
|
||||
// SetClock подменяет источник времени (тесты).
|
||||
func (i *TokenIssuer) SetClock(now func() time.Time) { i.now = now }
|
||||
|
||||
// Enabled сообщает, настроен ли LiveKit.
|
||||
func (i *TokenIssuer) Enabled() bool {
|
||||
return i != nil && i.apiKey != "" && i.apiSecret != ""
|
||||
}
|
||||
|
||||
// Grants — права участника комнаты LiveKit.
|
||||
type Grants struct {
|
||||
RoomJoin bool `json:"roomJoin"`
|
||||
CanPublish bool `json:"canPublish"`
|
||||
CanSubscribe bool `json:"canSubscribe"`
|
||||
CanPublishData bool `json:"canPublishData"`
|
||||
CanPublishAudio bool `json:"canPublishSources,omitempty"`
|
||||
}
|
||||
|
||||
// Claims — полезная нагрузка токена LiveKit.
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Metadata string `json:"metadata,omitempty"`
|
||||
Video struct {
|
||||
Room string `json:"room"`
|
||||
RoomJoin bool `json:"roomJoin"`
|
||||
CanPublish bool `json:"canPublish"`
|
||||
CanSubscribe bool `json:"canSubscribe"`
|
||||
CanPublishData bool `json:"canPublishData"`
|
||||
RoomAdmin bool `json:"roomAdmin,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Recorder bool `json:"recorder,omitempty"`
|
||||
} `json:"video"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// Issue собирает и подписывает токен доступа к комнате.
|
||||
func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (string, error) {
|
||||
if !i.Enabled() {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
now := i.now().UTC()
|
||||
claims := Claims{Issuer: i.apiKey, Subject: identity, Name: displayName}
|
||||
claims.Video.Room = room
|
||||
claims.Video.RoomJoin = grants.RoomJoin
|
||||
claims.Video.CanPublish = grants.CanPublish
|
||||
claims.Video.CanSubscribe = grants.CanSubscribe
|
||||
claims.Video.CanPublishData = grants.CanPublishData
|
||||
claims.IssuedAt = now.Unix()
|
||||
claims.NotBefore = now.Add(-10 * time.Second).Unix()
|
||||
claims.ExpiresAt = now.Add(i.ttl).Unix()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// RoomName собирает имя комнаты LiveKit: комнаты инстанса не пересекаются.
|
||||
func RoomName(guildID, channelID uint64) string {
|
||||
return fmt.Sprintf("guild_%d_channel_%d", guildID, channelID)
|
||||
}
|
||||
|
||||
// ParseRoomName разбирает имя комнаты, если нужно понять, куда подключён клиент.
|
||||
func ParseRoomName(room string) (guildID, channelID uint64, ok bool) {
|
||||
if _, err := fmt.Sscanf(room, "guild_%d_channel_%d", &guildID, &channelID); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return guildID, channelID, true
|
||||
}
|
||||
|
||||
func encodeSegment(payload []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется
|
||||
// тем же доменом, поэтому оставляем как есть, но проверяем схему.
|
||||
func ResolveLiveKitURL(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") {
|
||||
return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
|
||||
}
|
||||
return strings.TrimSuffix(trimmed, "/"), nil
|
||||
}
|
||||
|
||||
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
|
||||
// клиент: пусто, если голос не настроен.
|
||||
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {
|
||||
if !i.Enabled() {
|
||||
return ""
|
||||
}
|
||||
resolved, err := ResolveLiveKitURL(publicURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
Reference in New Issue
Block a user