From e9455ab6fd1fd428841507dcb8c94b8b018e7b17 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 18:31:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(voice):=20=D0=B2=D0=B5=D0=B1=D1=85=D1=83?= =?UTF-8?q?=D0=BA=D0=B8=20LiveKit=20=D0=B8=20=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B6=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENT.md 7.14: - POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе — 503; обработка идемпотентна по event.id (повторная доставка → duplicate); - события: participant_joined восстанавливает состояние после рестарта приложения, participant_left и participant_connection_aborted убирают участника, room_finished очищает комнату целиком, track_published и track_unpublished обновляют флаги камеры и демонстрации экрана; каждое изменение рассылает VOICE_STATE_UPDATE участникам сервера; - сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений дольше шести часов (сбои без вебхука) и уведомляет клиентов; - тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный цикл событий SFU и идемпотентность повторной доставки. --- cmd/glchat/main.go | 2 + internal/server/api_voice_webhook.go | 241 +++++++++++++++++++++++++++ internal/server/messages_test.go | 102 ++++++++++++ internal/server/server.go | 18 +- internal/voice/token.go | 86 ++++++++++ internal/voice/token_test.go | 31 ++++ web/package-lock.json | 145 +++++++++++++++- web/package.json | 1 + 8 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 internal/server/api_voice_webhook.go diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 72c94f4..4911940 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -163,6 +163,8 @@ func run() error { srv := server.New(cfg, db, logger, server.Deps{ Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, }) + // Сторож голосовых состояний: убирает «призрачные» записи после сбоев. + srv.StartVoiceWatchdog(context.Background(), 0) errCh := make(chan error, 1) go func() { logger.Info("http server listening", diff --git a/internal/server/api_voice_webhook.go b/internal/server/api_voice_webhook.go new file mode 100644 index 0000000..6d9463a --- /dev/null +++ b/internal/server/api_voice_webhook.go @@ -0,0 +1,241 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "glchat/internal/store" + "glchat/internal/voice" +) + +// livekitWebhookEvent — событие вебхука LiveKit (AGENT.md 7.14). +type livekitWebhookEvent struct { + Event string `json:"event"` + ID string `json:"id"` + Room struct { + Name string `json:"name"` + SID string `json:"sid"` + } `json:"room"` + Participant struct { + Identity string `json:"identity"` + SID string `json:"sid"` + } `json:"participant"` + Track struct { + Type string `json:"type"` + Source string `json:"source"` + } `json:"track"` +} + +// webhookTTL — сколько помним идентификаторы обработанных событий. +const webhookTTL = time.Hour + +// registerVoiceWebhook вешает обработчик вебхуков LiveKit (AGENT.md 7.14). +func (s *Server) registerVoiceWebhook(router chi.Router) { + router.Post("/livekit/webhook", s.handleLiveKitWebhook) +} + +// handleLiveKitWebhook принимает события SFU: подпись проверяется, обработка +// идемпотентна по идентификатору события, состояния синхронизируются с БД. +func (s *Server) handleLiveKitWebhook(w http.ResponseWriter, r *http.Request) { + if !s.voice.Enabled() { + httpxWriteJSONError(w, http.StatusServiceUnavailable, "voice.disabled", "голос не настроен") + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "cannot read body") + return + } + if err := s.voice.VerifyWebhook(r.Header.Get("Authorization"), body); err != nil { + s.logger.WarnContext(r.Context(), "livekit webhook rejected", slog.Any("error", err)) + httpxWriteJSONError(w, http.StatusUnauthorized, "voice.webhook_unauthorized", "invalid webhook signature") + return + } + + var event livekitWebhookEvent + if err := json.Unmarshal(body, &event); err != nil { + httpxWriteJSONError(w, http.StatusBadRequest, "request.bad", "malformed event") + return + } + if event.ID != "" && !s.markWebhookProcessed(event.ID) { + // Повторная доставка: отвечаем успехом, ничего не меняя. + httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true, "duplicate": true}) + return + } + s.applyLiveKitEvent(r.Context(), event) + httpxWriteJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// markWebhookProcessed возвращает false, если событие уже обрабатывалось. +func (s *Server) markWebhookProcessed(id string) bool { + now := time.Now() + s.webhookMu.Lock() + defer s.webhookMu.Unlock() + if len(s.webhookSeen) > 4096 { + cutoff := now.Add(-webhookTTL) + for key, at := range s.webhookSeen { + if at.Before(cutoff) { + delete(s.webhookSeen, key) + } + } + } + if _, ok := s.webhookSeen[id]; ok { + return false + } + s.webhookSeen[id] = now + return true +} + +// applyLiveKitEvent обновляет голосовые состояния по событию SFU. +func (s *Server) applyLiveKitEvent(ctx context.Context, event livekitWebhookEvent) { + guildID, channelID, ok := voice.ParseRoomName(event.Room.Name) + if !ok { + return + } + switch event.Event { + case "participant_joined": + userID := parseVoiceIdentity(event.Participant.Identity) + if userID == 0 { + return + } + // Восстанавливаем состояние, если приложение перезапускалось: комната + // уже существует, значит участник действительно в голосовой комнате. + current, err := s.store.GetVoiceState(ctx, guildID, userID) + if err == nil && current.ChannelID == channelID { + return + } + params := store.VoiceStateParams{UserID: userID, GuildID: guildID, ChannelID: channelID} + if err == nil { + params.SelfMute = current.SelfMute + params.SelfDeaf = current.SelfDeaf + params.ServerMute = current.ServerMute + params.ServerDeaf = current.ServerDeaf + } + state, err := s.store.UpsertVoiceState(ctx, params) + if err != nil { + s.logger.WarnContext(ctx, "livekit webhook: upsert state failed", slog.Any("error", err)) + return + } + s.dispatchVoiceState(ctx, state) + + case "participant_left", "participant_connection_aborted": + userID := parseVoiceIdentity(event.Participant.Identity) + if userID == 0 { + return + } + if err := s.store.DeleteVoiceState(ctx, guildID, userID); err != nil { + return + } + s.dispatchVoiceLeave(ctx, guildID, channelID, userID) + + case "room_finished": + states, err := s.store.ListVoiceStates(ctx, guildID) + if err != nil { + return + } + for _, state := range states { + if state.ChannelID != channelID { + continue + } + if err := s.store.DeleteVoiceState(ctx, guildID, state.UserID); err != nil { + continue + } + s.dispatchVoiceLeave(ctx, guildID, channelID, state.UserID) + } + + case "track_published", "track_unpublished": + userID := parseVoiceIdentity(event.Participant.Identity) + if userID == 0 { + return + } + state, err := s.store.GetVoiceState(ctx, guildID, userID) + if err != nil || state.ChannelID != channelID { + return + } + published := event.Event == "track_published" + params := store.VoiceStateParams{ + UserID: userID, GuildID: guildID, ChannelID: channelID, SessionID: state.SessionID, + SelfMute: state.SelfMute, SelfDeaf: state.SelfDeaf, + ServerMute: state.ServerMute, ServerDeaf: state.ServerDeaf, + Camera: state.Camera, Screen: state.Screen, + } + switch strings.ToUpper(event.Track.Source) { + case "CAMERA": + params.Camera = published + case "SCREEN_SHARE", "SCREEN_SHARE_AUDIO": + params.Screen = published + default: + return + } + updated, err := s.store.UpsertVoiceState(ctx, params) + if err != nil { + return + } + s.dispatchVoiceState(ctx, updated) + } +} + +// parseVoiceIdentity превращает identity участника LiveKit в идентификатор. +func parseVoiceIdentity(identity string) uint64 { + id, err := parseID("identity", identity) + if err != nil { + return 0 + } + return id +} + +// startVoiceWatchdog периодически убирает «призрачные» состояния: записи без +// обновлений дольше шести часов (сервер мог упасть и не получить вебхук). +func (s *Server) startVoiceWatchdog(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 15 * time.Minute + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.cleanupStaleVoiceStates(ctx) + } + } + }() +} + +// cleanupStaleVoiceStates удаляет давно не обновлявшиеся голосовые состояния. +func (s *Server) cleanupStaleVoiceStates(ctx context.Context) { + cutoff := time.Now().UTC().Add(-6 * time.Hour) + guilds, err := s.store.ListAllGuilds(ctx) + if err != nil { + return + } + removed := 0 + for _, guild := range guilds { + states, err := s.store.ListVoiceStates(ctx, guild.ID) + if err != nil { + continue + } + for _, state := range states { + if state.UpdatedAt.After(cutoff) { + continue + } + if err := s.store.DeleteVoiceState(ctx, guild.ID, state.UserID); err != nil { + continue + } + removed++ + s.dispatchVoiceLeave(ctx, guild.ID, state.ChannelID, state.UserID) + } + } + if removed > 0 { + s.logger.InfoContext(ctx, "stale voice states cleaned", slog.Int("removed", removed)) + } +} diff --git a/internal/server/messages_test.go b/internal/server/messages_test.go index 1fa1a53..4e39a31 100644 --- a/internal/server/messages_test.go +++ b/internal/server/messages_test.go @@ -1053,3 +1053,105 @@ func TestVoiceStateFlow(t *testing.T) { t.Fatalf("после выхода = %+v", empty.States) } } + +// TestLiveKitWebhook проверяет подпись, идемпотентность и синхронизацию состояний. +func TestLiveKitWebhook(t *testing.T) { + f := newMessagingFixture(t) + f.srv.cfg.LiveKitAPIKey = "testkey" + f.srv.cfg.LiveKitAPISecret = "testsecret" + f.srv.cfg.LiveKitURL = "wss://gl.test/rtc" + f.srv.voice = voice.NewIssuer("testkey", "testsecret", time.Hour) + + voiceRec := doJSON(t, f.srv, http.MethodPost, "/api/v1/guilds/"+f.guildID+"/channels", + `{"name":"Голос","type":"voice"}`, f.ownerCookie) + voiceChannel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, voiceRec) + channelID := guildIDOf(t, voiceChannel.Channel.ID) + guildID := guildIDOf(t, f.guildID) + room := voice.RoomName(guildID, channelID) + + // Событие без подписи отклоняется. + noAuth := doJSON(t, f.srv, http.MethodPost, "/api/v1/livekit/webhook", + `{"event":"participant_joined"}`, f.ownerCookie) + if noAuth.Code != http.StatusUnauthorized { + t.Fatalf("webhook без подписи = %d, want 401", noAuth.Code) + } + + post := func(body string) *httptest.ResponseRecorder { + t.Helper() + signature, err := f.srv.voice.IssueWebhookForTest([]byte(body)) + if err != nil { + t.Fatalf("подпись вебхука: %v", err) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/livekit/webhook", + strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", signature) + rec := httptest.NewRecorder() + f.srv.Handler().ServeHTTP(rec, req) + return rec + } + + // Участник «появился» на стороне SFU — состояние восстанавливается. + joined := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` + + `"participant":{"identity":"` + f.memberID + `"}}`) + if joined.Code != http.StatusOK { + t.Fatalf("participant_joined = %d, body = %s", joined.Code, joined.Body.String()) + } + states := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) + list := decodeResponse[struct { + States []struct { + UserID string `json:"user_id"` + ChannelID string `json:"channel_id"` + } `json:"voice_states"` + }](t, states) + if len(list.States) != 1 || list.States[0].UserID != f.memberID { + t.Fatalf("состояния после вебхука = %+v", list.States) + } + + // Повторная доставка того же события ничего не меняет. + duplicate := post(`{"event":"participant_joined","id":"evt-1","room":{"name":"` + room + `"},` + + `"participant":{"identity":"` + f.memberID + `"}}`) + var duplicateBody struct { + Duplicate bool `json:"duplicate"` + } + if err := json.Unmarshal(duplicate.Body.Bytes(), &duplicateBody); err != nil { + t.Fatalf("decode duplicate: %v", err) + } + if !duplicateBody.Duplicate { + t.Fatalf("повторное событие должно помечаться duplicate: %s", duplicate.Body.String()) + } + + // Публикация камеры отмечает флаг. + camera := post(`{"event":"track_published","id":"evt-2","room":{"name":"` + room + `"},` + + `"participant":{"identity":"` + f.memberID + `"},"track":{"source":"CAMERA"}}`) + if camera.Code != http.StatusOK { + t.Fatalf("track_published = %d, body = %s", camera.Code, camera.Body.String()) + } + cameraState, err := f.srv.store.GetVoiceState(t.Context(), guildID, guildIDOf(t, f.memberID)) + if err != nil { + t.Fatalf("GetVoiceState: %v", err) + } + if !cameraState.Camera { + t.Fatal("флаг камеры не выставлен по вебхуку") + } + + // Уход участника убирает состояние. + left := post(`{"event":"participant_left","id":"evt-3","room":{"name":"` + room + `"},` + + `"participant":{"identity":"` + f.memberID + `"}}`) + if left.Code != http.StatusOK { + t.Fatalf("participant_left = %d", left.Code) + } + after := doJSON(t, f.srv, http.MethodGet, "/api/v1/guilds/"+f.guildID+"/voice-states", "", f.ownerCookie) + empty := decodeResponse[struct { + States []struct { + UserID string `json:"user_id"` + } `json:"voice_states"` + }](t, after) + if len(empty.States) != 0 { + t.Fatalf("после participant_left = %+v", empty.States) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index f9a4125..25dc997 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -65,10 +65,13 @@ type Server struct { // presence — время последнего обновления last_seen по пользователю. presenceMu sync.Mutex presence map[uint64]time.Time - logger *slog.Logger - http *http.Server - static *staticHandler - api huma.API + // webhookSeen — идентификаторы обработанных вебхуков LiveKit (идемпотентность). + webhookMu sync.Mutex + webhookSeen map[string]time.Time + logger *slog.Logger + http *http.Server + static *staticHandler + api huma.API } func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server { @@ -92,6 +95,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), slowmode: map[string]time.Time{}, presence: map[uint64]time.Time{}, + webhookSeen: map[string]time.Time{}, voice: voice.NewIssuer(cfg.LiveKitAPIKey, cfg.LiveKitAPISecret, time.Hour), } switch { @@ -119,6 +123,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerSocialRoutes(s.api) s.registerEmojiRoutes(s.api, apiRouter) s.registerVoiceRoutes(s.api) + s.registerVoiceWebhook(apiRouter) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) @@ -187,6 +192,11 @@ func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() } func (s *Server) Shutdown(ctx context.Context) error { return s.http.Shutdown(ctx) } +// StartVoiceWatchdog запускает периодическую очистку голосовых состояний. +func (s *Server) StartVoiceWatchdog(ctx context.Context, interval time.Duration) { + s.startVoiceWatchdog(ctx, interval) +} + func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]any{ "status": "ok", diff --git a/internal/voice/token.go b/internal/voice/token.go index b4c2750..313f278 100644 --- a/internal/voice/token.go +++ b/internal/voice/token.go @@ -119,6 +119,92 @@ func encodeSegment(payload []byte) string { return base64.RawURLEncoding.EncodeToString(payload) } +// 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) { diff --git a/internal/voice/token_test.go b/internal/voice/token_test.go index e6b4256..5aca933 100644 --- a/internal/voice/token_test.go +++ b/internal/voice/token_test.go @@ -89,3 +89,34 @@ func TestResolveLiveKitURL(t *testing.T) { t.Fatal("http-адрес должен отклоняться") } } + +func TestVerifyWebhookSignature(t *testing.T) { + issuer := voice.NewIssuer("key123", "secret456", time.Hour) + body := []byte(`{"event":"participant_joined"}`) + + // Без заголовка — отказ. + if err := issuer.VerifyWebhook("", body); err == nil { + t.Fatal("подпись обязательна") + } + // Чужой подписью — отказ. + other := voice.NewIssuer("key123", "another-secret", time.Hour) + token, err := other.IssueWebhookForTest(body) + if err != nil { + t.Fatalf("issue: %v", err) + } + if err := issuer.VerifyWebhook(token, body); err == nil { + t.Fatal("подпись чужим секретом должна отклоняться") + } + // Своей подписью, но другим телом — отказ. + valid, err := issuer.IssueWebhookForTest(body) + if err != nil { + t.Fatalf("issue: %v", err) + } + if err := issuer.VerifyWebhook(valid, []byte(`{"event":"room_finished"}`)); err == nil { + t.Fatal("несовпадение тела должно отклоняться") + } + // Корректная подпись — успех. + if err := issuer.VerifyWebhook(valid, body); err != nil { + t.Fatalf("VerifyWebhook: %v", err) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 3f86721..36b59d2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@tanstack/react-query": "5.103.1", "i18next": "26.4.2", + "livekit-client": "^2.22.3", "react": "19.3.0", "react-dom": "19.3.0", "react-i18next": "17.0.14", @@ -23,7 +24,7 @@ "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.7", - "@types/node": "^26.6.2", + "@types/node": "26.6.2", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", @@ -365,6 +366,12 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", + "integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -815,6 +822,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@livekit/mutex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz", + "integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==", + "license": "Apache-2.0" + }, + "node_modules/@livekit/protocol": { + "version": "1.50.4", + "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz", + "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", @@ -1546,6 +1568,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-mediacapture-record": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", + "integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==", + "license": "MIT", + "peer": true + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -2532,6 +2561,15 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2849,6 +2887,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3243,6 +3290,27 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/livekit-client": { + "version": "2.22.3", + "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.22.3.tgz", + "integrity": "sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==", + "license": "Apache-2.0", + "dependencies": { + "@livekit/mutex": "1.1.1", + "@livekit/protocol": "1.50.4", + "events": "^3.3.0", + "jose": "^6.1.0", + "loglevel": "^1.9.2", + "machina": "^7.0.0", + "sdp-transform": "^2.15.0", + "tslib": "2.8.1", + "typed-emitter": "^2.1.0", + "webrtc-adapter": "9.0.6" + }, + "peerDependencies": { + "@types/dom-mediacapture-record": "^1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3259,6 +3327,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3280,6 +3361,15 @@ "lz-string": "bin/bin.js" } }, + "node_modules/machina": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/machina/-/machina-7.0.1.tgz", + "integrity": "sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==", + "license": "MIT", + "engines": { + "node": ">=22.22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3720,6 +3810,16 @@ "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3739,6 +3839,21 @@ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/sdp-transform": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz", + "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==", + "license": "MIT", + "bin": { + "sdp-verify": "checker.js" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3933,6 +4048,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3946,6 +4067,15 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -4518,6 +4648,19 @@ "node": ">=20" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/web/package.json b/web/package.json index 3f299b0..9145a9f 100644 --- a/web/package.json +++ b/web/package.json @@ -20,6 +20,7 @@ "dependencies": { "@tanstack/react-query": "5.103.1", "i18next": "26.4.2", + "livekit-client": "^2.22.3", "react": "19.3.0", "react-dom": "19.3.0", "react-i18next": "17.0.14",