From 2538e07ec3ceed24e70932ca910803699c67c942 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 19:29:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(voice):=20=D0=BE=D1=82=D0=B4=D0=B0=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D1=83?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=20LiveKit=20=D0=B1=D0=B5=D0=B7=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B3=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20/rtc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` в голосовом сценарии. --- internal/server/api_voice.go | 7 ++++++- internal/server/messages_test.go | 4 +++- internal/voice/token.go | 22 +++++++++++++++++++--- internal/voice/token_test.go | 24 +++++++++++++++++++++--- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/internal/server/api_voice.go b/internal/server/api_voice.go index 6bfbc6f..ae66ac9 100644 --- a/internal/server/api_voice.go +++ b/internal/server/api_voice.go @@ -78,6 +78,11 @@ func (s *Server) registerVoiceRoutes(api huma.API) { if !s.voice.Enabled() { 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{ @@ -100,7 +105,7 @@ func (s *Server) registerVoiceRoutes(api huma.API) { output := &voiceJoinOutput{} output.Body.Token = token - output.Body.URL = s.cfg.LiveKitURL + output.Body.URL = clientURL output.Body.Room = voice.RoomName(*channel.GuildID, channelID) output.Body.ChannelID = formatSnowflake(channelID) output.Body.GuildID = formatSnowflake(*channel.GuildID) diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index e1f4da7..fc1cfcb 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -977,7 +977,9 @@ func TestVoiceStateFlow(t *testing.T) { Camera bool `json:"camera"` } `json:"state"` }](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) } if payload.State.UserID != f.memberID || payload.State.ChannelID != voiceChannel.Channel.ID { diff --git a/internal/voice/token.go b/internal/voice/token.go index 748ab6b..168a947 100644 --- a/internal/voice/token.go +++ b/internal/voice/token.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "strings" "time" ) @@ -241,8 +242,11 @@ func subtleCompare(a, b string) bool { 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) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -251,7 +255,19 @@ func ResolveLiveKitURL(raw string) (string, error) { if !strings.HasPrefix(trimmed, "ws://") && !strings.HasPrefix(trimmed, "wss://") { 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 так, как его должен использовать diff --git a/internal/voice/token_test.go b/internal/voice/token_test.go index 5aca933..4f77ef3 100644 --- a/internal/voice/token_test.go +++ b/internal/voice/token_test.go @@ -3,6 +3,7 @@ package voice_test import ( "encoding/base64" "encoding/json" + "errors" "strings" "testing" "time" @@ -81,13 +82,30 @@ func TestParseRoomName(t *testing.T) { } 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) + cases := map[string]string{ + "wss://gl.example/rtc/": "wss://gl.example", + "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 { t.Fatal("http-адрес должен отклоняться") } + if _, err := voice.ResolveLiveKitURL(""); !errors.Is(err, voice.ErrDisabled) { + t.Fatalf("пустой адрес = %v, ожидалось ErrDisabled", err) + } } func TestVerifyWebhookSignature(t *testing.T) {