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

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:
tajniak81andClaude Opus 5.5 committed 2026-10-18 19:33:29 +02:00
1 parent 325431a5be
commit 76dfc98a86
49 files changed
+3849 -432

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
package clientapi_test
import (
"net/http"
"net/url"
"strconv"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
const delayedEventsPath = "/_matrix/client/unstable/org.matrix.msc4140/delayed_events"
// callMemberPath is where a member of a room's group call puts its state.
func callMemberPath(roomID, stateKey string) string {
return "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) +
"/state/org.matrix.msc3401.call.member/" + url.PathEscape(stateKey)
}
// scheduleDelayed PUTs a delayed state event and returns its delay ID.
func (ts *testServer) scheduleDelayed(path string, delayMS int, acc account, content map[string]any) string {
ts.t.Helper()
rec := ts.put(path+"?org.matrix.msc4140.delay="+strconv.Itoa(delayMS), acc.AccessToken, content)
expectStatus(ts.t, rec, http.StatusOK)
delayID, _ := jsonBody(ts.t, rec)["delay_id"].(string)
if delayID == "" {
ts.t.Fatalf("a delayed send returned no delay_id: %s", rec.Body.String())
}
return delayID
}
// tick moves the clock and sends whatever has come due.
func (ts *testServer) tick(d time.Duration) {
ts.t.Helper()
ts.clock.advance(d)
ts.delayed.SendDue(ts.t.Context())
}
type delayedList struct {
Scheduled []struct {
DelayID string `json:"delay_id"`
RoomID string `json:"room_id"`
Type string `json:"type"`
StateKey *string `json:"state_key"`
Content map[string]any `json:"content"`
Delay int64 `json:"delay"`
RunningSince int64 `json:"running_since"`
} `json:"scheduled"`
Finalised []struct {
DelayedEvent struct {
DelayID string `json:"delay_id"`
} `json:"delayed_event"`
Outcome string `json:"outcome"`
Reason string `json:"reason"`
EventID string `json:"event_id"`
Error map[string]any `json:"error"`
} `json:"finalised"`
}
func (ts *testServer) delayedList(acc account) delayedList {
ts.t.Helper()
rec := ts.get(delayedEventsPath, acc.AccessToken)
expectStatus(ts.t, rec, http.StatusOK)
return decode[delayedList](ts.t, rec)
}
// TestDelayedStateEventIsSentWhenDue is the MatrixRTC case: a member's
// "left the call" is scheduled when it joins, and arrives on its own once
// the delay runs out.
func TestDelayedStateEventIsSentWhenDue(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
stateKey := "_" + alice.UserID + "_" + alice.DeviceID + "_m.call"
path := callMemberPath(roomID, stateKey)
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
// Nothing is in the room yet.
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
list := ts.delayedList(alice)
if len(list.Scheduled) != 1 || list.Scheduled[0].DelayID != delayID {
t.Fatalf("scheduled = %+v", list.Scheduled)
}
got := list.Scheduled[0]
if got.RoomID != roomID || got.Type != "org.matrix.msc3401.call.member" ||
got.StateKey == nil || *got.StateKey != stateKey || got.Delay != 5000 {
t.Errorf("the scheduled event is %+v", got)
}
ts.tick(4 * time.Second)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
ts.tick(2 * time.Second)
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
list = ts.delayedList(alice)
if len(list.Scheduled) != 0 {
t.Errorf("still scheduled after sending: %+v", list.Scheduled)
}
if len(list.Finalised) != 1 || list.Finalised[0].Outcome != "send" ||
list.Finalised[0].Reason != "delay" || list.Finalised[0].EventID == "" {
t.Errorf("finalised = %+v", list.Finalised)
}
// Looked up on its own, it is the same.
rec := ts.get(delayedEventsPath+"/"+delayID, alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["outcome"] != "send" {
t.Errorf("GET by ID = %s", rec.Body.String())
}
}
// TestDelayedEventRestart: a client still in the call keeps pushing its
// leave back, and it never arrives.
func TestDelayedEventRestart(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
for range 3 {
ts.tick(4 * time.Second)
// The action endpoints need no token: the delay ID is the
// capability.
expectStatus(t, ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil), http.StatusOK)
}
ts.tick(4 * time.Second)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
ts.tick(2 * time.Second)
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
// Once it has been sent it cannot be restarted.
rec := ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil)
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
}
// TestDelayedEventActions covers cancel and send, in both the path and the
// body spelling.
func TestDelayedEventActions(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
escaped := url.PathEscape(roomID)
// Cancelled with the action in the body: never sent.
topic := "/_matrix/client/v3/rooms/" + escaped + "/state/m.room.topic"
cancelled := ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "never"})
expectStatus(t, ts.post(delayedEventsPath+"/"+cancelled, alice.AccessToken,
map[string]any{"action": "cancel"}), http.StatusOK)
ts.tick(2 * time.Second)
expectError(t, ts.get(topic, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
// Sent at once with the action in the path: a timeline event, which
// is how a client sends a message it held back.
send := "/_matrix/client/v3/rooms/" + escaped + "/send/m.room.message/txn1"
sent := ts.scheduleDelayed(send, 60000, alice, map[string]any{"msgtype": "m.text", "body": "later"})
expectStatus(t, ts.post(delayedEventsPath+"/"+sent+"/send", "", nil), http.StatusOK)
list := ts.delayedList(alice)
if len(list.Scheduled) != 0 || len(list.Finalised) != 2 {
t.Fatalf("list = %+v", list)
}
outcomes := map[string]string{}
for _, f := range list.Finalised {
outcomes[f.DelayedEvent.DelayID] = f.Outcome + "/" + f.Reason
}
if outcomes[cancelled] != "cancel/action" || outcomes[sent] != "send/action" {
t.Errorf("outcomes = %v", outcomes)
}
// status narrows the list.
rec := ts.get(delayedEventsPath+"?status=scheduled", alice.AccessToken)
expectStatus(t, rec, http.StatusOK)
if l := decode[delayedList](t, rec); len(l.Finalised) != 0 {
t.Errorf("status=scheduled listed finalised events: %s", rec.Body.String())
}
expectError(t, ts.post(delayedEventsPath+"/nope/cancel", "", nil), http.StatusNotFound, httputil.ErrCodeNotFound)
expectError(t, ts.post(delayedEventsPath+"/"+sent+"/explode", "", nil),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
}
// TestDelayedEventFailureIsRecorded: an event that can no longer be sent
// is finalised with the error the client would have got.
func TestDelayedEventFailureIsRecorded(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
ts.scheduleDelayed(path, 1000, alice, map[string]any{})
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave",
alice.AccessToken, map[string]any{}), http.StatusOK)
ts.tick(2 * time.Second)
list := ts.delayedList(alice)
if len(list.Finalised) != 1 || list.Finalised[0].Reason != "error" ||
list.Finalised[0].Error["errcode"] != httputil.ErrCodeForbidden {
t.Errorf("finalised = %+v", list.Finalised)
}
}
// TestDelayedStateIsCancelledByOthers: someone else setting the same state
// cancels a delayed event that would have overwritten it, and the owner's
// own updates do not. It also proves an ordinary member may join a call.
func TestDelayedStateIsCancelledByOthers(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
bob := ts.register("bob", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
bob.AccessToken, map[string]any{}), http.StatusOK)
aliceKey := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
ts.scheduleDelayed(aliceKey, 5000, alice, map[string]any{})
// Alice refreshing her own membership leaves the delayed leave alone.
expectStatus(t, ts.put(aliceKey, alice.AccessToken, map[string]any{"application": "m.call"}), http.StatusOK)
// Bob, an ordinary member, may put call state of his own.
expectStatus(t, ts.put(callMemberPath(roomID, "_"+bob.UserID+"_B_m.call"), bob.AccessToken,
map[string]any{"application": "m.call"}), http.StatusOK)
// The cancellation runs off the emit stage, so give it a moment.
time.Sleep(100 * time.Millisecond)
if l := ts.delayedList(alice); len(l.Scheduled) != 1 {
t.Fatalf("alice's own update cancelled her delayed event: %+v", l)
}
// Bob writing the same piece of state cancels it.
expectStatus(t, ts.put(aliceKey, bob.AccessToken, map[string]any{}), http.StatusOK)
deadline := time.Now().Add(5 * time.Second)
for {
l := ts.delayedList(alice)
if len(l.Scheduled) == 0 {
if len(l.Finalised) != 1 || l.Finalised[0].Outcome != "cancel" {
t.Errorf("finalised = %+v", l.Finalised)
}
break
}
if time.Now().After(deadline) {
t.Fatal("the overwritten delayed event was never cancelled")
}
time.Sleep(10 * time.Millisecond)
}
}
func TestDelayedEventLimits(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
topic := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.topic"
// Longer than the server allows: the MSC's own code and the maximum
// come with the error.
rec := ts.put(topic+"?org.matrix.msc4140.delay=7200000", alice.AccessToken, map[string]any{"topic": "x"})
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown)
body := jsonBody(t, rec)
if body["org.matrix.msc4140.errcode"] != "M_MAX_DELAY_EXCEEDED" || body["org.matrix.msc4140.max_delay"] != float64(3600000) {
t.Errorf("error = %s", rec.Body.String())
}
expectError(t, ts.put(topic+"?org.matrix.msc4140.delay=soon", alice.AccessToken, map[string]any{"topic": "x"}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
expectError(t, ts.put(topic+"?org.matrix.msc4140.parent_delay_id=x", alice.AccessToken, map[string]any{"topic": "x"}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
// Five at once is the test server's limit.
for range 5 {
ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "x"})
}
rec = ts.put(topic+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{"topic": "x"})
expectError(t, rec, http.StatusBadRequest, "M_LIMIT_EXCEEDED")
// Nobody else can read them.
bob := ts.register("bob", "correct-horse-battery-staple")
if l := ts.delayedList(bob); len(l.Scheduled) != 0 {
t.Errorf("bob sees alice's delayed events: %+v", l)
}
expectError(t, ts.get(delayedEventsPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
}
// TestDelayedEventsDisabled: without the service the parameter is refused,
// rather than the event being sent at once, and the endpoints are absent.
func TestDelayedEventsDisabled(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) { o.DelayedEvents = nil })
alice := ts.register("alice", "correct-horse-battery-staple")
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
path := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
expectError(t, ts.put(path+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{}),
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
expectError(t, ts.get(delayedEventsPath, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeUnrecognized)
}