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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user