Group voice and video calls with MatrixRTC and LiveKit
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.
Server:
- MSC4140 delayed events (send/state with a delay, plus the
delayed_events list/restart/cancel/send API), stored and scheduled, so
a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.
Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
325431a5be
commit
76dfc98a86
49 files changed
+3849
-432
No files matched your search
@@ -1,10 +1,12 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/tracing"
|
||||
)
|
||||
@@ -32,10 +34,19 @@ type versionsResponse struct {
|
||||
UnstableFeatures map[string]bool `json:"unstable_features,omitempty"`
|
||||
}
|
||||
|
||||
// unstableDelayedEvents is the MSC4140 feature flag. It is advertised only
|
||||
// while delayed events are switched on, because a MatrixRTC client checks
|
||||
// it before scheduling its delayed leave.
|
||||
const unstableDelayedEvents = "org.matrix.msc4140"
|
||||
|
||||
// wellKnownClient is the /.well-known/matrix/client document.
|
||||
type wellKnownClient struct {
|
||||
Homeserver wellKnownBaseURL `json:"m.homeserver"`
|
||||
IdentityServer *wellKnownBaseURL `json:"m.identity_server,omitempty"`
|
||||
|
||||
// RTCFoci are the MatrixRTC backends (MSC4143) a client may use for a
|
||||
// group call it starts.
|
||||
RTCFoci []clientapi.RTCTransport `json:"org.matrix.msc4143.rtc_foci,omitempty"`
|
||||
}
|
||||
|
||||
type wellKnownBaseURL struct {
|
||||
@@ -84,10 +95,14 @@ func (s *Server) routes() *httputil.Router {
|
||||
|
||||
// handleVersions implements GET /_matrix/client/versions.
|
||||
func (s *Server) handleVersions(w http.ResponseWriter, _ *http.Request) error {
|
||||
features := maps.Clone(UnstableFeatures)
|
||||
if s.delayed.Enabled() {
|
||||
features[unstableDelayedEvents] = true
|
||||
}
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, versionsResponse{
|
||||
Versions: SpecVersions,
|
||||
UnstableFeatures: UnstableFeatures,
|
||||
UnstableFeatures: features,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -101,6 +116,7 @@ func (s *Server) handleWellKnownClient(w http.ResponseWriter, _ *http.Request) e
|
||||
if url := s.cfg.Global.WellKnown.IdentityServerBaseURL; url != "" {
|
||||
doc.IdentityServer = &wellKnownBaseURL{BaseURL: url}
|
||||
}
|
||||
doc.RTCFoci = clientapi.RTCTransports(s.cfg.ClientAPI.RTC.LiveKitServiceURL)
|
||||
httputil.CacheFor(w, time.Hour)
|
||||
httputil.WriteJSON(w, http.StatusOK, doc)
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package setup_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
)
|
||||
|
||||
// TestWellKnownClientAdvertisesLiveKit: group call clients find the SFU
|
||||
// they publish to in the discovery document (MSC4143).
|
||||
func TestWellKnownClientAdvertisesLiveKit(t *testing.T) {
|
||||
srv := newTestServer(t, func(c *config.Config) {
|
||||
c.ClientAPI.RTC.LiveKitServiceURL = "https://livekit-jwt.warp.example/"
|
||||
})
|
||||
rec := get(t, srv, http.MethodGet, "/.well-known/matrix/client")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := decode[struct {
|
||||
Foci []map[string]string `json:"org.matrix.msc4143.rtc_foci"`
|
||||
}](t, rec)
|
||||
if len(body.Foci) != 1 || body.Foci[0]["type"] != "livekit" ||
|
||||
body.Foci[0]["livekit_service_url"] != "https://livekit-jwt.warp.example" {
|
||||
t.Errorf("rtc_foci = %v", body.Foci)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWellKnownClientOmitsRTCFociByDefault(t *testing.T) {
|
||||
srv := newTestServer(t, nil)
|
||||
body := decode[map[string]any](t, get(t, srv, http.MethodGet, "/.well-known/matrix/client"))
|
||||
if _, present := body["org.matrix.msc4143.rtc_foci"]; present {
|
||||
t.Error("rtc_foci must be omitted when no LiveKit service is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTCConfigValidation(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Global.ServerName = "warp.example"
|
||||
cfg.ClientAPI.RTC.LiveKitServiceURL = "livekit-jwt.warp.example"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("a LiveKit service URL without a scheme was accepted")
|
||||
}
|
||||
cfg.ClientAPI.RTC.LiveKitServiceURL = ""
|
||||
cfg.ClientAPI.DelayedEvents.MaxPerUser = 0
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("delayed events with no room for any were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupCallRoutesAreWiredUp: with a signing key (and so rooms) the
|
||||
// server advertises delayed events, serves their endpoints, and hands out
|
||||
// OpenID tokens the userinfo endpoint accepts.
|
||||
func TestGroupCallRoutesAreWiredUp(t *testing.T) {
|
||||
srv := newSigningServer(t, func(c *config.Config) {
|
||||
c.Registration.Enabled = true
|
||||
c.ClientAPI.RateLimit.Enabled = false
|
||||
})
|
||||
features := decode[map[string]any](t, get(t, srv, http.MethodGet, "/_matrix/client/versions"))["unstable_features"].(map[string]any)
|
||||
if features["org.matrix.msc4140"] != true {
|
||||
t.Errorf("unstable_features = %v", features)
|
||||
}
|
||||
|
||||
token := registerThrough(t, srv, "alice")
|
||||
if rec := getWithToken(t, srv, "/_matrix/client/unstable/org.matrix.msc4140/delayed_events", token); rec.Code != http.StatusOK {
|
||||
t.Errorf("delayed_events: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec := post(t, srv, "/_matrix/client/v3/user/@alice:warp.example/openid/request_token", map[string]any{}, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("request_token: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
openID := decode[map[string]any](t, rec)["access_token"].(string)
|
||||
rec = get(t, srv, http.MethodGet, "/_matrix/federation/v1/openid/userinfo?access_token="+openID)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("userinfo: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedEventsCanBeSwitchedOff(t *testing.T) {
|
||||
srv := newSigningServer(t, func(c *config.Config) {
|
||||
c.ClientAPI.DelayedEvents.MaxDelay = 0
|
||||
})
|
||||
features := decode[map[string]any](t, get(t, srv, http.MethodGet, "/_matrix/client/versions"))["unstable_features"].(map[string]any)
|
||||
if _, present := features["org.matrix.msc4140"]; present {
|
||||
t.Errorf("unstable_features = %v", features)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/dariuszjaniak/warp/server/internal/buildinfo"
|
||||
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federationapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
@@ -61,6 +62,7 @@ type Server struct {
|
||||
clientAPI *clientapi.ClientAPI
|
||||
|
||||
appservices *appservice.API
|
||||
delayed *delayedevents.Service
|
||||
notices *servernotices.API
|
||||
regTokens *userapi.RegistrationTokens
|
||||
|
||||
@@ -407,6 +409,24 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
}
|
||||
emit.add(s.appservices)
|
||||
|
||||
// Delayed events are sent by the roomserver as the user who
|
||||
// scheduled them, and hear about state changes on the emit stage:
|
||||
// someone else setting a piece of state cancels the delayed
|
||||
// events that would overwrite it.
|
||||
s.delayed, err = delayedevents.New(delayedevents.Options{
|
||||
Store: s.db.DelayedEvents(),
|
||||
Rooms: s.rooms,
|
||||
MaxDelay: cfg.ClientAPI.DelayedEvents.MaxDelay,
|
||||
MaxPerUser: cfg.ClientAPI.DelayedEvents.MaxPerUser,
|
||||
DescribeError: clientapi.DescribeRoomError,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.closeDB()
|
||||
return nil, err
|
||||
}
|
||||
emit.add(s.delayed)
|
||||
|
||||
// Deactivating an account leaves its rooms, which only the
|
||||
// roomserver can do.
|
||||
s.users.SetAccountObserver(roomLeaver{rooms: s.rooms, log: log})
|
||||
@@ -490,6 +510,9 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
AppServices: s.appservices,
|
||||
RateLimit: cfg.ClientAPI.RateLimit,
|
||||
TURN: cfg.ClientAPI.TURN,
|
||||
RTC: cfg.ClientAPI.RTC,
|
||||
DelayedEvents: s.delayed,
|
||||
OpenIDTokens: s.db.OpenIDTokens(),
|
||||
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
|
||||
Logger: log,
|
||||
})
|
||||
@@ -522,6 +545,11 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
|
||||
// talks to the services, and tests that drive the handler directly
|
||||
// need it running.
|
||||
s.appservices.Start(ctx)
|
||||
// So do delayed events: a call member who drops off has to be removed
|
||||
// whether or not anything is listening yet.
|
||||
if s.delayed != nil {
|
||||
s.delayed.Start(ctx)
|
||||
}
|
||||
|
||||
s.handler = httputil.Chain(s.routes(),
|
||||
httputil.Recoverer,
|
||||
@@ -749,6 +777,10 @@ func (s *Server) Close() error {
|
||||
// cursors.
|
||||
s.appservices.Close()
|
||||
}
|
||||
if s.delayed != nil {
|
||||
// And the delayed event scheduler, which sends events.
|
||||
s.delayed.Close()
|
||||
}
|
||||
if s.stopTracing != nil {
|
||||
// Spans still in the batch are sent rather than lost.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
|
||||
Reference in new issue
Block a user