// Package voice выдаёт токены доступа к LiveKit и хранит голосовые состояния // (AGENT.md 7.14): собственный JWT-подписыватель без внешних зависимостей. package voice import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "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 приводит публичный адрес к базовому виду, который ждёт // LiveKit SDK: клиент сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`), // поэтому сегмент `/rtc` из адреса убираем — иначе брандмауэр клиента стучится // в `/rtc/rtc` и комната остаётся в состоянии «Подключение…». Префикс прокси // сохраняется: `wss://host/livekit/rtc` → `wss://host/livekit`. 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) } parsed, err := url.Parse(trimmed) if err != nil { return "", fmt.Errorf("voice.invalid_url: %s", trimmed) } path := strings.TrimSuffix(parsed.Path, "/") if path != "/rtc" { path = strings.TrimSuffix(path, "/rtc") } else { path = "" } parsed.Path = path parsed.RawPath = "" return strings.TrimSuffix(parsed.String(), "/"), nil } // VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать // клиент: пусто, если голос не настроен. func (i *TokenIssuer) VoiceURLForClient(publicURL string) string { if !i.Enabled() { return "" } resolved, err := ResolveLiveKitURL(publicURL) if err != nil { return "" } return resolved }