Files
glchat/internal/voice/token.go
T
grendervill 1552146aa1 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;
- тесты: права на загрузку, требование события для звука интерфейса, запрет
  проигрывания вне комнаты, лимит частоты, переименование и удаление.
2026-09-20 19:01:14 +03:00

269 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
// 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 {
if !i.Enabled() {
return ErrDisabled
}
token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
if token == "" {
return errors.New("voice.webhook_unauthorized")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return errors.New("voice.webhook_unauthorized")
}
signingInput := parts[0] + "." + parts[1]
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return err
}
if !hmac.Equal(signature, mac.Sum(nil)) {
return errors.New("voice.webhook_unauthorized")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return errors.New("voice.webhook_unauthorized")
}
var claims struct {
ExpiresAt int64 `json:"exp"`
SHA256 string `json:"sha256"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return errors.New("voice.webhook_unauthorized")
}
if claims.ExpiresAt > 0 && i.now().UTC().Unix() > claims.ExpiresAt {
return errors.New("voice.webhook_expired")
}
if claims.SHA256 != "" {
digest := sha256.Sum256(body)
if subtleCompare(claims.SHA256, base64.StdEncoding.EncodeToString(digest[:])) {
return nil
}
// LiveKit может присылать и url-safe вариант кодирования.
if subtleCompare(claims.SHA256, base64.RawURLEncoding.EncodeToString(digest[:])) {
return nil
}
return errors.New("voice.webhook_body_mismatch")
}
return nil
}
// IssueWebhookForTest подписывает тело вебхука так же, как это делает LiveKit:
// нужен для проверки верификатора и тестов обработчика.
func (i *TokenIssuer) IssueWebhookForTest(body []byte) (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
digest := sha256.Sum256(body)
claims := map[string]any{
"exp": i.now().UTC().Add(time.Minute).Unix(),
"sha256": base64.StdEncoding.EncodeToString(digest[:]),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
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
}
// subtleCompare сравнивает строки за постоянное время.
func subtleCompare(a, b string) bool {
return hmac.Equal([]byte(a), []byte(b))
}
// 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
}