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>
260 lines
8.9 KiB
Go
260 lines
8.9 KiB
Go
package clientapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
|
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
|
)
|
|
|
|
// MSC4140 is still unstable, so its endpoints and parameters carry its
|
|
// prefix.
|
|
const (
|
|
delayedEventsPrefix = "/_matrix/client/unstable/org.matrix.msc4140"
|
|
delayParam = "org.matrix.msc4140.delay"
|
|
parentDelayParam = "org.matrix.msc4140.parent_delay_id"
|
|
)
|
|
|
|
// registerDelayedEventRoutes adds the MSC4140 endpoints, if delayed events
|
|
// are switched on.
|
|
//
|
|
// The action endpoints are public: the delay ID is the capability, which is
|
|
// what lets a client hand its delayed leave to something else to keep alive.
|
|
// Both spellings are served — the action in the path, which is the current
|
|
// draft, and in the body, which clients fall back to.
|
|
func (c *ClientAPI) registerDelayedEventRoutes(r *httputil.Router) {
|
|
if !c.delayed.Enabled() || c.rooms == nil {
|
|
return
|
|
}
|
|
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events", c.limitDefault, c.handleGetDelayedEvents)
|
|
c.authed(r, "GET "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleGetDelayedEvent)
|
|
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}", c.limitDefault, c.handleUpdateDelayedEvent)
|
|
c.public(r, "POST "+delayedEventsPrefix+"/delayed_events/{delayID}/{action}", c.limitDefault, c.handleUpdateDelayedEvent)
|
|
}
|
|
|
|
// delayRequested reads the delay parameter of a send or state PUT. ok is
|
|
// false when there is none, and the event is sent at once as usual.
|
|
func (c *ClientAPI) delayRequested(r *http.Request) (time.Duration, bool, error) {
|
|
q := r.URL.Query()
|
|
if q.Has(parentDelayParam) {
|
|
return 0, false, httputil.InvalidParam("%s is not supported", parentDelayParam)
|
|
}
|
|
if !q.Has(delayParam) {
|
|
return 0, false, nil
|
|
}
|
|
if !c.delayed.Enabled() {
|
|
// Refused rather than ignored: sending a delayed "left the call"
|
|
// at once would be exactly the wrong thing.
|
|
return 0, false, httputil.InvalidParam("Delayed events are not enabled on this server")
|
|
}
|
|
ms, err := strconv.ParseInt(q.Get(delayParam), 10, 64)
|
|
if err != nil || ms < 0 {
|
|
return 0, false, httputil.InvalidParam("%s must be a non-negative number of milliseconds", delayParam)
|
|
}
|
|
return time.Duration(ms) * time.Millisecond, true, nil
|
|
}
|
|
|
|
// delayIDResponse answers a delayed send.
|
|
type delayIDResponse struct {
|
|
DelayID string `json:"delay_id"`
|
|
}
|
|
|
|
// scheduleDelayed schedules what a send or state PUT asked to delay.
|
|
func (c *ClientAPI) scheduleDelayed(w http.ResponseWriter, r *http.Request, req delayedevents.ScheduleRequest) error {
|
|
delayID, err := c.delayed.Schedule(r.Context(), req)
|
|
if err != nil {
|
|
return delayedError(err, c.delayed.MaxDelay())
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, delayIDResponse{DelayID: delayID})
|
|
return nil
|
|
}
|
|
|
|
// delayedError maps the service's errors onto Matrix errors. An overlong
|
|
// delay is reported the way Synapse does: M_UNKNOWN for a client that knows
|
|
// nothing of MSC4140, with the MSC's own code and the maximum alongside.
|
|
func delayedError(err error, maxDelay time.Duration) error {
|
|
switch {
|
|
case errors.Is(err, delayedevents.ErrMaxDelayExceeded):
|
|
merr := httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnknown, "%s", err.Error())
|
|
merr.DelayedErrCode = "M_MAX_DELAY_EXCEEDED"
|
|
merr.MaxDelayMS = maxDelay.Milliseconds()
|
|
return merr
|
|
case errors.Is(err, delayedevents.ErrTooMany):
|
|
return httputil.NewError(http.StatusBadRequest, "M_LIMIT_EXCEEDED", "%s", err.Error())
|
|
case errors.Is(err, delayedevents.ErrDisabled):
|
|
return httputil.Unrecognized()
|
|
case errors.Is(err, storage.ErrNoSuchDelayedEvent):
|
|
return httputil.NotFound("No such scheduled delayed event")
|
|
default:
|
|
return roomError(err)
|
|
}
|
|
}
|
|
|
|
// delayedEventJSON is one delayed event as MSC4140 lists it.
|
|
type delayedEventJSON struct {
|
|
DelayID string `json:"delay_id"`
|
|
RoomID string `json:"room_id"`
|
|
Type string `json:"type"`
|
|
StateKey *string `json:"state_key,omitempty"`
|
|
Content json.RawMessage `json:"content"`
|
|
Delay int64 `json:"delay"`
|
|
RunningSince int64 `json:"running_since"`
|
|
}
|
|
|
|
type finalisedDelayedEventJSON struct {
|
|
DelayedEvent delayedEventJSON `json:"delayed_event"`
|
|
Outcome string `json:"outcome"`
|
|
Reason string `json:"reason"`
|
|
Error json.RawMessage `json:"error,omitempty"`
|
|
EventID string `json:"event_id,omitempty"`
|
|
OriginServerTS int64 `json:"origin_server_ts,omitempty"`
|
|
}
|
|
|
|
type delayedEventsResponse struct {
|
|
Scheduled []delayedEventJSON `json:"scheduled"`
|
|
Finalised []finalisedDelayedEventJSON `json:"finalised"`
|
|
|
|
// DelayedEvents is the scheduled list under the name an earlier draft
|
|
// of MSC4140 used.
|
|
DelayedEvents []delayedEventJSON `json:"delayed_events"`
|
|
}
|
|
|
|
func toDelayedEventJSON(e storage.DelayedEvent) delayedEventJSON {
|
|
return delayedEventJSON{
|
|
DelayID: e.DelayID,
|
|
RoomID: e.RoomID,
|
|
Type: e.Type,
|
|
StateKey: e.StateKey,
|
|
Content: e.Content,
|
|
Delay: e.DelayMS,
|
|
RunningSince: e.RunningSinceMS,
|
|
}
|
|
}
|
|
|
|
func toFinalisedJSON(e storage.DelayedEvent) finalisedDelayedEventJSON {
|
|
out := finalisedDelayedEventJSON{
|
|
DelayedEvent: toDelayedEventJSON(e),
|
|
Outcome: e.Outcome,
|
|
Reason: e.Reason,
|
|
Error: e.Error,
|
|
EventID: e.EventID,
|
|
}
|
|
if e.EventID != "" {
|
|
out.OriginServerTS = e.FinalisedAtMS
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleGetDelayedEvents implements
|
|
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events.
|
|
//
|
|
// status narrows the answer to scheduled or finalised events, and delay_id,
|
|
// which may repeat, to particular ones. Nobody has enough delayed events
|
|
// for paging to matter, so there is never a next_batch.
|
|
func (c *ClientAPI) handleGetDelayedEvents(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
q := r.URL.Query()
|
|
status := q.Get("status")
|
|
if status != "" && status != "scheduled" && status != "finalised" {
|
|
return httputil.InvalidParam("status must be scheduled or finalised")
|
|
}
|
|
ids := q["delay_id"]
|
|
|
|
events, err := c.delayed.List(r.Context(), auth.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp := delayedEventsResponse{
|
|
Scheduled: []delayedEventJSON{},
|
|
Finalised: []finalisedDelayedEventJSON{},
|
|
}
|
|
for _, e := range events {
|
|
if len(ids) > 0 && !slices.Contains(ids, e.DelayID) {
|
|
continue
|
|
}
|
|
if e.FinalisedAtMS == 0 {
|
|
if status != "finalised" {
|
|
resp.Scheduled = append(resp.Scheduled, toDelayedEventJSON(e))
|
|
}
|
|
} else if status != "scheduled" {
|
|
resp.Finalised = append(resp.Finalised, toFinalisedJSON(e))
|
|
}
|
|
}
|
|
resp.DelayedEvents = resp.Scheduled
|
|
httputil.WriteJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// handleGetDelayedEvent implements
|
|
// GET /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}.
|
|
func (c *ClientAPI) handleGetDelayedEvent(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
|
e, err := c.delayed.Get(r.Context(), auth.UserID, r.PathValue("delayID"))
|
|
if err != nil {
|
|
return delayedError(err, 0)
|
|
}
|
|
if e.FinalisedAtMS == 0 {
|
|
httputil.WriteJSON(w, http.StatusOK, toDelayedEventJSON(e))
|
|
} else {
|
|
httputil.WriteJSON(w, http.StatusOK, toFinalisedJSON(e))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type updateDelayedEventRequest struct {
|
|
Action string `json:"action"`
|
|
}
|
|
|
|
// handleUpdateDelayedEvent implements
|
|
// POST /_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delayID}[/{action}].
|
|
func (c *ClientAPI) handleUpdateDelayedEvent(w http.ResponseWriter, r *http.Request) error {
|
|
delayID := r.PathValue("delayID")
|
|
action := r.PathValue("action")
|
|
if action == "" {
|
|
var req updateDelayedEventRequest
|
|
if err := httputil.DecodeJSON(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
action = req.Action
|
|
}
|
|
|
|
var err error
|
|
switch action {
|
|
case "restart":
|
|
err = c.delayed.Restart(r.Context(), delayID)
|
|
case "cancel":
|
|
err = c.delayed.Cancel(r.Context(), delayID)
|
|
case "send":
|
|
err = c.delayed.Send(r.Context(), delayID)
|
|
case "":
|
|
return httputil.MissingParam("An action is required")
|
|
default:
|
|
return httputil.InvalidParam("action must be restart, cancel or send")
|
|
}
|
|
if err != nil {
|
|
return delayedError(err, 0)
|
|
}
|
|
httputil.WriteJSON(w, http.StatusOK, struct{}{})
|
|
return nil
|
|
}
|
|
|
|
// DescribeRoomError is the Matrix error a failed delayed send is recorded
|
|
// with: the error the client would have got had it sent the event itself.
|
|
func DescribeRoomError(err error) json.RawMessage {
|
|
var matrixErr *httputil.MatrixError
|
|
if !errors.As(roomError(err), &matrixErr) {
|
|
matrixErr = httputil.NewError(http.StatusInternalServerError, httputil.ErrCodeUnknown, "%s", err.Error())
|
|
}
|
|
raw, _ := json.Marshal(matrixErr)
|
|
return raw
|
|
}
|
|
|
|
// Compile-time check that the roomserver can be the service's sender.
|
|
var _ delayedevents.Sender = (*roomserver.API)(nil)
|