fix(voice): отдавать клиенту базовый адрес LiveKit без сегмента /rtc

LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).

- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
  префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
  отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
This commit is contained in:
2026-09-20 19:29:19 +03:00
parent 1552146aa1
commit 2538e07ec3
4 changed files with 49 additions and 8 deletions
+6 -1
View File
@@ -78,6 +78,11 @@ func (s *Server) registerVoiceRoutes(api huma.API) {
if !s.voice.Enabled() { if !s.voice.Enabled() {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен") return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
} }
// Клиенту отдаём базовый адрес SDK: сигнальный путь `/rtc` SDK дописывает сам.
clientURL := s.voice.VoiceURLForClient(s.cfg.LiveKitURL)
if clientURL == "" {
return nil, humaErrorStatus(http.StatusServiceUnavailable, "voice.disabled", "голос на инстансе не настроен")
}
// Сохраняем состояние: пользователь мог перейти из другой комнаты. // Сохраняем состояние: пользователь мог перейти из другой комнаты.
state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{ state, err := s.store.UpsertVoiceState(ctx, store.VoiceStateParams{
@@ -100,7 +105,7 @@ func (s *Server) registerVoiceRoutes(api huma.API) {
output := &voiceJoinOutput{} output := &voiceJoinOutput{}
output.Body.Token = token output.Body.Token = token
output.Body.URL = s.cfg.LiveKitURL output.Body.URL = clientURL
output.Body.Room = voice.RoomName(*channel.GuildID, channelID) output.Body.Room = voice.RoomName(*channel.GuildID, channelID)
output.Body.ChannelID = formatSnowflake(channelID) output.Body.ChannelID = formatSnowflake(channelID)
output.Body.GuildID = formatSnowflake(*channel.GuildID) output.Body.GuildID = formatSnowflake(*channel.GuildID)
+3 -1
View File
@@ -977,7 +977,9 @@ func TestVoiceStateFlow(t *testing.T) {
Camera bool `json:"camera"` Camera bool `json:"camera"`
} `json:"state"` } `json:"state"`
}](t, join) }](t, join)
if payload.Token == "" || payload.URL != "wss://gl.test/rtc" { // Адрес отдаётся в виде, который ждёт LiveKit SDK: без сегмента `/rtc`,
// иначе SDK допишет его второй раз (AGENT.md 7.14).
if payload.Token == "" || payload.URL != "wss://gl.test" {
t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL) t.Fatalf("токен/адрес = %q / %q", payload.Token, payload.URL)
} }
if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID { if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID {
+19 -3
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
) )
@@ -241,8 +242,11 @@ func subtleCompare(a, b string) bool {
return hmac.Equal([]byte(a), []byte(b)) return hmac.Equal([]byte(a), []byte(b))
} }
// ResolveLiveKitURL приводит адрес к виду, понятному браузеру: /rtc проксируется // ResolveLiveKitURL приводит публичный адрес к базовому виду, который ждёт
// тем же доменом, поэтому оставляем как есть, но проверяем схему. // LiveKit SDK: клиент сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`),
// поэтому сегмент `/rtc` из адреса убираем — иначе брандмауэр клиента стучится
// в `/rtc/rtc` и комната остаётся в состоянии «Подключение…». Префикс прокси
// сохраняется: `wss://host/livekit/rtc` → `wss://host/livekit`.
func ResolveLiveKitURL(raw string) (string, error) { func ResolveLiveKitURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
@@ -251,7 +255,19 @@ func ResolveLiveKitURL(raw string) (string, error) {
if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") { if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") {
return "", fmt.Errorf("voice.invalid_url: %s", trimmed) return "", fmt.Errorf("voice.invalid_url: %s", trimmed)
} }
return strings.TrimSuffix(trimmed, "/"), nil 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 так, как его должен использовать // VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
+21 -3
View File
@@ -3,6 +3,7 @@ package voice_test
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -81,13 +82,30 @@ func TestParseRoomName(t *testing.T) {
} }
func TestResolveLiveKitURL(t *testing.T) { func TestResolveLiveKitURL(t *testing.T) {
url, err := voice.ResolveLiveKitURL("wss://gl.example/rtc/") cases := map[string]string{
if err != nil || url != "wss://gl.example/rtc" { "wss://gl.example/rtc/": "wss://gl.example",
t.Fatalf("ResolveLiveKitURL = %q, %v", url, err) "wss://gl.example/rtc": "wss://gl.example",
"wss://gl.example": "wss://gl.example",
"wss://gl.example/": "wss://gl.example",
"wss://gl.example/livekit/rtc": "wss://gl.example/livekit",
"wss://gl.example:7880/rtc": "wss://gl.example:7880",
"ws://192.168.1.247:7880/rtc/": "ws://192.168.1.247:7880",
"wss://gl.example/notrtc": "wss://gl.example/notrtc",
"wss://gl.example/rtc/extra": "wss://gl.example/rtc/extra",
"wss://gl.example/livekit/rtcx": "wss://gl.example/livekit/rtcx",
}
for raw, want := range cases {
got, err := voice.ResolveLiveKitURL(raw)
if err != nil || got != want {
t.Fatalf("ResolveLiveKitURL(%q) = %q, %v; ожидалось %q", raw, got, err, want)
}
} }
if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil { if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil {
t.Fatal("http-адрес должен отклоняться") t.Fatal("http-адрес должен отклоняться")
} }
if _, err := voice.ResolveLiveKitURL(""); !errors.Is(err, voice.ErrDisabled) {
t.Fatalf("пустой адрес = %v, ожидалось ErrDisabled", err)
}
} }
func TestVerifyWebhookSignature(t *testing.T) { func TestVerifyWebhookSignature(t *testing.T) {