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
+18
-6
@@ -63,12 +63,24 @@ that one origin.
|
||||
them asks you to acknowledge it; a reset since you *verified* them says
|
||||
their sessions are no longer trusted, with Verify again or Withdraw
|
||||
verification.
|
||||
- Voice and video calls in a room with one other person (Matrix 1:1 VoIP,
|
||||
WebRTC between the browsers): ringing, answer with or without video,
|
||||
mute, camera on and off, screen sharing, and a panel that stays up while
|
||||
you move between rooms and expands to fill the window. In an encrypted
|
||||
room the call signalling is encrypted too. Calls outside one network
|
||||
need a TURN server; see `client_api.turn` in the server configuration.
|
||||
- Voice and video calls in any room, all through a LiveKit SFU (MatrixRTC,
|
||||
the Element Call model; the old Matrix 1:1 VoIP is not used). Voice and
|
||||
video call buttons in the room header. In a room with one other person
|
||||
the call rings them: an incoming-call card with ring tone, answer, answer
|
||||
without video and decline, and "Calling…" with a ringback tone for the
|
||||
caller until they answer, decline or a minute passes. A call of two shows
|
||||
the other person large and you in the corner; a bigger one is a grid
|
||||
with a tile per person, their camera or screen, and a ring around
|
||||
whoever is speaking. Mute, camera, screen sharing, leave, and a panel
|
||||
that stays up while you move between rooms. A "Join call" banner while
|
||||
one is going on in a room you're not in it. In an encrypted room the media
|
||||
is end-to-end encrypted with per-participant keys. The room list says
|
||||
when a room has a call going on, and the timeline says when one started
|
||||
and ended. It needs `client_api.rtc` on the server and a LiveKit JWT
|
||||
service; see [docs/calls.md](../../docs/calls.md). warp-web proxies
|
||||
`/.well-known/matrix/client` for this, and `-call-origins`
|
||||
(`WARP_WEB_CALL_ORIGINS`) lets the browser reach the JWT service and
|
||||
LiveKit through the Content-Security-Policy.
|
||||
- Encryption setup in Settings: cross-signing keys, secret storage and a
|
||||
key backup in one step, behind a recovery key shown once. A new session
|
||||
verifies itself with the recovery key (or another session) and restores
|
||||
|
||||
@@ -54,6 +54,8 @@ func run() error {
|
||||
"base URL of the homeserver, proxied for the client (WARP_WEB_HOMESERVER)")
|
||||
public := flag.String("public-homeserver", env("WARP_WEB_PUBLIC_HOMESERVER", ""),
|
||||
"public URL of the homeserver; when set, the browser calls it directly and nothing is proxied (WARP_WEB_PUBLIC_HOMESERVER)")
|
||||
callOrigins := flag.String("call-origins", env("WARP_WEB_CALL_ORIGINS", ""),
|
||||
"space-separated origins of the LiveKit JWT service and SFU the browser may connect to for group calls (WARP_WEB_CALL_ORIGINS)")
|
||||
logFormat := flag.String("log-format", env("WARP_WEB_LOG_FORMAT", "text"), "text or json (WARP_WEB_LOG_FORMAT)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
@@ -74,7 +76,7 @@ func run() error {
|
||||
}
|
||||
slog.SetDefault(logger)
|
||||
|
||||
opts := host.Options{Logger: logger, Version: version}
|
||||
opts := host.Options{Logger: logger, Version: version, CallOrigins: strings.Fields(*callOrigins)}
|
||||
mode, target := "proxy", *homeserver
|
||||
if *public != "" {
|
||||
u, err := parseURL("public-homeserver", *public)
|
||||
|
||||
@@ -41,6 +41,11 @@ type Options struct {
|
||||
Logger *slog.Logger
|
||||
// Transport overrides the proxy's round tripper, for tests.
|
||||
Transport http.RoundTripper
|
||||
// CallOrigins are origins outside the homeserver the browser may
|
||||
// connect to for group calls: the LiveKit JWT service and the LiveKit
|
||||
// SFU's WebSocket, e.g. https://livekit-jwt.example and
|
||||
// wss://livekit.example. They are added to the CSP's connect-src.
|
||||
CallOrigins []string
|
||||
}
|
||||
|
||||
// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
|
||||
@@ -48,6 +53,10 @@ type Options struct {
|
||||
// are not reachable through warp-web.
|
||||
var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"}
|
||||
|
||||
// wellKnownClientPath is proxied too: it is where the client finds the
|
||||
// group call backend (org.matrix.msc4143.rtc_foci).
|
||||
const wellKnownClientPath = "/.well-known/matrix/client"
|
||||
|
||||
// Config is the body of /config.json, which the client reads on start.
|
||||
type Config struct {
|
||||
// HomeserverURL is where the client sends Matrix requests; empty
|
||||
@@ -78,6 +87,22 @@ func New(opts Options) (http.Handler, error) {
|
||||
cfg.HomeserverURL = strings.TrimRight(u.String(), "/")
|
||||
connect += " " + u.Scheme + "://" + u.Host
|
||||
}
|
||||
for _, raw := range opts.CallOrigins {
|
||||
u, err := url.Parse(strings.TrimRight(raw, "/"))
|
||||
if err != nil || u.Host == "" || u.Path != "" || u.RawQuery != "" ||
|
||||
(u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") {
|
||||
return nil, errors.New("host: a call origin must be an http(s) or ws(s) origin, got " + raw)
|
||||
}
|
||||
connect += " " + u.Scheme + "://" + u.Host
|
||||
// LiveKit falls back from its WebSocket to an HTTP request on
|
||||
// the same host to find out why a connection failed.
|
||||
switch u.Scheme {
|
||||
case "ws":
|
||||
connect += " http://" + u.Host
|
||||
case "wss":
|
||||
connect += " https://" + u.Host
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -167,6 +192,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
return
|
||||
|
||||
case p == wellKnownClientPath && h.proxy != nil:
|
||||
h.proxy.ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"):
|
||||
// A path that cleans to something else (dot segments, doubled
|
||||
// slashes) is refused rather than normalised: what is allowed
|
||||
|
||||
@@ -304,3 +304,35 @@ func TestNewValidatesHomeserver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiesWellKnownClient(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
h := newHandler(t, up, true)
|
||||
rec := do(h, http.MethodGet, "/.well-known/matrix/client", nil)
|
||||
if rec.Code != 200 || len(up.hits) != 1 || up.hits[0].URL.Path != "/.well-known/matrix/client" {
|
||||
t.Fatalf("status %d, hits %d", rec.Code, len(up.hits))
|
||||
}
|
||||
// The server delegation document stays private.
|
||||
if rec := do(h, http.MethodGet, "/.well-known/matrix/server", nil); rec.Code == 200 && len(up.hits) > 1 {
|
||||
t.Errorf("/.well-known/matrix/server was proxied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallOriginsJoinConnectSrc(t *testing.T) {
|
||||
up := newUpstream(t)
|
||||
target, _ := url.Parse(up.srv.URL)
|
||||
h, err := New(Options{Homeserver: target, Client: clientFS,
|
||||
CallOrigins: []string{"https://livekit-jwt.warp.example/", "wss://livekit.warp.example"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := do(h, http.MethodGet, "/", nil)
|
||||
want := "connect-src 'self' https://livekit-jwt.warp.example wss://livekit.warp.example https://livekit.warp.example;"
|
||||
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP %q", csp)
|
||||
}
|
||||
|
||||
if _, err := New(Options{Homeserver: target, CallOrigins: []string{"https://x.example/path"}}); err == nil {
|
||||
t.Error("a call origin with a path was accepted")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@warp/ui": "workspace:*",
|
||||
"jsqr": "1.4.0",
|
||||
"livekit-client": "2.22.3",
|
||||
"matrix-js-sdk": "^44.0.0",
|
||||
"pinia": "^3.0.3",
|
||||
"qrcode-generator": "1.4.4",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { Track } from 'livekit-client'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { WIcon } from '@warp/ui'
|
||||
import MxcAvatar from '@/components/MxcAvatar.vue'
|
||||
import type { Tile } from '@/stores/call'
|
||||
|
||||
// One person in a group call: their screen or camera, else their avatar,
|
||||
// with their name, a muted microphone, and a ring while they speak.
|
||||
|
||||
const props = defineProps<{ tile: Tile; compact?: boolean }>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement>()
|
||||
const audioEl = ref<HTMLAudioElement>()
|
||||
|
||||
const shown = computed(() => props.tile.screen ?? props.tile.camera)
|
||||
// Your own camera is mirrored, the way a mirror (and every call app) shows you.
|
||||
const mirrored = computed(() => props.tile.local && !props.tile.screen)
|
||||
|
||||
function bind(el: HTMLMediaElement | undefined, track: Track | undefined, old: Track | undefined) {
|
||||
if (!el) return
|
||||
if (old && old !== track) old.detach(el)
|
||||
if (track) track.attach(el)
|
||||
}
|
||||
|
||||
watch([videoEl, shown], ([el, track], [, old]) => bind(el, track, old), { flush: 'post', immediate: true })
|
||||
watch([audioEl, () => props.tile.audio], ([el, track], [, old]) => bind(el, track, old), { flush: 'post', immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (videoEl.value) shown.value?.detach(videoEl.value)
|
||||
if (audioEl.value) props.tile.audio?.detach(audioEl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative flex min-h-0 items-center justify-center overflow-hidden rounded-md bg-[#1d2126] transition-shadow',
|
||||
tile.speaking ? 'shadow-[0_0_0_3px_#3fb68b]' : '',
|
||||
]"
|
||||
>
|
||||
<audio v-if="tile.audio" ref="audioEl" autoplay class="hidden" />
|
||||
<video
|
||||
v-show="shown"
|
||||
ref="videoEl"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
:class="['h-full w-full', tile.screen ? 'object-contain' : 'object-cover', mirrored && '-scale-x-100']"
|
||||
/>
|
||||
<MxcAvatar v-if="!shown" :name="tile.name" :mxc="tile.avatarMxc" :size="compact ? 48 : 88" />
|
||||
<div class="absolute bottom-2 left-2 flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-sm bg-[#000000a6] px-2 py-0.5">
|
||||
<WIcon v-if="tile.micMuted" name="mic-off" :size="14" label="Muted" class="shrink-0 text-[#ff8a80]" />
|
||||
<WIcon v-if="tile.screen" name="screen-share" :size="14" label="Sharing their screen" class="shrink-0" />
|
||||
<span class="caption truncate text-[#eceeee]">{{ tile.local ? `${tile.name} (you)` : tile.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,108 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed'
|
||||
import { computed, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WIcon } from '@warp/ui'
|
||||
import { WIcon, WSpinner } from '@warp/ui'
|
||||
import type { IconName } from '@warp/ui'
|
||||
import CallTile from '@/components/CallTile.vue'
|
||||
import MxcAvatar from '@/components/MxcAvatar.vue'
|
||||
import { formatDuration } from '@/lib/format'
|
||||
import { useCall } from '@/stores/call'
|
||||
import { useCall, type Tile } from '@/stores/call'
|
||||
|
||||
// The call on screen: a ringing card for an incoming call, else a panel in
|
||||
// the corner that a video call can expand to fill the window. It sits above
|
||||
// every view, so a call goes on while you move between rooms.
|
||||
// The call on screen: a ringing card for a call coming in, else the call
|
||||
// you're in. A call of two shows the other person large and you in the
|
||||
// corner; a bigger one is a grid. The panel shrinks to the corner so the
|
||||
// call goes on while you read and write in your rooms, and sits above
|
||||
// every view.
|
||||
|
||||
const call = useCall()
|
||||
const router = useRouter()
|
||||
const expanded = ref(false)
|
||||
|
||||
function feedVideo(feed: CallFeed | undefined): MediaStream | undefined {
|
||||
if (!feed || feed.isVideoMuted() || !feed.stream.getVideoTracks().length) return undefined
|
||||
return feed.stream
|
||||
}
|
||||
|
||||
const view = computed(() => {
|
||||
void call.version
|
||||
const c = call.call
|
||||
if (!c) return null
|
||||
const remoteCamera = feedVideo(c.remoteUsermediaFeed)
|
||||
const remoteScreen = c.remoteScreensharingStream
|
||||
const localCamera = feedVideo(c.localUsermediaFeed)
|
||||
const localScreen = c.localScreensharingStream
|
||||
// Their screen beats their camera on the big stage; whatever is left of
|
||||
// theirs or yours goes in the small picture.
|
||||
const main = remoteScreen ?? remoteCamera
|
||||
const pip = remoteScreen && remoteCamera ? remoteCamera : (localScreen ?? localCamera)
|
||||
return {
|
||||
main,
|
||||
pip,
|
||||
pipMirrored: pip !== undefined && pip === localCamera,
|
||||
remoteAudio: c.remoteUsermediaStream,
|
||||
micMuted: c.isMicrophoneMuted(),
|
||||
cameraOn: Boolean(localCamera),
|
||||
sharing: c.isScreensharing(),
|
||||
remoteOnHold: c.isRemoteOnHold(),
|
||||
localOnHold: c.isLocalOnHold(),
|
||||
roomId: c.roomId,
|
||||
}
|
||||
})
|
||||
|
||||
const stage = computed(() => call.phase !== 'ringing' && Boolean(view.value?.main || view.value?.pip || call.isVideo))
|
||||
const canShare = typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getDisplayMedia)
|
||||
|
||||
const status = computed(() => {
|
||||
const v = view.value
|
||||
switch (call.phase) {
|
||||
case 'ringing':
|
||||
return call.isVideo ? 'Incoming video call' : 'Incoming voice call'
|
||||
case 'calling':
|
||||
return 'Calling…'
|
||||
case 'connecting':
|
||||
return 'Connecting…'
|
||||
case 'connected':
|
||||
if (v?.localOnHold) return 'You’re on hold'
|
||||
if (v?.remoteOnHold) return 'On hold'
|
||||
return formatDuration(call.length)
|
||||
case 'ended':
|
||||
return call.endReason
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Streams go to the media elements by hand: srcObject is not an attribute.
|
||||
const mainEl = ref<HTMLVideoElement>()
|
||||
const pipEl = ref<HTMLVideoElement>()
|
||||
const audioEl = ref<HTMLAudioElement>()
|
||||
|
||||
function bind(el: HTMLMediaElement | undefined, stream: MediaStream | undefined) {
|
||||
if (!el || el.srcObject === (stream ?? null)) return
|
||||
el.srcObject = stream ?? null
|
||||
if (stream) void el.play().catch(() => undefined)
|
||||
}
|
||||
watchEffect(() => bind(mainEl.value, view.value?.main), { flush: 'post' })
|
||||
watchEffect(() => bind(pipEl.value, view.value?.pip), { flush: 'post' })
|
||||
watchEffect(() => bind(audioEl.value, view.value?.remoteAudio), { flush: 'post' })
|
||||
const expanded = ref(true)
|
||||
|
||||
watch(
|
||||
() => call.call,
|
||||
(c) => {
|
||||
if (!c) expanded.value = false
|
||||
() => call.roomId,
|
||||
(rid) => {
|
||||
// Video calls and group calls open full size; a voice call between
|
||||
// two people starts as the panel in the corner.
|
||||
if (rid) expanded.value = call.video || !call.callee
|
||||
},
|
||||
)
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded.value) expanded.value = false
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
const remotes = computed(() => call.tiles.filter((t) => !t.local))
|
||||
const self = computed(() => call.tiles.find((t) => t.local))
|
||||
/** A call of two: the other person on the stage, you in the corner. */
|
||||
const pair = computed(() => remotes.value.length === 1)
|
||||
/** Ringing them, nobody here yet. */
|
||||
const calling = computed(() => call.ringingOut && remotes.value.length === 0)
|
||||
|
||||
function openRoom() {
|
||||
const roomId = view.value?.roomId
|
||||
if (!roomId) return
|
||||
expanded.value = false
|
||||
router.push({ name: 'room', params: { roomId } })
|
||||
}
|
||||
// Whoever shares their screen takes the stage; otherwise the person
|
||||
// speaking comes first, so the panel in the corner shows them.
|
||||
const ordered = computed(() => {
|
||||
const rank = (t: Tile) => (t.screen ? 0 : t.speaking && !t.local ? 1 : t.local ? 3 : 2)
|
||||
return [...call.tiles].sort((a, b) => rank(a) - rank(b))
|
||||
})
|
||||
const shown = computed(() => (expanded.value ? ordered.value : ordered.value.slice(0, 1)))
|
||||
// The rest still have to be heard while the panel shows only one.
|
||||
const hidden = computed(() => (expanded.value ? [] : ordered.value.slice(1).filter((t) => t.audio)))
|
||||
|
||||
const columns = computed(() => {
|
||||
const n = shown.value.length
|
||||
if (n <= 1) return 'grid-cols-1'
|
||||
if (n <= 4) return 'grid-cols-1 sm:grid-cols-2'
|
||||
if (n <= 9) return 'grid-cols-2 lg:grid-cols-3'
|
||||
return 'grid-cols-2 md:grid-cols-3 xl:grid-cols-4'
|
||||
})
|
||||
|
||||
const status = computed(() => {
|
||||
switch (call.phase) {
|
||||
case 'joining':
|
||||
return calling.value ? 'Calling…' : 'Joining…'
|
||||
case 'reconnecting':
|
||||
return 'Reconnecting…'
|
||||
default: {
|
||||
if (calling.value) return 'Calling…'
|
||||
const time = formatDuration(call.length)
|
||||
if (call.callee) return time
|
||||
const n = call.tiles.length
|
||||
return n === 1 ? `Only you so far · ${time}` : `${n} people · ${time}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const canShare = typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getDisplayMedia)
|
||||
|
||||
interface Control {
|
||||
icon: IconName
|
||||
@@ -113,23 +79,28 @@ interface Control {
|
||||
}
|
||||
|
||||
const controls = computed<Control[]>(() => {
|
||||
const v = view.value
|
||||
if (!v) return []
|
||||
if (call.phase === 'ringing') {
|
||||
const list: Control[] = [{ icon: 'phone-off', label: 'Decline', action: call.decline, tone: 'danger' }]
|
||||
if (call.isVideo) list.push({ icon: 'phone', label: 'Answer without video', action: () => call.answer(false) })
|
||||
list.push({ icon: call.isVideo ? 'video' : 'phone', label: 'Answer', action: () => call.answer(), tone: 'accept' })
|
||||
return list
|
||||
}
|
||||
if (call.phase === 'ended') return [{ icon: 'close', label: 'Close', action: call.hangup }]
|
||||
const list: Control[] = [
|
||||
{ icon: v.micMuted ? 'mic-off' : 'mic', label: v.micMuted ? 'Unmute' : 'Mute', action: call.toggleMic, on: v.micMuted },
|
||||
{ icon: v.cameraOn ? 'video' : 'video-off', label: v.cameraOn ? 'Turn camera off' : 'Turn camera on', action: call.toggleCamera, on: !v.cameraOn && call.isVideo },
|
||||
{ icon: call.micOn ? 'mic' : 'mic-off', label: call.micOn ? 'Mute' : 'Unmute', action: () => call.toggle('mic'), on: !call.micOn },
|
||||
{
|
||||
icon: call.cameraOn ? 'video' : 'video-off',
|
||||
label: call.cameraOn ? 'Turn camera off' : 'Turn camera on',
|
||||
action: () => call.toggle('camera'),
|
||||
on: !call.cameraOn,
|
||||
},
|
||||
]
|
||||
if (canShare && call.phase === 'connected') {
|
||||
list.push({ icon: 'screen-share', label: v.sharing ? 'Stop sharing' : 'Share screen', action: call.toggleScreenShare, on: v.sharing })
|
||||
list.push({ icon: 'screen-share', label: call.sharing ? 'Stop sharing' : 'Share screen', action: () => call.toggle('screen'), on: call.sharing })
|
||||
}
|
||||
list.push({ icon: 'phone-off', label: 'Hang up', action: call.hangup, tone: 'danger' })
|
||||
list.push({ icon: 'phone-off', label: calling.value ? 'Cancel call' : 'Leave call', action: () => void call.leave(), tone: 'danger' })
|
||||
return list
|
||||
})
|
||||
|
||||
const ringControls = computed<Control[]>(() => {
|
||||
const c = call.incoming
|
||||
if (!c) return []
|
||||
const list: Control[] = [{ icon: 'phone-off', label: 'Decline', action: () => void call.decline(), tone: 'danger' }]
|
||||
if (c.video) list.push({ icon: 'phone', label: 'Answer without video', action: () => void call.answer(false) })
|
||||
list.push({ icon: c.video ? 'video' : 'phone', label: 'Answer', action: () => void call.answer(), tone: 'accept' })
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -139,37 +110,70 @@ function controlClass(c: Control): string {
|
||||
if (c.on) return 'bg-[#eceeee] text-[#111418] hover:bg-[#ffffff]'
|
||||
return 'bg-[#ffffff1f] text-[#eceeee] hover:bg-[#ffffff33]'
|
||||
}
|
||||
|
||||
function openRoom() {
|
||||
const roomId = call.roomId
|
||||
if (!roomId) return
|
||||
expanded.value = false
|
||||
router.push({ name: 'room', params: { roomId } })
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded.value) expanded.value = false
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="call.call && view"
|
||||
v-if="call.incoming && !call.joined"
|
||||
class="fixed right-4 bottom-4 left-4 z-40 flex flex-col overflow-hidden rounded-lg bg-[#111418] text-[#eceeee] shadow-float sm:left-auto sm:w-[360px]"
|
||||
:aria-label="`Call from ${call.incoming.callerName}`"
|
||||
role="alertdialog"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 pt-4">
|
||||
<MxcAvatar :name="call.incoming.callerName" :mxc="call.incoming.callerAvatarMxc" :size="48" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="body-strong block truncate">{{ call.incoming.callerName }}</span>
|
||||
<span class="small block truncate text-[#eceeeeb3]">{{ call.incoming.video ? 'Incoming video call' : 'Incoming voice call' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3 px-4 pt-3 pb-4">
|
||||
<button
|
||||
v-for="c in ringControls"
|
||||
:key="c.label"
|
||||
type="button"
|
||||
:class="['flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border-0 transition-colors', controlClass(c)]"
|
||||
:aria-label="c.label"
|
||||
:title="c.label"
|
||||
@click="c.action()"
|
||||
>
|
||||
<WIcon :name="c.icon" :size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="call.roomId"
|
||||
:class="[
|
||||
'fixed z-40 flex flex-col overflow-hidden bg-[#111418] text-[#eceeee] shadow-float',
|
||||
expanded ? 'inset-0' : 'right-4 bottom-4 left-4 rounded-lg sm:left-auto sm:w-[360px]',
|
||||
]"
|
||||
:aria-label="`Call with ${call.peerName}`"
|
||||
:role="call.phase === 'ringing' ? 'alertdialog' : 'region'"
|
||||
:aria-label="`Call: ${call.title}`"
|
||||
role="region"
|
||||
>
|
||||
<audio ref="audioEl" autoplay class="hidden" />
|
||||
|
||||
<div v-if="stage" :class="['relative bg-[#000000]', expanded ? 'min-h-0 flex-1' : 'aspect-video']">
|
||||
<video v-show="view.main" ref="mainEl" autoplay playsinline muted class="h-full w-full object-contain" />
|
||||
<div v-if="!view.main" class="absolute inset-0 flex items-center justify-center">
|
||||
<MxcAvatar :name="call.peerName" :mxc="call.peerAvatarMxc" :size="expanded ? 120 : 72" />
|
||||
</div>
|
||||
<video
|
||||
v-show="view.pip"
|
||||
ref="pipEl"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
:class="['absolute right-3 bottom-3 w-1/4 min-w-24 rounded-md bg-[#000000] object-cover shadow-float', view.pipMirrored && '-scale-x-100']"
|
||||
/>
|
||||
<div class="flex items-center gap-3 px-4 pt-3">
|
||||
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left text-[#eceeee]" @click="openRoom">
|
||||
<span class="body-strong flex items-center gap-2 truncate">
|
||||
{{ call.title }}
|
||||
<WIcon v-if="call.encrypted" name="lock" :size="14" label="End-to-end encrypted" class="shrink-0 text-[#3fb68b]" />
|
||||
</span>
|
||||
<span class="small block truncate text-[#eceeeeb3] tabular-nums" aria-live="polite">{{ status }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="call.isVideo || view.main"
|
||||
type="button"
|
||||
class="absolute top-2 right-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 bg-[#00000080] text-[#eceeee] hover:bg-[#000000b3]"
|
||||
class="flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 bg-[#ffffff1f] text-[#eceeee] hover:bg-[#ffffff33]"
|
||||
:aria-label="expanded ? 'Shrink' : 'Expand'"
|
||||
:title="expanded ? 'Shrink' : 'Expand'"
|
||||
@click="expanded = !expanded"
|
||||
@@ -178,12 +182,34 @@ function controlClass(c: Control): string {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-4 pt-4">
|
||||
<MxcAvatar v-if="!stage" :name="call.peerName" :mxc="call.peerAvatarMxc" :size="48" />
|
||||
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left text-[#eceeee]" @click="openRoom">
|
||||
<span class="body-strong block truncate">{{ call.peerName }}</span>
|
||||
<span class="small block truncate text-[#eceeeeb3] tabular-nums" aria-live="polite">{{ status }}</span>
|
||||
</button>
|
||||
<div :class="['relative px-4 pt-3', expanded ? 'min-h-0 flex-1' : '']">
|
||||
<!-- Joining, or ringing someone who hasn't come yet. -->
|
||||
<div
|
||||
v-if="call.phase === 'joining' || calling"
|
||||
:class="['flex flex-col items-center justify-center gap-4', expanded ? 'h-full' : 'aspect-video']"
|
||||
>
|
||||
<MxcAvatar v-if="call.callee" :name="call.callee.name" :mxc="call.callee.avatarMxc" :size="expanded ? 120 : 72" />
|
||||
<WSpinner v-else :size="32" />
|
||||
</div>
|
||||
|
||||
<!-- Two of you: them on the stage, you in the corner. -->
|
||||
<div v-else-if="pair && remotes[0]" :class="['relative', expanded ? 'h-full' : 'aspect-video']">
|
||||
<CallTile :tile="remotes[0]" :compact="!expanded" class="h-full w-full" />
|
||||
<CallTile
|
||||
v-if="self"
|
||||
:tile="self"
|
||||
compact
|
||||
class="absolute right-3 bottom-3 aspect-video w-1/4 min-w-28 shadow-float"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- More: a grid, or whoever is speaking when shrunk. -->
|
||||
<template v-else>
|
||||
<div :class="['grid gap-2', expanded ? `h-full auto-rows-fr ${columns}` : 'aspect-video grid-cols-1']">
|
||||
<CallTile v-for="t in shown" :key="t.id" :tile="t" :compact="!expanded" />
|
||||
</div>
|
||||
<CallTile v-for="t in hidden" :key="t.id" :tile="t" class="hidden" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 px-4 pt-3 pb-4">
|
||||
@@ -193,7 +219,7 @@ function controlClass(c: Control): string {
|
||||
type="button"
|
||||
:class="['flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border-0 transition-colors', controlClass(c)]"
|
||||
:aria-label="c.label"
|
||||
:aria-pressed="c.on === undefined || c.tone ? undefined : c.on"
|
||||
:aria-pressed="c.tone ? undefined : c.on"
|
||||
:title="c.label"
|
||||
@click="c.action()"
|
||||
>
|
||||
|
||||
@@ -83,6 +83,13 @@ watchEffect(() => {
|
||||
document.title = `${n ? `(${n}) ` : ''}${name ? `${name} · ` : ''}Warp`
|
||||
})
|
||||
|
||||
// A room with a call going on says so in the list, instead of its last message.
|
||||
function callPreview(roomId: string): string {
|
||||
if (call.roomId === roomId) return 'You’re in the call'
|
||||
const n = call.inCall(roomId)
|
||||
return n === 1 ? 'Call in progress: 1 person' : `Call in progress: ${n} people`
|
||||
}
|
||||
|
||||
const ownName = computed(() => profile.displayName || localpart(session.userId))
|
||||
</script>
|
||||
|
||||
@@ -166,7 +173,7 @@ const ownName = computed(() => profile.displayName || localpart(session.userId))
|
||||
:key="r.roomId"
|
||||
:name="r.name"
|
||||
:avatar-mxc="r.avatarMxc"
|
||||
:preview="r.preview"
|
||||
:preview="call.inCall(r.roomId) ? callPreview(r.roomId) : r.preview"
|
||||
:time="formatListTime(r.ts)"
|
||||
:unread="r.unread"
|
||||
:mention="r.highlight > 0"
|
||||
|
||||
@@ -68,6 +68,9 @@ export async function startClient(creds: Credentials): Promise<MatrixClient> {
|
||||
accessToken: creds.accessToken,
|
||||
timelineSupport: true,
|
||||
useAuthorizationHeader: true,
|
||||
// Calls are MatrixRTC with LiveKit (stores/call.ts); the SDK's own
|
||||
// 1:1 VoIP, and its polling for TURN servers, stay off.
|
||||
disableVoip: true,
|
||||
cryptoCallbacks,
|
||||
// Emoji (SAS), and QR codes both ways: shown here, or scanned with
|
||||
// the camera.
|
||||
|
||||
@@ -114,6 +114,7 @@ function describeCall(ev: MatrixEvent, room: Room): string | null {
|
||||
case EventType.CallInvite:
|
||||
return `${sender} started a ${isVideoCallInvite(ev) ? 'video' : 'voice'} call`
|
||||
case EventType.CallReject:
|
||||
case EventType.RTCDecline:
|
||||
return `${sender} declined the call`
|
||||
case EventType.CallHangup:
|
||||
switch (ev.getContent().reason) {
|
||||
@@ -131,6 +132,47 @@ function describeCall(ev: MatrixEvent, room: Room): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a call.member state event (MatrixRTC) says its device is in the
|
||||
* call. Leaving is an empty event; the legacy form lists memberships.
|
||||
*/
|
||||
export function isActiveCallMember(content: Record<string, unknown>): boolean {
|
||||
if (Object.keys(content).length === 0) return false
|
||||
if (Array.isArray(content.memberships)) return content.memberships.length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows who is in a room's group call along the timeline, to say when
|
||||
* it started and ended. Each device in the call has a call.member state
|
||||
* event of its own; a member refreshing theirs says nothing.
|
||||
*/
|
||||
class CallTracker {
|
||||
private readonly inCall = new Set<string>()
|
||||
|
||||
constructor(room: Room) {
|
||||
const start = room.getLiveTimeline().getState(Direction.Backward)
|
||||
for (const ev of start?.getStateEvents(EventType.GroupCallMemberPrefix) ?? []) {
|
||||
if (isActiveCallMember(ev.getContent())) this.inCall.add(ev.getStateKey() ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
describe(ev: MatrixEvent, room: Room): string | null {
|
||||
const key = ev.getStateKey() ?? ''
|
||||
const was = this.inCall.has(key)
|
||||
const now = isActiveCallMember(ev.getContent())
|
||||
if (was === now) return null
|
||||
const sender = nameOf(room, ev.getSender() ?? '')
|
||||
if (now) {
|
||||
const first = this.inCall.size === 0
|
||||
this.inCall.add(key)
|
||||
return first ? `${sender} started a call` : `${sender} joined the call`
|
||||
}
|
||||
this.inCall.delete(key)
|
||||
return this.inCall.size === 0 ? 'The call ended' : `${sender} left the call`
|
||||
}
|
||||
}
|
||||
|
||||
/** A one-line description of a state change, or null to leave it out. */
|
||||
function describeState(ev: MatrixEvent, room: Room): string | null {
|
||||
const sender = nameOf(room, ev.getSender() ?? '')
|
||||
@@ -285,6 +327,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
|
||||
const items: TimelineItem[] = []
|
||||
let lastTs = 0
|
||||
let prev: MessageItem | null = null
|
||||
const calls = new CallTracker(room)
|
||||
|
||||
for (const ev of events) {
|
||||
const type = ev.getType()
|
||||
@@ -310,7 +353,7 @@ export function buildTimeline(c: MatrixClient, room: Room): TimelineItem[] {
|
||||
}
|
||||
continue
|
||||
}
|
||||
const text = describeState(ev, room)
|
||||
const text = type === EventType.GroupCallMemberPrefix ? calls.describe(ev, room) : describeState(ev, room)
|
||||
if (text) {
|
||||
items.push({ kind: 'notice', key, ts, text })
|
||||
prev = null
|
||||
|
||||
+616
-264
@@ -1,7 +1,26 @@
|
||||
import { CallEvent, CallFeedEvent, createNewMatrixCall, type MatrixCall, type MatrixClient } from 'matrix-js-sdk'
|
||||
import { CallDirection, CallErrorCode, CallParty, CallState, CallType, type CallError } from 'matrix-js-sdk/lib/webrtc/call'
|
||||
import { CallEventHandlerEvent } from 'matrix-js-sdk/lib/webrtc/callEventHandler'
|
||||
import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed'
|
||||
import {
|
||||
ClientEvent,
|
||||
EventType,
|
||||
MatrixEventEvent,
|
||||
RelationType,
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
SyncState,
|
||||
type IRoomTimelineData,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type Room as MatrixRoom,
|
||||
} from 'matrix-js-sdk'
|
||||
import { MatrixRTCSessionEvent, MatrixRTCSessionManagerEvents, type MatrixRTCSession } from 'matrix-js-sdk/lib/matrixrtc'
|
||||
import {
|
||||
BaseKeyProvider,
|
||||
createKeyMaterialFromBuffer,
|
||||
DisconnectReason,
|
||||
isE2EESupported,
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
type Participant,
|
||||
} from 'livekit-client'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { errorMessage } from '@/lib/format'
|
||||
@@ -9,332 +28,665 @@ import { client } from '@/matrix/client'
|
||||
import { startTone, stopTone } from '@/matrix/tones'
|
||||
import { useToasts } from '@/stores/toasts'
|
||||
|
||||
// Voice and video calls between two people: Matrix's 1:1 VoIP (m.call.*
|
||||
// events in the room, WebRTC between the browsers), which the SDK runs.
|
||||
// In an encrypted room the call events are encrypted like any other; the
|
||||
// media itself is always encrypted by WebRTC (DTLS-SRTP).
|
||||
// Voice and video calls, all of them: MatrixRTC (MSC4143) with a LiveKit
|
||||
// SFU, the way Element Call does them. A call between two people is the
|
||||
// same thing as a call between ten, with a ring on top.
|
||||
//
|
||||
// One call at a time. A call that comes in while one is going is answered
|
||||
// "busy", as a phone would.
|
||||
// Who is in a room's call is Matrix state (one
|
||||
// org.matrix.msc3401.call.member event per device), which the SDK's
|
||||
// MatrixRTC session keeps; the media goes through LiveKit, whose token the
|
||||
// LiveKit JWT service hands out for an OpenID token from the homeserver.
|
||||
// The session also schedules a delayed "left" with the homeserver (MSC4140)
|
||||
// and keeps pushing it back, so a member whose browser crashes or loses
|
||||
// its connection drops out of the call on its own.
|
||||
//
|
||||
// Starting a call in a room with one other person rings them: the first
|
||||
// member sends an org.matrix.msc4075.rtc.notification, and the other side
|
||||
// answers by joining or declines with org.matrix.msc4310.rtc.decline.
|
||||
//
|
||||
// In an encrypted room the media is end-to-end encrypted too: each member
|
||||
// makes its own key, shares it with the others over encrypted to-device
|
||||
// messages, and LiveKit encrypts every frame with it (encryptMedia).
|
||||
//
|
||||
// One call at a time.
|
||||
|
||||
export type CallPhase = 'ringing' | 'calling' | 'connecting' | 'connected' | 'ended'
|
||||
/** A person (well, a device) in the call. */
|
||||
export interface Tile {
|
||||
/** The LiveKit identity: "@user:server:DEVICE". */
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
avatarMxc?: string
|
||||
local: boolean
|
||||
camera?: Track
|
||||
screen?: Track
|
||||
/** A remote microphone, to play. Your own is never played back. */
|
||||
audio?: Track
|
||||
micMuted: boolean
|
||||
speaking: boolean
|
||||
}
|
||||
|
||||
/** How long the ended call stays on screen, saying why. */
|
||||
const endedLingerMs = 2500
|
||||
export type CallPhase = 'joining' | 'connected' | 'reconnecting'
|
||||
|
||||
/** A call ringing for you. */
|
||||
export interface IncomingCall {
|
||||
roomId: string
|
||||
/** The notification event, which a decline refers to. */
|
||||
eventId: string
|
||||
callerId: string
|
||||
callerName: string
|
||||
callerAvatarMxc?: string
|
||||
roomName: string
|
||||
video: boolean
|
||||
/** When the ring stops on its own (ms since the epoch). */
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
/** The MatrixRTC transport this client publishes: LiveKit via the JWT service. */
|
||||
interface LivekitTransport {
|
||||
type: 'livekit'
|
||||
livekit_service_url: string
|
||||
livekit_alias: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** How long a call rings, unless the notification says less. */
|
||||
const ringLifetimeMs = 60_000
|
||||
/** The most a received notification may ring for, whatever it says. */
|
||||
const maxRingMs = 120_000
|
||||
|
||||
/**
|
||||
* Feeds the per-participant keys MatrixRTC hands out to LiveKit's frame
|
||||
* encryption. Each key belongs to one participant and index; there is no
|
||||
* shared key and no ratcheting, the same settings as Element Call, so the
|
||||
* two can be in one call.
|
||||
*/
|
||||
class MatrixKeyProvider extends BaseKeyProvider {
|
||||
constructor() {
|
||||
super({ ratchetWindowSize: 0, keyringSize: 256 })
|
||||
}
|
||||
|
||||
async setParticipantKey(key: Uint8Array, index: number, identity: string) {
|
||||
const material = await createKeyMaterialFromBuffer(key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer)
|
||||
this.onSetEncryptionKey(material, identity, index)
|
||||
}
|
||||
}
|
||||
|
||||
/** The user of a LiveKit identity lk-jwt-service made: "@alice:example.org:DEVICE". */
|
||||
function identityUser(identity: string): string {
|
||||
// The device is after the last colon; a server name may itself have a port.
|
||||
const i = identity.lastIndexOf(':')
|
||||
return i > 0 ? identity.slice(0, i) : identity
|
||||
}
|
||||
|
||||
/** How many people (not devices) are in a session. */
|
||||
function countMembers(session: MatrixRTCSession): number {
|
||||
return new Set(session.memberships.map((m) => m.userId)).size
|
||||
}
|
||||
|
||||
/** The other person in a room of two, or undefined. */
|
||||
function otherOfTwo(room: MatrixRoom, me: string): string | undefined {
|
||||
if (room.getJoinedMemberCount() !== 2) return undefined
|
||||
return room.getJoinedMembers().find((m) => m.userId !== me)?.userId
|
||||
}
|
||||
|
||||
export const useCall = defineStore('call', () => {
|
||||
const call = shallowRef<MatrixCall | null>(null)
|
||||
/** The room whose call this client is in. */
|
||||
const roomId = ref<string | null>(null)
|
||||
const phase = ref<CallPhase | null>(null)
|
||||
/** Bumps on every change the call view shows. */
|
||||
const version = ref(0)
|
||||
/** Seconds since the call connected. */
|
||||
/** How many people are in each room's call, for every room with one. */
|
||||
const active = ref<Record<string, number>>({})
|
||||
const lk = shallowRef<Room | null>(null)
|
||||
/** Media is end-to-end encrypted. */
|
||||
const encrypted = ref(false)
|
||||
/** Joined with the camera on. */
|
||||
const video = ref(false)
|
||||
/** Seconds since you joined. */
|
||||
const length = ref(0)
|
||||
/** Why the call ended, once it has. */
|
||||
const endReason = ref('')
|
||||
/** We rang someone and they haven't come yet. */
|
||||
const ringingOut = ref(false)
|
||||
/** A call ringing for you. */
|
||||
const incoming = ref<IncomingCall | null>(null)
|
||||
|
||||
let attached: MatrixClient | null = null
|
||||
let clearTimer = 0
|
||||
/** The call got as far as connecting media, at some point. */
|
||||
let connected = false
|
||||
/** The feeds whose mute state is listened to. */
|
||||
let feeds: CallFeed[] = []
|
||||
|
||||
const phase = computed<CallPhase | null>(() => {
|
||||
void version.value
|
||||
const c = call.value
|
||||
if (!c) return null
|
||||
switch (c.state) {
|
||||
case CallState.Ringing:
|
||||
return 'ringing'
|
||||
case CallState.Fledgling:
|
||||
case CallState.WaitLocalMedia:
|
||||
case CallState.CreateOffer:
|
||||
case CallState.InviteSent:
|
||||
return c.direction === CallDirection.Inbound ? 'connecting' : 'calling'
|
||||
case CallState.CreateAnswer:
|
||||
case CallState.Connecting:
|
||||
return 'connecting'
|
||||
case CallState.Connected:
|
||||
return 'connected'
|
||||
default:
|
||||
return 'ended'
|
||||
}
|
||||
})
|
||||
|
||||
const isVideo = computed(() => {
|
||||
void version.value
|
||||
return call.value?.type === CallType.Video
|
||||
})
|
||||
|
||||
const peerName = computed(() => {
|
||||
void version.value
|
||||
const c = call.value
|
||||
if (!c) return ''
|
||||
const member = c.getOpponentMember()
|
||||
if (member) return member.name
|
||||
const room = attached?.getRoom(c.roomId)
|
||||
return room?.name ?? c.invitee ?? ''
|
||||
})
|
||||
|
||||
const peerAvatarMxc = computed(() => {
|
||||
void version.value
|
||||
return call.value?.getOpponentMember()?.getMxcAvatarUrl() ?? undefined
|
||||
})
|
||||
let session: MatrixRTCSession | null = null
|
||||
let keyProvider: MatrixKeyProvider | null = null
|
||||
let lengthTimer = 0
|
||||
let ringOutTimer = 0
|
||||
let ringInTimer = 0
|
||||
/** The ring notification we sent, which a decline refers to. */
|
||||
let sentNotification: string | null = null
|
||||
const watched = new Map<string, MatrixRTCSession>()
|
||||
|
||||
function bump() {
|
||||
version.value++
|
||||
}
|
||||
|
||||
// A feed muting its camera or microphone is not a change in the call's
|
||||
// feeds, so each feed is listened to as well.
|
||||
function watchFeeds(c: MatrixCall | null) {
|
||||
for (const f of feeds) {
|
||||
f.off(CallFeedEvent.MuteStateChanged, bump)
|
||||
f.off(CallFeedEvent.NewStream, bump)
|
||||
}
|
||||
feeds = c ? c.getFeeds() : []
|
||||
for (const f of feeds) {
|
||||
f.on(CallFeedEvent.MuteStateChanged, bump)
|
||||
f.on(CallFeedEvent.NewStream, bump)
|
||||
}
|
||||
const joined = computed(() => roomId.value !== null)
|
||||
|
||||
/** Whether this browser can take part in calls at all. */
|
||||
function supported(): boolean {
|
||||
return typeof window !== 'undefined' && 'RTCPeerConnection' in window
|
||||
}
|
||||
|
||||
function onFeeds() {
|
||||
watchFeeds(call.value)
|
||||
bump()
|
||||
}
|
||||
// --- Tones --------------------------------------------------------------
|
||||
|
||||
function tones() {
|
||||
const p = phase.value
|
||||
if (p === 'ringing') startTone('ring')
|
||||
else if (p === 'calling') startTone('ringback')
|
||||
if (incoming.value && !joined.value) startTone('ring')
|
||||
else if (ringingOut.value) startTone('ringback')
|
||||
else stopTone()
|
||||
}
|
||||
|
||||
function onState(state: CallState) {
|
||||
if (state === CallState.Connected) connected = true
|
||||
// --- Who is in which room's call ----------------------------------------
|
||||
|
||||
function refresh(rid: string) {
|
||||
const s = watched.get(rid)
|
||||
const n = s ? countMembers(s) : 0
|
||||
const next = { ...active.value }
|
||||
if (n > 0) next[rid] = n
|
||||
else delete next[rid]
|
||||
active.value = next
|
||||
// The caller gave up, or someone else answered for you.
|
||||
if (incoming.value?.roomId === rid && n === 0) clearIncoming()
|
||||
// Someone came: stop ringing them.
|
||||
if (rid === roomId.value && ringingOut.value && n > 1) stopRingingOut()
|
||||
}
|
||||
|
||||
function watchSession(rid: string, s: MatrixRTCSession) {
|
||||
if (watched.get(rid) === s) return
|
||||
watched.get(rid)?.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships)
|
||||
watched.set(rid, s)
|
||||
s.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships)
|
||||
refresh(rid)
|
||||
// Memberships are parsed asynchronously: a call already going on when
|
||||
// the room loads only shows up once that is done.
|
||||
void s.initialMembershipCalculated.then(() => {
|
||||
refresh(rid)
|
||||
bump()
|
||||
})
|
||||
}
|
||||
|
||||
/** Follows the call of every room the client knows. */
|
||||
function watchRoom(room: MatrixRoom) {
|
||||
if (!attached) return
|
||||
watchSession(room.roomId, attached.matrixRTC.getRoomSession(room))
|
||||
}
|
||||
|
||||
function watchAllRooms() {
|
||||
for (const r of attached?.getRooms() ?? []) watchRoom(r)
|
||||
}
|
||||
|
||||
function onSync(state: SyncState) {
|
||||
if (state === SyncState.Prepared) watchAllRooms()
|
||||
}
|
||||
|
||||
function onMemberships() {
|
||||
for (const rid of watched.keys()) refresh(rid)
|
||||
bump()
|
||||
tones()
|
||||
}
|
||||
|
||||
function onLength(seconds: number) {
|
||||
length.value = seconds
|
||||
function onSessionStarted(rid: string, s: MatrixRTCSession) {
|
||||
watchSession(rid, s)
|
||||
}
|
||||
|
||||
function onError(err: CallError) {
|
||||
// The hangup that follows says the rest.
|
||||
if (err.code === CallErrorCode.NoUserMedia) {
|
||||
forgetFailedMedia()
|
||||
useToasts().error(isVideo.value ? 'Can’t use your camera or microphone. Check that the browser may use them.' : 'Can’t use your microphone. Check that the browser may use it.')
|
||||
} else {
|
||||
useToasts().error(`Call failed: ${errorMessage(err)}`)
|
||||
function onSessionEnded(rid: string) {
|
||||
refresh(rid)
|
||||
}
|
||||
|
||||
// --- Ringing ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A ring notification or a decline arriving live. A ring counts when it
|
||||
* is someone else's, still within its lifetime, mentions you (or the
|
||||
* room), and you aren't in that call already.
|
||||
*/
|
||||
function onTimeline(ev: MatrixEvent, room: MatrixRoom | undefined, toStart: boolean | undefined, _removed: boolean, data: IRoomTimelineData) {
|
||||
if (toStart || !data?.liveEvent || !room) return
|
||||
// In an encrypted room this is still ciphertext; onDecrypted has it.
|
||||
if (ev.isEncrypted() && ev.isBeingDecrypted()) return
|
||||
onCallEvent(ev, room)
|
||||
}
|
||||
|
||||
/** An encrypted ring or decline, once it can be read: only recent ones count. */
|
||||
function onDecrypted(ev: MatrixEvent) {
|
||||
if (ev.isDecryptionFailure() || Date.now() - ev.getTs() > maxRingMs) return
|
||||
const room = attached?.getRoom(ev.getRoomId() ?? '')
|
||||
if (room) onCallEvent(ev, room)
|
||||
}
|
||||
|
||||
/** Event IDs already acted on, so a ring isn't taken twice. */
|
||||
const seen = new Set<string>()
|
||||
|
||||
function onCallEvent(ev: MatrixEvent, room: MatrixRoom) {
|
||||
if (!attached) return
|
||||
const type = ev.getType()
|
||||
if (type !== EventType.RTCNotification && type !== EventType.RTCDecline) return
|
||||
const id = ev.getId()
|
||||
if (id) {
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function onHangup(c: MatrixCall) {
|
||||
if (c !== call.value) return
|
||||
endReason.value = describeHangup(c, connected)
|
||||
bump()
|
||||
stopTone()
|
||||
window.clearTimeout(clearTimer)
|
||||
clearTimer = window.setTimeout(() => {
|
||||
if (call.value === c) release()
|
||||
}, endedLingerMs)
|
||||
}
|
||||
|
||||
function onReplaced(newCall: MatrixCall) {
|
||||
// Both of you called at once: the SDK keeps one of the two calls.
|
||||
show(newCall)
|
||||
}
|
||||
|
||||
function listen(c: MatrixCall, on: boolean) {
|
||||
const m = on ? 'on' : 'off'
|
||||
c[m](CallEvent.State, onState)
|
||||
c[m](CallEvent.FeedsChanged, onFeeds)
|
||||
c[m](CallEvent.LocalHoldUnhold, bump)
|
||||
c[m](CallEvent.RemoteHoldUnhold, bump)
|
||||
c[m](CallEvent.LengthChanged, onLength)
|
||||
c[m](CallEvent.Error, onError)
|
||||
c[m](CallEvent.Hangup, onHangup)
|
||||
c[m](CallEvent.Replaced, onReplaced)
|
||||
}
|
||||
|
||||
function show(c: MatrixCall) {
|
||||
if (call.value) listen(call.value, false)
|
||||
window.clearTimeout(clearTimer)
|
||||
call.value = c
|
||||
connected = false
|
||||
length.value = 0
|
||||
endReason.value = ''
|
||||
listen(c, true)
|
||||
watchFeeds(c)
|
||||
bump()
|
||||
tones()
|
||||
}
|
||||
|
||||
function release() {
|
||||
if (call.value) listen(call.value, false)
|
||||
watchFeeds(null)
|
||||
window.clearTimeout(clearTimer)
|
||||
call.value = null
|
||||
stopTone()
|
||||
bump()
|
||||
}
|
||||
|
||||
function onIncoming(incoming: MatrixCall) {
|
||||
if (call.value && phase.value !== 'ended') {
|
||||
incoming.hangup(CallErrorCode.UserBusy, false)
|
||||
const me = attached.getUserId() ?? ''
|
||||
if (type === EventType.RTCNotification) {
|
||||
if (ev.getSender() === me || roomId.value === room.roomId) return
|
||||
const content = ev.getContent()
|
||||
const mentions = content['m.mentions'] as { user_ids?: string[]; room?: boolean } | undefined
|
||||
if (!mentions?.room && !mentions?.user_ids?.includes(me)) return
|
||||
// A ring for a call of two; in a bigger room the banner says enough.
|
||||
if (content.notification_type !== 'ring' && !otherOfTwo(room, me)) return
|
||||
const sentAt = typeof content.sender_ts === 'number' ? Math.min(content.sender_ts, ev.getTs()) : ev.getTs()
|
||||
const lifetime = Math.min(typeof content.lifetime === 'number' ? content.lifetime : ringLifetimeMs, maxRingMs)
|
||||
const expiresAt = sentAt + lifetime
|
||||
if (expiresAt <= Date.now()) return
|
||||
if (joined.value) return // Busy: you see the banner when you're free.
|
||||
const caller = room.getMember(ev.getSender() ?? '')
|
||||
incoming.value = {
|
||||
roomId: room.roomId,
|
||||
eventId: ev.getId() ?? '',
|
||||
callerId: ev.getSender() ?? '',
|
||||
callerName: caller?.name ?? ev.getSender() ?? '',
|
||||
callerAvatarMxc: caller?.getMxcAvatarUrl() ?? undefined,
|
||||
roomName: room.name,
|
||||
video: content['m.call.intent'] !== 'audio',
|
||||
expiresAt,
|
||||
}
|
||||
window.clearTimeout(ringInTimer)
|
||||
ringInTimer = window.setTimeout(clearIncoming, expiresAt - Date.now())
|
||||
tones()
|
||||
return
|
||||
}
|
||||
show(incoming)
|
||||
// Hung up (or answered elsewhere) before we did anything.
|
||||
if (incoming.callHasEnded()) onHangup(incoming)
|
||||
if (type === EventType.RTCDecline) {
|
||||
const target = ev.getContent()['m.relates_to']?.event_id
|
||||
if (!target) return
|
||||
// You declined on another of your devices: stop ringing here.
|
||||
if (ev.getSender() === me && incoming.value?.eventId === target) {
|
||||
clearIncoming()
|
||||
return
|
||||
}
|
||||
// The one person you rang said no.
|
||||
if (target === sentNotification && ringingOut.value && room.roomId === roomId.value) {
|
||||
useToasts().info(`${room.getMember(ev.getSender() ?? '')?.name ?? 'They'} declined the call.`)
|
||||
void leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearIncoming() {
|
||||
window.clearTimeout(ringInTimer)
|
||||
incoming.value = null
|
||||
tones()
|
||||
}
|
||||
|
||||
function stopRingingOut() {
|
||||
window.clearTimeout(ringOutTimer)
|
||||
ringingOut.value = false
|
||||
tones()
|
||||
}
|
||||
|
||||
function onNotificationSent(sent: { event_id: string; lifetime?: number }) {
|
||||
sentNotification = sent.event_id
|
||||
}
|
||||
|
||||
/** Answers the call ringing for you. */
|
||||
async function answer(withVideo = true) {
|
||||
const call = incoming.value
|
||||
if (!call) return
|
||||
clearIncoming()
|
||||
await join(call.roomId, withVideo && call.video)
|
||||
}
|
||||
|
||||
/** Declines the call ringing for you. The caller stops ringing when it's the two of you. */
|
||||
async function decline() {
|
||||
const call = incoming.value
|
||||
if (!call) return
|
||||
clearIncoming()
|
||||
try {
|
||||
await client().sendEvent(call.roomId, EventType.RTCDecline, {
|
||||
'm.relates_to': { rel_type: RelationType.Reference, event_id: call.eventId },
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('call: decline: ', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts listening for calls on the running client. */
|
||||
function attach() {
|
||||
const c = client()
|
||||
if (attached === c) return
|
||||
if (attached) attached.off(CallEventHandlerEvent.Incoming, onIncoming)
|
||||
release()
|
||||
if (attached) {
|
||||
attached.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onSessionStarted)
|
||||
attached.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded)
|
||||
attached.off(ClientEvent.Room, watchRoom)
|
||||
attached.off(ClientEvent.Sync, onSync)
|
||||
attached.off(MatrixRoomEvent.Timeline, onTimeline)
|
||||
attached.off(MatrixEventEvent.Decrypted, onDecrypted)
|
||||
}
|
||||
for (const s of watched.values()) s.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships)
|
||||
watched.clear()
|
||||
active.value = {}
|
||||
clearIncoming()
|
||||
void leave()
|
||||
attached = c
|
||||
c.on(CallEventHandlerEvent.Incoming, onIncoming)
|
||||
c.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onSessionStarted)
|
||||
c.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onSessionEnded)
|
||||
c.on(ClientEvent.Room, watchRoom)
|
||||
c.on(ClientEvent.Sync, onSync)
|
||||
c.on(MatrixRoomEvent.Timeline, onTimeline)
|
||||
c.on(MatrixEventEvent.Decrypted, onDecrypted)
|
||||
watchAllRooms()
|
||||
}
|
||||
|
||||
/** Whether this browser can make calls at all. */
|
||||
function supported(): boolean {
|
||||
return Boolean(attached?.supportsVoip())
|
||||
}
|
||||
// --- Joining ------------------------------------------------------------
|
||||
|
||||
async function place(roomId: string, invitee: string | undefined, video: boolean) {
|
||||
if (call.value && phase.value !== 'ended') {
|
||||
useToasts().error('You’re already in a call.')
|
||||
return
|
||||
/** The LiveKit JWT service to use: the one the call already uses, else this server's. */
|
||||
async function chooseTransport(s: MatrixRTCSession, rid: string): Promise<LivekitTransport> {
|
||||
const oldest = s.memberships[0]
|
||||
const theirs = oldest?.getTransport(oldest)
|
||||
if (theirs && theirs.type === 'livekit' && typeof theirs.livekit_service_url === 'string') {
|
||||
return { type: 'livekit', livekit_service_url: theirs.livekit_service_url, livekit_alias: rid }
|
||||
}
|
||||
const c = createNewMatrixCall(client(), roomId, { invitee })
|
||||
if (!c) {
|
||||
useToasts().error('This browser can’t make calls.')
|
||||
return
|
||||
}
|
||||
show(c)
|
||||
const c = client()
|
||||
// The homeserver lists its backends (MSC4143); a server that predates
|
||||
// the endpoint has them in its discovery document instead.
|
||||
let foci: unknown
|
||||
try {
|
||||
if (video) await c.placeVideoCall()
|
||||
else await c.placeVoiceCall()
|
||||
} catch (e) {
|
||||
// The SDK has hung up already and said why through CallEvent.Error.
|
||||
console.error('call: ', e)
|
||||
foci = await c._unstable_getRTCTransports()
|
||||
} catch {
|
||||
const res = await fetch(`${c.baseUrl.replace(/\/$/, '')}/.well-known/matrix/client`)
|
||||
if (!res.ok) throw new Error('This server doesn’t offer calls.')
|
||||
const doc = (await res.json()) as Record<string, unknown>
|
||||
foci = doc['org.matrix.msc4143.rtc_foci']
|
||||
}
|
||||
const focus = Array.isArray(foci)
|
||||
? (foci as Record<string, unknown>[]).find((f) => f?.type === 'livekit' && typeof f.livekit_service_url === 'string')
|
||||
: undefined
|
||||
if (!focus) throw new Error('This server doesn’t offer calls.')
|
||||
return { type: 'livekit', livekit_service_url: String(focus.livekit_service_url), livekit_alias: rid }
|
||||
}
|
||||
|
||||
/** Answers the ringing call; a video call with the camera off when `video` is false. */
|
||||
async function answer(video = true) {
|
||||
const c = call.value
|
||||
if (!c || phase.value !== 'ringing') return
|
||||
stopTone()
|
||||
/** A LiveKit URL and token, from the JWT service, for an OpenID token. */
|
||||
async function livekitToken(transport: LivekitTransport, rid: string): Promise<{ url: string; jwt: string }> {
|
||||
const c = client()
|
||||
const openid = await c.getOpenIdToken()
|
||||
const res = await fetch(`${transport.livekit_service_url.replace(/\/$/, '')}/sfu/get`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room: rid, openid_token: openid, device_id: c.getDeviceId() }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`The call service refused to let you in (${res.status}).`)
|
||||
const body = (await res.json()) as { url?: string; jwt?: string }
|
||||
if (!body.url || !body.jwt) throw new Error('The call service gave an unusable answer.')
|
||||
return { url: body.url, jwt: body.jwt }
|
||||
}
|
||||
|
||||
function listen(r: Room) {
|
||||
r.on(RoomEvent.ParticipantConnected, bump)
|
||||
.on(RoomEvent.ParticipantDisconnected, bump)
|
||||
.on(RoomEvent.TrackSubscribed, bump)
|
||||
.on(RoomEvent.TrackUnsubscribed, bump)
|
||||
.on(RoomEvent.TrackMuted, bump)
|
||||
.on(RoomEvent.TrackUnmuted, bump)
|
||||
.on(RoomEvent.LocalTrackPublished, bump)
|
||||
.on(RoomEvent.LocalTrackUnpublished, bump)
|
||||
.on(RoomEvent.ActiveSpeakersChanged, bump)
|
||||
.on(RoomEvent.ParticipantEncryptionStatusChanged, bump)
|
||||
.on(RoomEvent.Reconnecting, () => {
|
||||
phase.value = 'reconnecting'
|
||||
})
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
phase.value = 'connected'
|
||||
})
|
||||
.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
|
||||
if (lk.value !== r) return
|
||||
if (reason !== DisconnectReason.CLIENT_INITIATED) useToasts().error('You were disconnected from the call.')
|
||||
void leave()
|
||||
})
|
||||
}
|
||||
|
||||
function onEncryptionKey(key: Uint8Array, index: number, _membership: unknown, backendIdentity: string) {
|
||||
void keyProvider?.setParticipantKey(key, index, backendIdentity)
|
||||
}
|
||||
|
||||
function onMembershipError(e: unknown) {
|
||||
useToasts().error(`The call stopped working: ${errorMessage(e)}`)
|
||||
void leave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the call in a room, or starts one. Starting a call in a room of
|
||||
* two rings the other person.
|
||||
*/
|
||||
async function join(rid: string, withVideo: boolean) {
|
||||
if (roomId.value === rid) return
|
||||
if (roomId.value) await leave()
|
||||
const c = client()
|
||||
const room = c.getRoom(rid)
|
||||
if (!room) return
|
||||
if (incoming.value?.roomId === rid) clearIncoming()
|
||||
|
||||
roomId.value = rid
|
||||
phase.value = 'joining'
|
||||
video.value = withVideo
|
||||
length.value = 0
|
||||
sentNotification = null
|
||||
bump()
|
||||
const s = c.matrixRTC.getRoomSession(room)
|
||||
watchSession(rid, s)
|
||||
session = s
|
||||
const e2ee = room.hasEncryptionStateEvent()
|
||||
encrypted.value = e2ee
|
||||
const me = c.getUserId()!
|
||||
const ringing = countMembers(s) === 0 && otherOfTwo(room, me) !== undefined
|
||||
try {
|
||||
await c.answer(true, video && c.type === CallType.Video)
|
||||
if (e2ee && !isE2EESupported()) {
|
||||
throw new Error('This browser can’t encrypt call media, and this room is end-to-end encrypted.')
|
||||
}
|
||||
const transport = await chooseTransport(s, rid)
|
||||
const { url, jwt } = await livekitToken(transport, rid)
|
||||
if (roomId.value !== rid) return
|
||||
|
||||
keyProvider = e2ee ? new MatrixKeyProvider() : null
|
||||
const r = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
e2ee: keyProvider
|
||||
? { keyProvider, worker: new Worker(new URL('livekit-client/e2ee-worker', import.meta.url), { type: 'module' }) }
|
||||
: undefined,
|
||||
})
|
||||
lk.value = r
|
||||
listen(r)
|
||||
if (keyProvider) await r.setE2EEEnabled(true)
|
||||
|
||||
// The Matrix side: announce ourselves (and schedule our own delayed
|
||||
// leave), ring the other person if we're the first, and in an
|
||||
// encrypted room start sharing media keys.
|
||||
const deviceId = c.getDeviceId()!
|
||||
s.on(MatrixRTCSessionEvent.EncryptionKeyChanged, onEncryptionKey)
|
||||
s.on(MatrixRTCSessionEvent.MembershipManagerError, onMembershipError)
|
||||
s.on(MatrixRTCSessionEvent.DidSendCallNotification, onNotificationSent)
|
||||
s.join({ userId: me, deviceId, memberId: `${me}:${deviceId}` }, [transport], {
|
||||
manageMediaKeys: e2ee,
|
||||
callIntent: withVideo ? 'video' : 'audio',
|
||||
notificationType: ringing ? 'ring' : countMembers(s) === 0 ? 'notification' : undefined,
|
||||
notificationLifetimeMs: ringLifetimeMs,
|
||||
})
|
||||
if (e2ee) s.reemitEncryptionKeys()
|
||||
if (ringing) {
|
||||
ringingOut.value = true
|
||||
ringOutTimer = window.setTimeout(() => {
|
||||
if (!ringingOut.value || roomId.value !== rid) return
|
||||
useToasts().info('No answer.')
|
||||
void leave()
|
||||
}, ringLifetimeMs)
|
||||
tones()
|
||||
}
|
||||
|
||||
await r.connect(url, jwt)
|
||||
if (roomId.value !== rid) {
|
||||
await r.disconnect()
|
||||
return
|
||||
}
|
||||
phase.value = 'connected'
|
||||
window.clearInterval(lengthTimer)
|
||||
const since = Date.now()
|
||||
lengthTimer = window.setInterval(() => (length.value = Math.floor((Date.now() - since) / 1000)), 1000)
|
||||
bump()
|
||||
try {
|
||||
await r.localParticipant.setMicrophoneEnabled(true)
|
||||
} catch (e) {
|
||||
useToasts().error(`Can’t use your microphone: ${errorMessage(e)}`)
|
||||
}
|
||||
if (withVideo) {
|
||||
try {
|
||||
await r.localParticipant.setCameraEnabled(true)
|
||||
} catch (e) {
|
||||
useToasts().error(`Can’t use your camera: ${errorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
// Browsers only play audio after a gesture; joining was one.
|
||||
await r.startAudio().catch(() => undefined)
|
||||
bump()
|
||||
} catch (e) {
|
||||
console.error('call: ', e)
|
||||
useToasts().error(errorMessage(e))
|
||||
await leave()
|
||||
}
|
||||
}
|
||||
|
||||
function decline() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
if (phase.value === 'ringing') c.reject()
|
||||
else hangup()
|
||||
}
|
||||
|
||||
function hangup() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
if (phase.value === 'ended') release()
|
||||
else c.hangup(CallErrorCode.UserHangup, false)
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
await c.setMicrophoneMuted(!c.isMicrophoneMuted())
|
||||
/** Leaves the call this client is in. */
|
||||
async function leave() {
|
||||
const s = session
|
||||
const r = lk.value
|
||||
session = null
|
||||
lk.value = null
|
||||
keyProvider = null
|
||||
sentNotification = null
|
||||
window.clearInterval(lengthTimer)
|
||||
stopRingingOut()
|
||||
const rid = roomId.value
|
||||
roomId.value = null
|
||||
phase.value = null
|
||||
bump()
|
||||
if (s) {
|
||||
s.off(MatrixRTCSessionEvent.EncryptionKeyChanged, onEncryptionKey)
|
||||
s.off(MatrixRTCSessionEvent.MembershipManagerError, onMembershipError)
|
||||
s.off(MatrixRTCSessionEvent.DidSendCallNotification, onNotificationSent)
|
||||
}
|
||||
await Promise.allSettled([r?.disconnect(), s?.leave(5000)])
|
||||
if (rid) refresh(rid)
|
||||
}
|
||||
|
||||
async function toggleCamera() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
await c.setLocalVideoMuted(!c.isLocalVideoMuted())
|
||||
bump()
|
||||
}
|
||||
// --- What the view shows ------------------------------------------------
|
||||
|
||||
async function toggleScreenShare() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
const tiles = computed<Tile[]>(() => {
|
||||
void version.value
|
||||
const r = lk.value
|
||||
const rid = roomId.value
|
||||
if (!r || !rid) return []
|
||||
const room = attached?.getRoom(rid)
|
||||
const speakers = new Set(r.activeSpeakers.map((p) => p.identity))
|
||||
const tile = (p: Participant, local: boolean): Tile => {
|
||||
const userId = identityUser(p.identity)
|
||||
const member = room?.getMember(userId)
|
||||
const cam = p.getTrackPublication(Track.Source.Camera)
|
||||
const screen = p.getTrackPublication(Track.Source.ScreenShare)
|
||||
const mic = p.getTrackPublication(Track.Source.Microphone)
|
||||
return {
|
||||
id: p.identity,
|
||||
userId,
|
||||
name: member?.name ?? userId,
|
||||
avatarMxc: member?.getMxcAvatarUrl() ?? undefined,
|
||||
local,
|
||||
camera: cam?.track && !cam.isMuted ? cam.track : undefined,
|
||||
screen: screen?.track && !screen.isMuted ? screen.track : undefined,
|
||||
audio: !local && mic?.track ? mic.track : undefined,
|
||||
micMuted: !mic || mic.isMuted,
|
||||
speaking: speakers.has(p.identity),
|
||||
}
|
||||
}
|
||||
return [tile(r.localParticipant, true), ...[...r.remoteParticipants.values()].map((p) => tile(p, false))]
|
||||
})
|
||||
|
||||
/** The room's name, or for a call of two, the other person's. */
|
||||
const title = computed(() => {
|
||||
void version.value
|
||||
const rid = roomId.value
|
||||
const room = rid ? attached?.getRoom(rid) : undefined
|
||||
if (!room) return ''
|
||||
const other = otherOfTwo(room, attached?.getUserId() ?? '')
|
||||
return other ? (room.getMember(other)?.name ?? room.name) : room.name
|
||||
})
|
||||
|
||||
/** The person you're calling, for the "ringing" screen of a call of two. */
|
||||
const callee = computed(() => {
|
||||
void version.value
|
||||
const rid = roomId.value
|
||||
const room = rid ? attached?.getRoom(rid) : undefined
|
||||
const other = room ? otherOfTwo(room, attached?.getUserId() ?? '') : undefined
|
||||
const member = other ? room?.getMember(other) : undefined
|
||||
return member ? { name: member.name, avatarMxc: member.getMxcAvatarUrl() ?? undefined } : null
|
||||
})
|
||||
|
||||
const micOn = computed(() => {
|
||||
void version.value
|
||||
return lk.value?.localParticipant.isMicrophoneEnabled ?? false
|
||||
})
|
||||
const cameraOn = computed(() => {
|
||||
void version.value
|
||||
return lk.value?.localParticipant.isCameraEnabled ?? false
|
||||
})
|
||||
const sharing = computed(() => {
|
||||
void version.value
|
||||
return lk.value?.localParticipant.isScreenShareEnabled ?? false
|
||||
})
|
||||
|
||||
async function toggle(what: 'mic' | 'camera' | 'screen') {
|
||||
const p = lk.value?.localParticipant
|
||||
if (!p) return
|
||||
try {
|
||||
await c.setScreensharingEnabled(!c.isScreensharing())
|
||||
if (what === 'mic') await p.setMicrophoneEnabled(!p.isMicrophoneEnabled)
|
||||
else if (what === 'camera') await p.setCameraEnabled(!p.isCameraEnabled)
|
||||
else await p.setScreenShareEnabled(!p.isScreenShareEnabled, { audio: true })
|
||||
} catch (e) {
|
||||
useToasts().error(`Can’t share your screen: ${errorMessage(e)}`)
|
||||
// Cancelling the browser's screen picker is not an error worth a toast.
|
||||
if (!(e instanceof DOMException && e.name === 'NotAllowedError' && what === 'screen')) {
|
||||
useToasts().error(errorMessage(e))
|
||||
}
|
||||
}
|
||||
bump()
|
||||
}
|
||||
|
||||
function toggleHold() {
|
||||
const c = call.value
|
||||
if (!c) return
|
||||
c.setRemoteOnHold(!c.isRemoteOnHold())
|
||||
bump()
|
||||
/** How many people are in a room's call; 0 when there is none. */
|
||||
function inCall(rid: string): number {
|
||||
return active.value[rid] ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
version,
|
||||
length,
|
||||
endReason,
|
||||
roomId,
|
||||
phase,
|
||||
isVideo,
|
||||
peerName,
|
||||
peerAvatarMxc,
|
||||
attach,
|
||||
version,
|
||||
active,
|
||||
joined,
|
||||
encrypted,
|
||||
video,
|
||||
length,
|
||||
ringingOut,
|
||||
incoming,
|
||||
tiles,
|
||||
title,
|
||||
callee,
|
||||
micOn,
|
||||
cameraOn,
|
||||
sharing,
|
||||
supported,
|
||||
place,
|
||||
attach,
|
||||
join,
|
||||
leave,
|
||||
answer,
|
||||
decline,
|
||||
hangup,
|
||||
toggleMic,
|
||||
toggleCamera,
|
||||
toggleScreenShare,
|
||||
toggleHold,
|
||||
toggle,
|
||||
inCall,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The SDK's media handler queues every request for the microphone and
|
||||
* camera behind the previous one, and never lets go of a request that
|
||||
* failed: once you said no, every later call would fail the same way until
|
||||
* the page reloads. Dropping the failed request lets the next call ask again.
|
||||
*/
|
||||
function forgetFailedMedia() {
|
||||
const handler = client().getMediaHandler() as unknown as { getMediaStreamPromise?: Promise<MediaStream> }
|
||||
handler.getMediaStreamPromise = undefined
|
||||
}
|
||||
|
||||
function describeHangup(c: MatrixCall, connected: boolean): string {
|
||||
switch (c.hangupReason) {
|
||||
case CallErrorCode.UserBusy:
|
||||
return 'Busy'
|
||||
case CallErrorCode.InviteTimeout:
|
||||
return c.direction === CallDirection.Inbound ? 'Missed call' : 'No answer'
|
||||
case CallErrorCode.AnsweredElsewhere:
|
||||
return 'Answered in another session'
|
||||
case CallErrorCode.IceFailed:
|
||||
return 'Couldn’t connect'
|
||||
case CallErrorCode.NoUserMedia:
|
||||
return 'No microphone or camera'
|
||||
case CallErrorCode.Replaced:
|
||||
return 'Call replaced'
|
||||
default:
|
||||
if (!connected) {
|
||||
const outbound = c.direction === CallDirection.Outbound
|
||||
if (c.hangupParty === CallParty.Remote) return outbound ? 'Declined' : 'Missed call'
|
||||
return outbound ? 'Call cancelled' : 'Declined'
|
||||
}
|
||||
return 'Call ended'
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,9 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende
|
||||
// own placeholder text: never show that.
|
||||
if (ev.isDecryptionFailure()) return withSender ? `${senderName(ev, me, room)}: Can’t decrypt this message` : 'Can’t decrypt this message'
|
||||
if (type === EventType.RoomMessageEncrypted) return 'Encrypted message'
|
||||
if (type === EventType.CallInvite) {
|
||||
const text = isVideoCallInvite(ev) ? 'Video call' : 'Voice call'
|
||||
if (type === EventType.CallInvite || type === EventType.RTCNotification) {
|
||||
const video = type === EventType.CallInvite ? isVideoCallInvite(ev) : ev.getContent()['m.call.intent'] !== 'audio'
|
||||
const text = video ? 'Video call' : 'Voice call'
|
||||
return withSender ? `${senderName(ev, me, room)}: ${text}` : text
|
||||
}
|
||||
if (type !== EventType.RoomMessage && type !== EventType.Sticker) return null
|
||||
|
||||
@@ -54,19 +54,25 @@ const canSend = computed(() => {
|
||||
|
||||
const infoOpen = ref(false)
|
||||
|
||||
// Calls are between two people: offered in a room with just you and one
|
||||
// other person in it.
|
||||
const callee = computed(() => {
|
||||
// Calls, in any room with someone else in it: starting one in a room of
|
||||
// two rings the other person. One call at a time, wherever it is.
|
||||
const canCall = computed(() => {
|
||||
void chat.version
|
||||
const r = room.value
|
||||
if (!r || r.getJoinedMemberCount() !== 2) return undefined
|
||||
return r.getJoinedMembers().find((m) => m.userId !== me)?.userId
|
||||
return Boolean(r) && membership.value === KnownMembership.Join && call.supported() && (r?.getJoinedMemberCount() ?? 0) > 1
|
||||
})
|
||||
const canCall = computed(() => Boolean(callee.value) && call.supported())
|
||||
// One call at a time, wherever it is.
|
||||
const inCall = computed(() => Boolean(call.call) && call.phase !== 'ended')
|
||||
function startCall(video: boolean) {
|
||||
void call.place(props.roomId, callee.value, video)
|
||||
const inCall = computed(() => call.joined)
|
||||
// A call going on here that you're not in.
|
||||
const callHere = computed(() => call.inCall(props.roomId))
|
||||
const inThisCall = computed(() => call.roomId === props.roomId)
|
||||
const callPeople = computed(() => {
|
||||
void call.version
|
||||
void chat.version
|
||||
const n = callHere.value
|
||||
return n === 1 ? '1 person is' : `${n} people are`
|
||||
})
|
||||
function joinCall(video: boolean) {
|
||||
void call.join(props.roomId, video)
|
||||
}
|
||||
|
||||
// Someone in an encrypted room whose identity changed: said before you
|
||||
@@ -294,13 +300,23 @@ const placeholder = computed(() => `Message ${summary.value?.name ?? 'this room'
|
||||
<span v-if="!encrypted" class="caption hidden items-center gap-1 text-ink-muted sm:flex">
|
||||
<WIcon name="shield-alert" :size="14" /> Not encrypted
|
||||
</span>
|
||||
<template v-if="canCall">
|
||||
<WIconButton icon="phone" label="Voice call" :disabled="inCall" @click="startCall(false)" />
|
||||
<WIconButton icon="video" label="Video call" :disabled="inCall" @click="startCall(true)" />
|
||||
<template v-if="canCall && !callHere">
|
||||
<WIconButton icon="phone" label="Voice call" :disabled="inCall" @click="joinCall(false)" />
|
||||
<WIconButton icon="video" label="Video call" :disabled="inCall" @click="joinCall(true)" />
|
||||
</template>
|
||||
<WIconButton icon="users" :label="infoOpen ? 'Hide room info' : 'Room info'" :aria-pressed="infoOpen" @click="infoOpen = !infoOpen" />
|
||||
</header>
|
||||
|
||||
<div v-if="callHere && canCall && !inThisCall" class="shrink-0 border-b border-line bg-surface px-4 py-2">
|
||||
<WBanner tone="info" icon="video" title="A call is going on in this room">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="min-w-0 flex-1">{{ callPeople }} in it.</span>
|
||||
<WButton size="sm" :disabled="inCall" @click="joinCall(false)">Join with audio</WButton>
|
||||
<WButton size="sm" variant="primary" :disabled="inCall" @click="joinCall(true)">Join call</WButton>
|
||||
</div>
|
||||
</WBanner>
|
||||
</div>
|
||||
|
||||
<RoomTimeline :room="room" :is-direct="summary?.isDirect ?? false" />
|
||||
|
||||
<div class="shrink-0 bg-surface-sunken px-4 pt-2 pb-4">
|
||||
|
||||
@@ -26,6 +26,8 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/_matrix/client': { target: homeserver, changeOrigin: true },
|
||||
'/_matrix/media': { target: homeserver, changeOrigin: true },
|
||||
// Where the client finds the group call backend (rtc_foci).
|
||||
'/.well-known/matrix/client': { target: homeserver, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
# Calls for a local Warp: a LiveKit SFU and the LiveKit JWT service
|
||||
# (lk-jwt-service), layered over docker-compose.yml.
|
||||
#
|
||||
# cd deploy/compose
|
||||
# docker compose -f docker-compose.yml -f calls.yml --profile web up -d --build
|
||||
#
|
||||
# Then open http://localhost:8080 in a few browsers (or private windows),
|
||||
# sign in as different users, and press the voice or video call button in
|
||||
# a room: in a room of two it rings the other person.
|
||||
#
|
||||
# How the pieces find each other:
|
||||
#
|
||||
# browser ── /.well-known/matrix/client ──▶ warp rtc_foci: http://localhost:8070
|
||||
# browser ── openid/request_token ────────▶ warp an OpenID token
|
||||
# browser ── POST /sfu/get ───────────────▶ jwt {url, jwt} for the LiveKit room
|
||||
# jwt ───── /_matrix/federation/v1/openid/userinfo ─▶ warp (https://<server_name>:8448)
|
||||
# browser ── ws://localhost:7880 + UDP 7882 ─▶ livekit
|
||||
#
|
||||
# The JWT service checks a token by asking the user's homeserver over the
|
||||
# federation port, with TLS. To make that work with the default server name
|
||||
# "localhost", warp gets a federation listener on :8448 with a self-signed
|
||||
# certificate, and the JWT service and LiveKit share warp's network
|
||||
# namespace, so that "localhost" means warp for all three. This is a local
|
||||
# setup: in production, give each its own hostname and real certificates
|
||||
# (see docs/calls.md).
|
||||
|
||||
services:
|
||||
# Makes the self-signed certificate for warp's federation listener once.
|
||||
calls-cert:
|
||||
image: alpine:3.21
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- >-
|
||||
test -f /tls/tls.crt ||
|
||||
(apk add --no-cache openssl >/dev/null &&
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650
|
||||
-subj "/CN=$${WARP_SERVER_NAME}" -addext "subjectAltName=DNS:$${WARP_SERVER_NAME}"
|
||||
-keyout /tls/tls.key -out /tls/tls.crt &&
|
||||
chmod 644 /tls/tls.key /tls/tls.crt)
|
||||
environment:
|
||||
WARP_SERVER_NAME: ${WARP_SERVER_NAME:-localhost}
|
||||
volumes:
|
||||
- warp-tls:/tls
|
||||
|
||||
warp:
|
||||
depends_on:
|
||||
calls-cert:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
WARP_RTC_LIVEKIT_SERVICE_URL: ${WARP_LIVEKIT_JWT_PUBLIC_URL:-http://localhost:8070}
|
||||
WARP_LISTEN_FEDERATION_ADDRESS: ":8448"
|
||||
WARP_TLS_CERT_FILE: /tls/tls.crt
|
||||
WARP_TLS_KEY_FILE: /tls/tls.key
|
||||
volumes:
|
||||
- warp-tls:/tls:ro
|
||||
# LiveKit and the JWT service live in this container's network
|
||||
# namespace, so their ports are published here.
|
||||
ports:
|
||||
- "8070:8070" # lk-jwt-service
|
||||
- "7880:7880" # LiveKit signalling (WebSocket)
|
||||
- "7881:7881" # LiveKit media over TCP, the fallback
|
||||
- "7882:7882/udp" # LiveKit media over UDP
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.13.7
|
||||
restart: unless-stopped
|
||||
network_mode: service:warp
|
||||
depends_on:
|
||||
- warp
|
||||
environment:
|
||||
# node_ip is what LiveKit tells browsers to send media to. 127.0.0.1
|
||||
# works for browsers on this machine; set LIVEKIT_NODE_IP to the
|
||||
# machine's LAN address to call from other devices.
|
||||
LIVEKIT_CONFIG: |
|
||||
port: 7880
|
||||
bind_addresses: [""]
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: false
|
||||
node_ip: ${LIVEKIT_NODE_IP:-127.0.0.1}
|
||||
room:
|
||||
auto_create: false
|
||||
keys:
|
||||
${LIVEKIT_KEY:-warp}: ${LIVEKIT_SECRET:-warp-local-livekit-secret-change-me-0123456789}
|
||||
logging:
|
||||
level: info
|
||||
|
||||
livekit-jwt:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
|
||||
restart: unless-stopped
|
||||
network_mode: service:warp
|
||||
depends_on:
|
||||
- warp
|
||||
- livekit
|
||||
environment:
|
||||
LIVEKIT_JWT_BIND: ":8070"
|
||||
# The URL handed to browsers, and the one the service creates rooms
|
||||
# through: both reach LiveKit at localhost here.
|
||||
LIVEKIT_URL: ${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
|
||||
LIVEKIT_KEY: ${LIVEKIT_KEY:-warp}
|
||||
LIVEKIT_SECRET: ${LIVEKIT_SECRET:-warp-local-livekit-secret-change-me-0123456789}
|
||||
# Users of this server may create call rooms on the SFU.
|
||||
LIVEKIT_FULL_ACCESS_HOMESERVERS: ${WARP_SERVER_NAME:-localhost}
|
||||
# warp's federation certificate is self-signed.
|
||||
LIVEKIT_INSECURE_SKIP_VERIFY_TLS: YES_I_KNOW_WHAT_I_AM_DOING
|
||||
|
||||
web:
|
||||
environment:
|
||||
# The browser connects to the JWT service and to LiveKit directly.
|
||||
WARP_WEB_CALL_ORIGINS: >-
|
||||
${WARP_LIVEKIT_JWT_PUBLIC_URL:-http://localhost:8070}
|
||||
${LIVEKIT_PUBLIC_URL:-ws://localhost:7880}
|
||||
|
||||
volumes:
|
||||
warp-tls:
|
||||
@@ -12,6 +12,12 @@
|
||||
# http://localhost:8009; with the web profile, the web client is on
|
||||
# http://localhost:8080.
|
||||
#
|
||||
# For voice and video calls (LiveKit and its JWT service) add calls.yml:
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f calls.yml --profile web up -d
|
||||
#
|
||||
# WARP_PORT moves the homeserver off host port 8008.
|
||||
#
|
||||
# Create the first administrator once the server is up:
|
||||
#
|
||||
# docker compose exec warp warp create-account --username admin --admin
|
||||
@@ -63,7 +69,7 @@ services:
|
||||
volumes:
|
||||
- warp-data:/data
|
||||
ports:
|
||||
- "8008:8008"
|
||||
- "${WARP_PORT:-8008}:8008"
|
||||
|
||||
# The admin panel is optional. Start it with the admin profile:
|
||||
# docker compose --profile admin up -d
|
||||
|
||||
@@ -65,3 +65,32 @@ app.kubernetes.io/name: {{ printf "%s-admin" (include "warp.name" .) }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: admin
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The LiveKit JWT service for calls, with its own name label like the
|
||||
admin panel.
|
||||
*/}}
|
||||
{{- define "warp.calls.fullname" -}}
|
||||
{{- printf "%s-livekit-jwt" (include "warp.fullname" . | trunc 51 | trimSuffix "-") -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "warp.calls.labels" -}}
|
||||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
|
||||
{{ include "warp.calls.selectorLabels" . }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "warp.calls.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ printf "%s-livekit-jwt" (include "warp.name" .) }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: calls
|
||||
{{- end -}}
|
||||
|
||||
{{/* The JWT service's public URL, advertised to clients: explicit, else its ingress. */}}
|
||||
{{- define "warp.calls.serviceURL" -}}
|
||||
{{- if .Values.calls.livekitServiceURL -}}
|
||||
{{- .Values.calls.livekitServiceURL -}}
|
||||
{{- else if and .Values.calls.jwtService.enabled .Values.calls.jwtService.ingress.enabled -}}
|
||||
{{- printf "https://%s" .Values.calls.jwtService.ingress.host -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -97,6 +97,10 @@ spec:
|
||||
value: {{ .Values.tracing.sampleRatio | quote }}
|
||||
- name: WARP_LOG_FORMAT
|
||||
value: json
|
||||
{{- with (include "warp.calls.serviceURL" .) }}
|
||||
- name: WARP_RTC_LIVEKIT_SERVICE_URL
|
||||
value: {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{{- if .Values.calls.jwtService.enabled }}
|
||||
{{- $jwt := .Values.calls.jwtService }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "warp.calls.fullname" . }}
|
||||
labels:
|
||||
{{- include "warp.calls.labels" . | nindent 4 }}
|
||||
spec:
|
||||
# The service keeps its state in memory: one replica, unless LIVEKIT_REDIS_URL
|
||||
# is set through extraEnv.
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "warp.calls.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "warp.calls.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: lk-jwt-service
|
||||
image: "{{ $jwt.image.repository }}:{{ $jwt.image.tag }}"
|
||||
imagePullPolicy: {{ $jwt.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: LIVEKIT_JWT_BIND
|
||||
value: ":8080"
|
||||
- name: LIVEKIT_URL
|
||||
value: {{ required "calls.jwtService.livekitURL is required" $jwt.livekitURL | quote }}
|
||||
- name: LIVEKIT_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ required "calls.jwtService.existingSecret is required" $jwt.existingSecret }}
|
||||
key: {{ $jwt.keyKey }}
|
||||
- name: LIVEKIT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ $jwt.existingSecret }}
|
||||
key: {{ $jwt.secretKey }}
|
||||
- name: LIVEKIT_FULL_ACCESS_HOMESERVERS
|
||||
value: {{ $jwt.fullAccessHomeservers | default .Values.serverName | quote }}
|
||||
{{- with $jwt.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
resources:
|
||||
{{- toYaml $jwt.resources | nindent 12 }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "warp.calls.fullname" . }}
|
||||
labels:
|
||||
{{- include "warp.calls.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: {{ $jwt.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ $jwt.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
{{- include "warp.calls.selectorLabels" . | nindent 4 }}
|
||||
{{- if $jwt.ingress.enabled }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "warp.calls.fullname" . }}
|
||||
labels:
|
||||
{{- include "warp.calls.labels" . | nindent 4 }}
|
||||
{{- with $jwt.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with $jwt.ingress.className }}
|
||||
ingressClassName: {{ . }}
|
||||
{{- end }}
|
||||
{{- with $jwt.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ required "calls.jwtService.ingress.host is required" $jwt.ingress.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "warp.calls.fullname" . }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -190,3 +190,50 @@ admin:
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
|
||||
# Calls (MatrixRTC with a LiveKit SFU). Warp only advertises the LiveKit
|
||||
# JWT service and answers its OpenID checks; the media goes through LiveKit.
|
||||
# See docs/calls.md.
|
||||
calls:
|
||||
# The JWT service's public URL, advertised in /.well-known/matrix/client.
|
||||
# Defaults to https://<jwtService.ingress.host> when the chart runs the
|
||||
# service with an ingress; empty and without one, there are no calls.
|
||||
livekitServiceURL: ""
|
||||
|
||||
# lk-jwt-service: hands out LiveKit tokens to users of this server, who
|
||||
# prove who they are with an OpenID token. It checks that token with this
|
||||
# server's federation endpoint at https://<serverName>/.well-known/matrix/server
|
||||
# or <serverName>:8448, so one of the two must reach Warp.
|
||||
jwtService:
|
||||
enabled: false
|
||||
image:
|
||||
repository: ghcr.io/element-hq/lk-jwt-service
|
||||
tag: "0.8.0"
|
||||
pullPolicy: IfNotPresent
|
||||
# The LiveKit SFU's WebSocket URL as browsers reach it, e.g.
|
||||
# wss://livekit.example.org. Run LiveKit itself with its own chart
|
||||
# (https://github.com/livekit/livekit-helm); it needs UDP from clients.
|
||||
livekitURL: wss://livekit.example.org
|
||||
# A secret with the LiveKit API key and secret under these keys.
|
||||
existingSecret: ""
|
||||
keyKey: LIVEKIT_KEY
|
||||
secretKey: LIVEKIT_SECRET
|
||||
# Homeservers whose users may create call rooms on the SFU. Defaults to
|
||||
# serverName.
|
||||
fullAccessHomeservers: ""
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ""
|
||||
annotations: {}
|
||||
host: livekit-jwt.example.org
|
||||
tls: []
|
||||
extraEnv: []
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
@@ -13,6 +13,7 @@
|
||||
| [federation.md](federation.md) | the Server-Server API: discovery, keys, taking in events, joins across servers, the outbound queue, the switch |
|
||||
| [media.md](media.md) | `internal/mediaapi`: uploads, downloads, thumbnails, other servers' media, where the files go |
|
||||
| [e2ee.md](e2ee.md) | `internal/keyserver`: device and one-time keys, cross-signing, device list tracking, key backup |
|
||||
| [calls.md](calls.md) | voice and video calls with MatrixRTC and LiveKit: ringing, delayed events, OpenID, the JWT service, deploying it |
|
||||
| [push.md](push.md) | `internal/pushrules` and `internal/pushserver`: push rules, notification counts, pushers, the push gateway client |
|
||||
| [appservices.md](appservices.md) | `internal/appservice`: registration files, namespaces, acting as users, delivering events to bridges |
|
||||
| [admin-api.md](admin-api.md) | `/_warp/admin/v1`: the operator API behind the admin panel |
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
# Voice and video calls
|
||||
|
||||
Every call in Warp's web client, between two people or twenty, is a
|
||||
MatrixRTC call (MSC4143) with its media through a LiveKit SFU: the Element
|
||||
Call model, which Element X uses too. A call of two is a call with two
|
||||
members, plus a ring. The server needs a LiveKit SFU and the LiveKit JWT
|
||||
service (`client_api.rtc`); without them there are no calls.
|
||||
|
||||
The old Matrix 1:1 VoIP (`m.call.*` events, WebRTC between the two
|
||||
browsers) is not used: the web client starts the SDK with `disableVoip`, so
|
||||
it neither places nor answers such calls, and shows them only as history in
|
||||
the timeline. The server still serves `GET /_matrix/client/v3/voip/turnServer`
|
||||
(`client_api.turn`) for other clients that do.
|
||||
|
||||
## How a call works
|
||||
|
||||
Who is in a room's call is room state: every device in it has an
|
||||
`org.matrix.msc3401.call.member` state event (state key
|
||||
`_@user:server_DEVICE_m.call`), with the LiveKit service it publishes to.
|
||||
The media goes through a LiveKit SFU, which Warp does not run itself.
|
||||
|
||||
```
|
||||
browser ── GET /.well-known/matrix/client ──────────▶ warp org.matrix.msc4143.rtc_foci
|
||||
── GET …/org.matrix.msc4143/rtc/transports ─▶ warp (the same list, authenticated)
|
||||
── PUT …/state/org.matrix.msc3401.call.member/<key>
|
||||
?org.matrix.msc4140.delay=… (the delayed "left")
|
||||
and then without a delay (the "joined") ──▶ warp
|
||||
── POST /user/<me>/openid/request_token ────▶ warp an OpenID token
|
||||
── POST <livekit_service_url>/sfu/get ──────▶ lk-jwt-service
|
||||
lk-jwt-service ── GET /_matrix/federation/v1/openid/userinfo ──▶ warp
|
||||
◀── {url, jwt}
|
||||
── WebSocket + UDP ─────────────────────────▶ LiveKit
|
||||
```
|
||||
|
||||
### What the server does
|
||||
|
||||
- **Discovery.** With `client_api.rtc.livekit_service_url` set, Warp adds
|
||||
`org.matrix.msc4143.rtc_foci: [{"type": "livekit", "livekit_service_url": …}]`
|
||||
to `/.well-known/matrix/client` and serves the same list at
|
||||
`GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports`.
|
||||
- **Delayed events (MSC4140)**, advertised as `org.matrix.msc4140` in
|
||||
`/versions`. `PUT /rooms/{roomId}/state/{type}/{key}` and
|
||||
`PUT /rooms/{roomId}/send/{type}/{txnId}` take
|
||||
`?org.matrix.msc4140.delay=<ms>`: the event is stored instead of sent,
|
||||
and the answer is `{"delay_id": …}`. Under
|
||||
`/_matrix/client/unstable/org.matrix.msc4140`:
|
||||
- `GET /delayed_events` lists your events as `scheduled` and `finalised`
|
||||
(`?status=` and `?delay_id=` narrow it); `GET /delayed_events/{id}`
|
||||
returns one.
|
||||
- `POST /delayed_events/{id}/{restart|cancel|send}`, or the older
|
||||
`POST /delayed_events/{id}` with `{"action": …}`. These need no access
|
||||
token: the delay ID is the capability.
|
||||
|
||||
A scheduler goroutine sleeps until the next event is due and sends it
|
||||
as the user who scheduled it, through the same pipeline as any event, so
|
||||
power levels and membership still apply; a send that fails is finalised
|
||||
with the error the client would have got. Someone *else* setting the
|
||||
same piece of state cancels a delayed state event, so a stale "left"
|
||||
can't overwrite a newer membership; the owner's own updates don't. Events
|
||||
live in the database (`delayed_events`, migration 14) and survive a
|
||||
restart. Finalised events are kept for a day. `client_api.delayed_events`
|
||||
caps the delay (24h) and how many a user may have (100).
|
||||
|
||||
This is what makes a call robust: a joining member schedules its own
|
||||
empty `call.member` a few seconds out and keeps restarting it while it
|
||||
is in the call. A browser that crashes or loses its network stops
|
||||
restarting it, and Warp removes the member.
|
||||
- **OpenID.** `POST /_matrix/client/v3/user/{userId}/openid/request_token`
|
||||
gives a user a one-hour token, which `GET /_matrix/federation/v1/openid/userinfo?access_token=…`
|
||||
turns back into `{"sub": "@user:server"}` for whoever it was handed to.
|
||||
Tokens are stored hashed (`openid_tokens`). The userinfo endpoint is
|
||||
served with federation switched off too: a server that does not
|
||||
federate can still have calls.
|
||||
- **Power levels.** New rooms let anyone who may talk join the call:
|
||||
`org.matrix.msc3401.call.member` is at level 0 in the `events` of the
|
||||
default power levels. Rooms created before this keep `state_default`
|
||||
(50) for it, so an ordinary member gets `M_FORBIDDEN` until a moderator
|
||||
sets it to 0.
|
||||
|
||||
### Ringing
|
||||
|
||||
Starting a call in a room with one other person rings them. The caller's
|
||||
MatrixRTC session sends an `org.matrix.msc4075.rtc.notification`
|
||||
(`notification_type: "ring"`, a `lifetime` of a minute, `m.call.intent`
|
||||
audio or video) when its membership is the first; in a bigger room it
|
||||
sends `notification`, which the web client doesn't ring for, since the
|
||||
"Join call" banner says enough. The other side shows an incoming-call card
|
||||
for a ring that is someone else's, mentions them or the room, and is
|
||||
within its lifetime, until they answer (join the call), decline (an
|
||||
`org.matrix.msc4310.rtc.decline` referencing the notification, which also
|
||||
stops the ring on their other devices), the caller leaves, or it expires.
|
||||
The caller hears a ringback until someone joins, and gives up on a decline
|
||||
or after the minute. In an encrypted room both events are encrypted like
|
||||
any other.
|
||||
|
||||
The SDK's own validator for received notifications expects MSC4354 sticky
|
||||
events and `m.rtc.slot` state, which Warp doesn't serve yet, so the web
|
||||
client applies the receiving rules itself.
|
||||
|
||||
### What the web client does
|
||||
|
||||
`src/stores/call.ts` uses the SDK's MatrixRTC session
|
||||
(`client.matrixRTC.getRoomSession(room)`), publishing the transport
|
||||
`{type: "livekit", livekit_service_url, livekit_alias: <room ID>}` (the one
|
||||
the call already uses, else this server's), gets a LiveKit token from
|
||||
`/sfu/get`, and connects with `livekit-client`. In an encrypted room it
|
||||
joins with `manageMediaKeys`: each member makes its own media key and
|
||||
sends it to the others over encrypted to-device messages, and a
|
||||
`BaseKeyProvider` hands each key to LiveKit's frame encryption for that
|
||||
participant (`@user:server:DEVICE`). The settings (no shared key, no
|
||||
ratcheting) match Element Call's, so the two can share a call.
|
||||
|
||||
### Running it
|
||||
|
||||
Locally, [`deploy/compose/calls.yml`](../deploy/compose/calls.yml) adds
|
||||
LiveKit and lk-jwt-service to the Compose setup:
|
||||
|
||||
```sh
|
||||
cd deploy/compose
|
||||
WARP_REGISTRATION_ENABLED=true docker compose -f docker-compose.yml -f calls.yml --profile web up -d --build
|
||||
```
|
||||
|
||||
Then sign in as different users at <http://localhost:8080>,
|
||||
<http://127.0.0.1:8080> and <http://[::1]:8080> (different origins keep
|
||||
separate sessions in one browser). A voice or video call in a room of two
|
||||
rings the other person; in a room of three or more the others see a "Join
|
||||
call" banner. `WARP_PORT` and `WARP_WEB_PORT` move the homeserver and web client
|
||||
off 8008 and 8080 (then set `WARP_PUBLIC_BASEURL` to match).
|
||||
|
||||
The JWT service checks an OpenID token by asking the user's homeserver
|
||||
over its federation port with TLS. For the server name `localhost` that
|
||||
means `https://localhost:8448`, so the file gives Warp a federation
|
||||
listener with a self-signed certificate (`LIVEKIT_INSECURE_SKIP_VERIFY_TLS`
|
||||
accepts it) and runs LiveKit and the JWT service in Warp's network
|
||||
namespace, where `localhost` is Warp. One consequence: when the `warp`
|
||||
container is recreated on its own, the other two lose their network. Run
|
||||
`up` for the whole file, not for one service, or recreate them with
|
||||
`docker compose -f docker-compose.yml -f calls.yml up -d --force-recreate livekit livekit-jwt`.
|
||||
LiveKit advertises `127.0.0.1` for media; set `LIVEKIT_NODE_IP` to the
|
||||
machine's LAN address to call from other devices.
|
||||
|
||||
In production, give each piece its own hostname and a real certificate:
|
||||
|
||||
- **LiveKit**, reachable by browsers on TCP 7880 behind TLS (`wss://`),
|
||||
and UDP (7882, or a port range) and TCP 7881 for media. See LiveKit's
|
||||
own deployment guide or its Helm chart.
|
||||
- **lk-jwt-service** behind TLS, with `LIVEKIT_URL` (the `wss://` URL),
|
||||
`LIVEKIT_KEY` / `LIVEKIT_SECRET` (LiveKit's API key) and
|
||||
`LIVEKIT_FULL_ACCESS_HOMESERVERS` (your server name). The Helm chart
|
||||
runs it with `calls.jwtService.enabled`.
|
||||
- **Warp** with `client_api.rtc.livekit_service_url` set to the JWT
|
||||
service's URL. The JWT service must reach
|
||||
`https://<server_name>/.well-known/matrix/server` (a delegation to the
|
||||
host that serves Warp) or `<server_name>:8448`. Note that Warp serves
|
||||
`/.well-known/matrix/server` only with federation on; with it off,
|
||||
serve the delegation file from the server name's web server, as you
|
||||
would for `/.well-known/matrix/client`.
|
||||
- **warp-web**: `WARP_WEB_CALL_ORIGINS="https://livekit-jwt.example.org wss://livekit.example.org"`
|
||||
lets the browser reach both through its Content-Security-Policy.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- Calls only use the legacy `org.matrix.msc3401.call.member` state events,
|
||||
not the sticky `m.rtc.member` events of newer MatrixRTC drafts.
|
||||
- `org.matrix.msc4140.parent_delay_id` (chained delayed events) is
|
||||
refused, and a delayed send's transaction ID is not deduplicated.
|
||||
- No ring for calls in rooms of more than two (only the banner), no
|
||||
MSC4354 sticky events, and no MSC4195 homeserver-side LiveKit token
|
||||
endpoint (`rtc/livekit/get_token`).
|
||||
- Calls from clients that only do legacy 1:1 VoIP (`m.call.*`) don't ring
|
||||
in the web client.
|
||||
@@ -62,10 +62,13 @@ List-valued variables are comma-separated. A single `-` clears a list:
|
||||
| `client_api.rate_limit.join.burst` / `.interval` | — | `10` / `1s` | The two join endpoints. |
|
||||
| `client_api.rate_limit.sync.burst` / `.interval` | — | `100` / `20ms` | `/sync` and the writes behind it: typing, receipts, to-device, presence. |
|
||||
| `client_api.rate_limit.media.burst` / `.interval` | — | `100` / `20ms` | Media downloads and thumbnails: a client opening a room asks for every avatar at once. Uploads are in the default bucket. |
|
||||
| `client_api.turn.uris` | `WARP_TURN_URIS` | `[]` | `turn:`, `turns:` and `stun:` URIs for calls. Empty = no relay; `/voip/turnServer` answers `{}`. |
|
||||
| `client_api.turn.uris` | `WARP_TURN_URIS` | `[]` | `turn:`, `turns:` and `stun:` URIs for other clients' legacy 1:1 VoIP calls (the web client calls through LiveKit and doesn't use them). Empty = no relay; `/voip/turnServer` answers `{}`. |
|
||||
| `client_api.turn.shared_secret` | `WARP_TURN_SHARED_SECRET` | — | coturn's `static-auth-secret`: each user gets expiring TURN REST API credentials. |
|
||||
| `client_api.turn.user_lifetime` | `WARP_TURN_USER_LIFETIME` | `1h` | How long shared-secret credentials last. |
|
||||
| `client_api.turn.username` / `.password` | `WARP_TURN_USERNAME` / `WARP_TURN_PASSWORD` | — | Static credentials, used only without a shared secret. |
|
||||
| `client_api.rtc.livekit_service_url` | `WARP_RTC_LIVEKIT_SERVICE_URL` | — | Base URL of the LiveKit JWT service (lk-jwt-service) for calls, advertised in `/.well-known/matrix/client` as `org.matrix.msc4143.rtc_foci`. Empty = no calls. See [calls.md](calls.md). |
|
||||
| `client_api.delayed_events.max_delay` | `WARP_DELAYED_EVENTS_MAX_DELAY` | `24h` | Longest delay a client may give a delayed event (MSC4140). `0` turns delayed events off and stops advertising `org.matrix.msc4140`; calls then can't remove a member whose client vanished. |
|
||||
| `client_api.delayed_events.max_per_user` | `WARP_DELAYED_EVENTS_MAX_PER_USER` | `100` | How many delayed events one user may have scheduled at once. |
|
||||
| `sync.presence` | `WARP_SYNC_PRESENCE` | `false` | Off by default; see below. |
|
||||
| `sync.typing_timeout` | `WARP_SYNC_TYPING_TIMEOUT` | `30s` | How long a typing notification lasts, and the cap on what a client may ask for. |
|
||||
| `sync.long_poll_timeout` | `WARP_SYNC_LONG_POLL_TIMEOUT` | `30s` | The cap on a `/sync` `?timeout=`. |
|
||||
|
||||
@@ -135,6 +135,21 @@ already public behind its own proxy, set `WARP_WEB_PUBLIC_HOMESERVER` to
|
||||
its URL instead: the browser then calls it directly and warp-web serves
|
||||
only the client. See [apps/web](../apps/web/README.md).
|
||||
|
||||
## Calls
|
||||
|
||||
Calls need a LiveKit SFU and the LiveKit JWT service next to Warp.
|
||||
For a local try, `deploy/compose/calls.yml` adds both:
|
||||
|
||||
```sh
|
||||
cd deploy/compose
|
||||
WARP_REGISTRATION_ENABLED=true docker compose -f docker-compose.yml -f calls.yml --profile web up -d --build
|
||||
```
|
||||
|
||||
The Helm chart can run the JWT service (`calls.jwtService.enabled`) and
|
||||
sets `client_api.rtc.livekit_service_url` from it; LiveKit itself runs
|
||||
from its own chart. See [calls.md](calls.md) for how the pieces find each
|
||||
other and what production needs.
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
Behind a proxy that terminates TLS:
|
||||
|
||||
Generated
+94
@@ -71,6 +71,9 @@ importers:
|
||||
jsqr:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0
|
||||
livekit-client:
|
||||
specifier: 2.22.3
|
||||
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
matrix-js-sdk:
|
||||
specifier: ^44.0.0
|
||||
version: 44.0.0
|
||||
@@ -131,6 +134,9 @@ packages:
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@bufbuild/protobuf@1.10.1':
|
||||
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -303,6 +309,12 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@livekit/mutex@1.1.1':
|
||||
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
|
||||
|
||||
'@livekit/protocol@1.50.4':
|
||||
resolution: {integrity: sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==}
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
|
||||
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -531,6 +543,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@types/dom-mediacapture-record@1.0.22':
|
||||
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@@ -676,6 +691,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.12:
|
||||
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||
|
||||
jsqr@1.4.0:
|
||||
resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
|
||||
|
||||
@@ -749,10 +767,19 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
livekit-client@2.22.3:
|
||||
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
loglevel@1.9.2:
|
||||
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
machina@7.0.1:
|
||||
resolution: {integrity: sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==}
|
||||
engines: {node: '>=22.22'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -818,10 +845,20 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
sdp-transform@2.15.0:
|
||||
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
|
||||
hasBin: true
|
||||
|
||||
sdp-transform@3.0.0:
|
||||
resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
|
||||
hasBin: true
|
||||
|
||||
sdp@3.2.2:
|
||||
resolution: {integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==}
|
||||
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -845,6 +882,12 @@ packages:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typed-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -918,6 +961,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
webrtc-adapter@9.0.6:
|
||||
resolution: {integrity: sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==}
|
||||
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
@@ -935,6 +982,8 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@bufbuild/protobuf@1.10.1': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.2':
|
||||
optional: true
|
||||
|
||||
@@ -1032,6 +1081,12 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@livekit/mutex@1.1.1': {}
|
||||
|
||||
'@livekit/protocol@1.50.4':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
|
||||
|
||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||
@@ -1182,6 +1237,8 @@ snapshots:
|
||||
tailwindcss: 4.3.3
|
||||
vite: 7.3.7(@types/node@22.20.5)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||
|
||||
'@types/dom-mediacapture-record@1.0.22': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/events@3.0.3': {}
|
||||
@@ -1367,6 +1424,8 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.12: {}
|
||||
|
||||
jsqr@1.4.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@@ -1418,8 +1477,24 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
'@types/dom-mediacapture-record': 1.0.22
|
||||
events: 3.3.0
|
||||
jose: 6.2.12
|
||||
loglevel: 1.9.2
|
||||
machina: 7.0.1
|
||||
sdp-transform: 2.15.0
|
||||
tslib: 2.8.1
|
||||
typed-emitter: 2.1.0
|
||||
webrtc-adapter: 9.0.6
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
machina@7.0.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
@@ -1512,8 +1587,17 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.64.9
|
||||
fsevents: 2.3.3
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
sdp-transform@2.15.0: {}
|
||||
|
||||
sdp-transform@3.0.0: {}
|
||||
|
||||
sdp@3.2.2: {}
|
||||
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
@@ -1531,6 +1615,12 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typed-emitter@2.1.0:
|
||||
optionalDependencies:
|
||||
rxjs: 7.8.2
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
@@ -1573,3 +1663,7 @@ snapshots:
|
||||
'@vue/shared': 3.5.44
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
webrtc-adapter@9.0.6:
|
||||
dependencies:
|
||||
sdp: 3.2.2
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/appservice"
|
||||
"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/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/keyserver"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/settings"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/syncapi"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
@@ -48,6 +50,9 @@ type ClientAPI struct {
|
||||
regTokens *userapi.RegistrationTokens
|
||||
appservices *appservice.API
|
||||
turn config.TURN
|
||||
delayed *delayedevents.Service
|
||||
rtcTransports []RTCTransport
|
||||
openID storage.OpenIDTokenStore
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
@@ -145,6 +150,20 @@ type Options struct {
|
||||
// TURN is the relay handed to clients for calls.
|
||||
TURN config.TURN
|
||||
|
||||
// RTC is the group call backend, listed at the MSC4143 transports
|
||||
// endpoint.
|
||||
RTC config.RTC
|
||||
|
||||
// DelayedEvents schedules MSC4140 delayed events. Nil, or a service
|
||||
// with delayed events switched off, leaves the endpoints unregistered
|
||||
// and refuses the delay parameter.
|
||||
DelayedEvents *delayedevents.Service
|
||||
|
||||
// OpenIDTokens keeps the OpenID tokens a user hands third parties,
|
||||
// such as the LiveKit JWT service of a group call. Nil leaves the
|
||||
// OpenID endpoints unregistered.
|
||||
OpenIDTokens storage.OpenIDTokenStore
|
||||
|
||||
// TrustXForwardedFor takes the client address from the proxy header.
|
||||
TrustXForwardedFor bool
|
||||
|
||||
@@ -205,6 +224,9 @@ func New(opts Options) (*ClientAPI, error) {
|
||||
regTokens: opts.RegistrationTokens,
|
||||
appservices: opts.AppServices,
|
||||
turn: opts.TURN,
|
||||
delayed: opts.DelayedEvents,
|
||||
rtcTransports: RTCTransports(opts.RTC.LiveKitServiceURL),
|
||||
openID: opts.OpenIDTokens,
|
||||
log: opts.Logger,
|
||||
now: opts.Now,
|
||||
uia: newUIAStore(uiaSessionTTL, opts.Now),
|
||||
@@ -274,6 +296,9 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
|
||||
|
||||
// Calls: the relay a client sets its peer connection up with.
|
||||
c.authed(r, "GET "+v3+"/voip/turnServer", c.limitDefault, c.handleTurnServer)
|
||||
c.registerOpenIDRoutes(r)
|
||||
c.registerDelayedEventRoutes(r)
|
||||
c.registerRTCRoutes(r)
|
||||
|
||||
// Devices.
|
||||
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
|
||||
|
||||
@@ -2,16 +2,20 @@ package clientapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
|
||||
@@ -39,6 +43,44 @@ type testServer struct {
|
||||
settings *settings.Service
|
||||
fedGate *federation.Gate
|
||||
db storage.Database
|
||||
|
||||
// delayed is the delayed event service, on a clock of its own that
|
||||
// tests move with advance.
|
||||
delayed *delayedevents.Service
|
||||
clock *testClock
|
||||
}
|
||||
|
||||
// testClock is a clock a test moves by hand.
|
||||
type testClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *testClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *testClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// teeNotifier passes the roomserver's emit stage on to the syncapi and,
|
||||
// once it is built, to the delayed event service, which needs the
|
||||
// roomserver to exist first.
|
||||
type teeNotifier struct {
|
||||
first roomserver.Notifier
|
||||
then roomserver.Notifier
|
||||
}
|
||||
|
||||
func (n *teeNotifier) EventPersisted(ctx context.Context, note roomserver.Notification) {
|
||||
n.first.EventPersisted(ctx, note)
|
||||
if n.then != nil {
|
||||
n.then.EventPersisted(ctx, note)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServer builds a server. tweak adjusts the options before the
|
||||
@@ -91,13 +133,14 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
// stage and the syncapi's wake-up are the same object, exactly as in
|
||||
// a real server.
|
||||
notifier := syncapi.NewNotifier()
|
||||
emit := &teeNotifier{first: notifier}
|
||||
rooms, err := roomserver.New(roomserver.Options{
|
||||
ServerName: testServerName,
|
||||
Store: db.Rooms(),
|
||||
Profiles: users,
|
||||
SigningKey: key,
|
||||
FederationEnabled: settingsService.FederationEnabled,
|
||||
Notifier: notifier,
|
||||
Notifier: emit,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -121,14 +164,34 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
}
|
||||
t.Cleanup(sync.Close)
|
||||
|
||||
// Delayed events are on, with the scheduler left stopped: a test
|
||||
// moves the clock and calls SendDue itself.
|
||||
clock := &testClock{now: time.Now()}
|
||||
delayed, err := delayedevents.New(delayedevents.Options{
|
||||
Store: db.DelayedEvents(),
|
||||
Rooms: rooms,
|
||||
MaxDelay: time.Hour,
|
||||
MaxPerUser: 5,
|
||||
DescribeError: clientapi.DescribeRoomError,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
Now: clock.Now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delayedevents.New: %v", err)
|
||||
}
|
||||
t.Cleanup(delayed.Close)
|
||||
emit.then = delayed
|
||||
|
||||
opts := clientapi.Options{
|
||||
ServerName: testServerName,
|
||||
Users: users,
|
||||
Rooms: rooms,
|
||||
Sync: sync,
|
||||
Settings: settingsService,
|
||||
Federation: gate,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
ServerName: testServerName,
|
||||
Users: users,
|
||||
Rooms: rooms,
|
||||
Sync: sync,
|
||||
Settings: settingsService,
|
||||
Federation: gate,
|
||||
DelayedEvents: delayed,
|
||||
OpenIDTokens: db.OpenIDTokens(),
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
if tweak != nil {
|
||||
tweak(&opts)
|
||||
@@ -150,6 +213,8 @@ func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
|
||||
settings: settingsService,
|
||||
fedGate: gate,
|
||||
db: db,
|
||||
delayed: delayed,
|
||||
clock: clock,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// openIDTokenLifetime is how long an OpenID token is good for. The LiveKit
|
||||
// JWT service checks it once, when a call is joined, so an hour is plenty.
|
||||
const openIDTokenLifetime = time.Hour
|
||||
|
||||
// openIDTokenPrefix marks OpenID tokens the way the userapi marks access
|
||||
// tokens, so one handed to the wrong endpoint is recognisable.
|
||||
const openIDTokenPrefix = "warp_oi_"
|
||||
|
||||
// openIDUserInfoPath is where a third party exchanges an OpenID token for
|
||||
// the user's ID. It is a federation endpoint by name, but needs neither
|
||||
// federation nor a signing key: Synapse serves it as a resource of its own
|
||||
// for the same reason. It is registered here so that a server with
|
||||
// federation off can still let its users into a group call.
|
||||
const openIDUserInfoPath = "/_matrix/federation/v1/openid/userinfo"
|
||||
|
||||
// registerOpenIDRoutes adds the OpenID endpoints, if there is a store for
|
||||
// the tokens.
|
||||
func (c *ClientAPI) registerOpenIDRoutes(r *httputil.Router) {
|
||||
if c.openID == nil {
|
||||
return
|
||||
}
|
||||
c.authed(r, "POST "+v3+"/user/{userID}/openid/request_token", c.limitDefault, c.handleRequestOpenIDToken)
|
||||
c.public(r, "GET "+openIDUserInfoPath, c.limitDefault, c.handleOpenIDUserInfo)
|
||||
}
|
||||
|
||||
type openIDTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
MatrixServerName string `json:"matrix_server_name"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// handleRequestOpenIDToken implements
|
||||
// POST /_matrix/client/v3/user/{userID}/openid/request_token.
|
||||
//
|
||||
// The token proves who the user is to whoever they hand it to, and nothing
|
||||
// else: it is not an access token and opens no other endpoint.
|
||||
func (c *ClientAPI) handleRequestOpenIDToken(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
|
||||
if r.PathValue("userID") != auth.UserID {
|
||||
return httputil.Forbidden("You cannot request an OpenID token for another user")
|
||||
}
|
||||
// The body is an empty object; anything in it is ignored.
|
||||
var ignored map[string]any
|
||||
if _, err := httputil.DecodeOptionalJSON(w, r, &ignored); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("generate OpenID token: %w", err)
|
||||
}
|
||||
token := openIDTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
|
||||
now := c.now()
|
||||
// Expired tokens go as new ones are made; nothing else reads them.
|
||||
if err := c.openID.PurgeOpenIDTokens(r.Context(), now.UnixMilli()); err != nil {
|
||||
c.log.Warn("could not purge expired OpenID tokens", "error", err)
|
||||
}
|
||||
if err := c.openID.InsertOpenIDToken(r.Context(), userapi.HashToken(token), auth.UserID,
|
||||
now.Add(openIDTokenLifetime).UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, openIDTokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
MatrixServerName: c.serverName,
|
||||
ExpiresIn: int64(openIDTokenLifetime.Seconds()),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type openIDUserInfoResponse struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// handleOpenIDUserInfo implements
|
||||
// GET /_matrix/federation/v1/openid/userinfo.
|
||||
func (c *ClientAPI) handleOpenIDUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
token := r.URL.Query().Get("access_token")
|
||||
if token == "" {
|
||||
return httputil.MissingToken()
|
||||
}
|
||||
userID, err := c.openID.OpenIDTokenUser(r.Context(), userapi.HashToken(token), c.now().UnixMilli())
|
||||
if errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
return httputil.UnknownToken(false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httputil.WriteJSON(w, http.StatusOK, openIDUserInfoResponse{Sub: userID})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package clientapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
// TestOpenIDToken is the exchange the LiveKit JWT service relies on: a user
|
||||
// asks for a token, hands it over, and the service asks this server who it
|
||||
// belongs to.
|
||||
func TestOpenIDToken(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
bob := ts.register("bob", "correct-horse-battery-staple")
|
||||
path := "/_matrix/client/v3/user/" + url.PathEscape(alice.UserID) + "/openid/request_token"
|
||||
|
||||
rec := ts.post(path, alice.AccessToken, map[string]any{})
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
body := jsonBody(t, rec)
|
||||
token, _ := body["access_token"].(string)
|
||||
if token == "" || body["token_type"] != "Bearer" || body["matrix_server_name"] != testServerName ||
|
||||
body["expires_in"] != float64(3600) {
|
||||
t.Fatalf("request_token = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// The token is no access token.
|
||||
expectError(t, ts.get("/_matrix/client/v3/account/whoami", token), http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
|
||||
|
||||
rec = ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), "")
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
if got := jsonBody(t, rec)["sub"]; got != alice.UserID {
|
||||
t.Errorf("sub = %v, want %s", got, alice.UserID)
|
||||
}
|
||||
|
||||
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token=nope", ""),
|
||||
http.StatusUnauthorized, httputil.ErrCodeUnknownToken)
|
||||
expectError(t, ts.get("/_matrix/federation/v1/openid/userinfo", ""),
|
||||
http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
|
||||
// Nobody gets a token in someone else's name.
|
||||
expectError(t, ts.post(path, bob.AccessToken, map[string]any{}), http.StatusForbidden, httputil.ErrCodeForbidden)
|
||||
}
|
||||
|
||||
// TestOpenIDUserInfoIgnoresFederationSwitch: the userinfo endpoint answers
|
||||
// with federation off, so a server that does not federate can still let its
|
||||
// users into group calls.
|
||||
func TestOpenIDUserInfoIgnoresFederationSwitch(t *testing.T) {
|
||||
ts := newTestServer(t, nil)
|
||||
if ts.settings.FederationEnabled() {
|
||||
t.Fatal("the test server is expected to start with federation off")
|
||||
}
|
||||
alice := ts.register("alice", "correct-horse-battery-staple")
|
||||
rec := ts.post("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/openid/request_token",
|
||||
alice.AccessToken, nil)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
token, _ := jsonBody(t, rec)["access_token"].(string)
|
||||
expectStatus(t, ts.get("/_matrix/federation/v1/openid/userinfo?access_token="+url.QueryEscape(token), ""), http.StatusOK)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
@@ -155,10 +156,20 @@ func (c *ClientAPI) handleSendEvent(w http.ResponseWriter, r *http.Request, auth
|
||||
return httputil.MissingParam("A transaction ID is required")
|
||||
}
|
||||
|
||||
delay, delayed, err := c.delayRequested(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var content sendEventRequest
|
||||
if err := httputil.DecodeJSON(w, r, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
if delayed {
|
||||
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
|
||||
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
|
||||
Type: eventType, Content: content, Delay: delay,
|
||||
})
|
||||
}
|
||||
|
||||
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
|
||||
RoomID: roomID,
|
||||
@@ -191,10 +202,20 @@ func (c *ClientAPI) handlePutState(w http.ResponseWriter, r *http.Request, auth
|
||||
}
|
||||
stateKey := r.PathValue("stateKey")
|
||||
|
||||
delay, delayed, err := c.delayRequested(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var content sendEventRequest
|
||||
if err := httputil.DecodeJSON(w, r, &content); err != nil {
|
||||
return err
|
||||
}
|
||||
if delayed {
|
||||
return c.scheduleDelayed(w, r, delayedevents.ScheduleRequest{
|
||||
UserID: auth.UserID, DeviceID: auth.DeviceID, RoomID: roomID,
|
||||
Type: eventType, StateKey: &stateKey, Content: content, Delay: delay,
|
||||
})
|
||||
}
|
||||
|
||||
eventID, err := c.rooms.SendEvent(r.Context(), roomserver.SendEventRequest{
|
||||
RoomID: roomID,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/userapi"
|
||||
)
|
||||
|
||||
// rtcTransportsPath is MSC4143's endpoint for the group call backends a
|
||||
// client may use: the same list /.well-known/matrix/client carries as
|
||||
// org.matrix.msc4143.rtc_foci, behind authentication and without needing
|
||||
// the discovery document.
|
||||
const rtcTransportsPath = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
|
||||
|
||||
// RTCTransport is one group call backend: a LiveKit SFU, named by the JWT
|
||||
// service that hands out tokens for it.
|
||||
type RTCTransport struct {
|
||||
Type string `json:"type"`
|
||||
LiveKitServiceURL string `json:"livekit_service_url"`
|
||||
}
|
||||
|
||||
// RTCTransports lists the configured group call backends, or nil for none.
|
||||
func RTCTransports(liveKitServiceURL string) []RTCTransport {
|
||||
if liveKitServiceURL == "" {
|
||||
return nil
|
||||
}
|
||||
return []RTCTransport{{Type: "livekit", LiveKitServiceURL: strings.TrimRight(liveKitServiceURL, "/")}}
|
||||
}
|
||||
|
||||
type rtcTransportsResponse struct {
|
||||
Transports []RTCTransport `json:"rtc_transports"`
|
||||
}
|
||||
|
||||
// registerRTCRoutes adds the MSC4143 endpoint, if a backend is configured.
|
||||
// Without one it is left unregistered: the 404 is how a client learns there
|
||||
// is none.
|
||||
func (c *ClientAPI) registerRTCRoutes(r *httputil.Router) {
|
||||
if len(c.rtcTransports) == 0 {
|
||||
return
|
||||
}
|
||||
c.authed(r, "GET "+rtcTransportsPath, c.limitDefault, c.handleRTCTransports)
|
||||
}
|
||||
|
||||
// handleRTCTransports implements
|
||||
// GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports.
|
||||
func (c *ClientAPI) handleRTCTransports(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
|
||||
httputil.WriteJSON(w, http.StatusOK, rtcTransportsResponse{Transports: c.rtcTransports})
|
||||
return nil
|
||||
}
|
||||
@@ -87,3 +87,27 @@ func TestTurnServerStatic(t *testing.T) {
|
||||
t.Errorf("response = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRTCTransports(t *testing.T) {
|
||||
const path = "/_matrix/client/unstable/org.matrix.msc4143/rtc/transports"
|
||||
ts := newTestServer(t, func(o *clientapi.Options) {
|
||||
o.RTC = config.RTC{LiveKitServiceURL: "https://livekit-jwt.warp.example/"}
|
||||
})
|
||||
acc := ts.register("alice", "correct horse battery")
|
||||
expectError(t, ts.get(path, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
|
||||
|
||||
rec := ts.get(path, acc.AccessToken)
|
||||
expectStatus(t, rec, http.StatusOK)
|
||||
got := decode[struct {
|
||||
Transports []map[string]string `json:"rtc_transports"`
|
||||
}](t, rec)
|
||||
if len(got.Transports) != 1 || got.Transports[0]["type"] != "livekit" ||
|
||||
got.Transports[0]["livekit_service_url"] != "https://livekit-jwt.warp.example" {
|
||||
t.Errorf("rtc_transports = %v", got.Transports)
|
||||
}
|
||||
|
||||
// Without a backend there is no endpoint, which is how a client knows.
|
||||
ts = newTestServer(t, nil)
|
||||
acc = ts.register("alice", "correct horse battery")
|
||||
expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_UNRECOGNIZED")
|
||||
}
|
||||
@@ -182,6 +182,39 @@ type ClientAPI struct {
|
||||
// TURN is what GET /voip/turnServer hands clients for voice and video
|
||||
// calls.
|
||||
TURN TURN `yaml:"turn"`
|
||||
|
||||
// RTC is the media backend for group calls (MatrixRTC), advertised in
|
||||
// /.well-known/matrix/client.
|
||||
RTC RTC `yaml:"rtc"`
|
||||
|
||||
// DelayedEvents bounds the delayed events (MSC4140) a client may
|
||||
// schedule. Group calls depend on them: a member's "I left" is
|
||||
// scheduled in advance, so a client that disappears still leaves.
|
||||
DelayedEvents DelayedEvents `yaml:"delayed_events"`
|
||||
}
|
||||
|
||||
// RTC configures group calls. Warp does not carry call media itself:
|
||||
// MatrixRTC clients publish to a LiveKit SFU, and get the token for it from
|
||||
// a LiveKit JWT service (lk-jwt-service) in exchange for an OpenID token
|
||||
// from this server. With no service URL no focus is advertised, and clients
|
||||
// that need one cannot start a group call.
|
||||
type RTC struct {
|
||||
// LiveKitServiceURL is the base URL of the LiveKit JWT service, e.g.
|
||||
// "https://livekit-jwt.example.org". It is advertised as an
|
||||
// org.matrix.msc4143.rtc_foci entry of type livekit.
|
||||
LiveKitServiceURL string `yaml:"livekit_service_url" env:"WARP_RTC_LIVEKIT_SERVICE_URL"`
|
||||
}
|
||||
|
||||
// DelayedEvents configures MSC4140 delayed events.
|
||||
type DelayedEvents struct {
|
||||
// MaxDelay is the longest delay a client may ask for. Zero turns
|
||||
// delayed events off: the delay parameter is refused and the feature
|
||||
// is not advertised.
|
||||
MaxDelay time.Duration `yaml:"max_delay" env:"WARP_DELAYED_EVENTS_MAX_DELAY"`
|
||||
|
||||
// MaxPerUser caps how many delayed events one user may have scheduled
|
||||
// at once.
|
||||
MaxPerUser int `yaml:"max_per_user" env:"WARP_DELAYED_EVENTS_MAX_PER_USER"`
|
||||
}
|
||||
|
||||
// TURN configures the relay servers clients use to set up calls. With no
|
||||
@@ -531,6 +564,10 @@ func Default() *Config {
|
||||
Media: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
|
||||
},
|
||||
TURN: TURN{UserLifetime: time.Hour},
|
||||
// A MatrixRTC client restarts its delayed leave every few
|
||||
// seconds; a day leaves room for other uses without letting a
|
||||
// forgotten event linger for weeks.
|
||||
DelayedEvents: DelayedEvents{MaxDelay: 24 * time.Hour, MaxPerUser: 100},
|
||||
},
|
||||
Sync: Sync{
|
||||
Presence: false,
|
||||
@@ -714,6 +751,17 @@ func (c *Config) Validate() error {
|
||||
if c.ClientAPI.TURN.SharedSecret != "" && c.ClientAPI.TURN.UserLifetime <= 0 {
|
||||
bad("client_api.turn.user_lifetime: must be positive")
|
||||
}
|
||||
if raw := c.ClientAPI.RTC.LiveKitServiceURL; raw != "" {
|
||||
if u, err := url.Parse(raw); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
bad("client_api.rtc.livekit_service_url: must be an http(s) URL, got %q", raw)
|
||||
}
|
||||
}
|
||||
if c.ClientAPI.DelayedEvents.MaxDelay < 0 {
|
||||
bad("client_api.delayed_events.max_delay: must not be negative")
|
||||
}
|
||||
if c.ClientAPI.DelayedEvents.MaxDelay > 0 && c.ClientAPI.DelayedEvents.MaxPerUser < 1 {
|
||||
bad("client_api.delayed_events.max_per_user: must be at least 1")
|
||||
}
|
||||
if c.Sync.TypingTimeout <= 0 {
|
||||
bad("sync.typing_timeout: must be positive")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// Package delayedevents implements MSC4140 delayed events: an event a client
|
||||
// hands the server now, to be sent later on the client's behalf unless the
|
||||
// client pushes it back or cancels it first.
|
||||
//
|
||||
// It is what makes MatrixRTC group calls robust. A member joining a call
|
||||
// sends its call.member state, and schedules an empty one to replace it a
|
||||
// few seconds out; while it is in the call it keeps restarting that delay.
|
||||
// A client that crashes, loses its network or is closed stops restarting
|
||||
// it, and the server sends the "left" for it.
|
||||
//
|
||||
// Events are kept in the database, so a restart of the server does not
|
||||
// lose them, and are sent by a single scheduler goroutine that sleeps until
|
||||
// the next one is due.
|
||||
package delayedevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Outcomes and reasons a finalised event is reported with, as MSC4140
|
||||
// names them.
|
||||
const (
|
||||
OutcomeSend = "send"
|
||||
OutcomeCancel = "cancel"
|
||||
|
||||
ReasonDelay = "delay"
|
||||
ReasonAction = "action"
|
||||
ReasonError = "error"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDisabled is returned when delayed events are switched off.
|
||||
ErrDisabled = errors.New("delayed events are disabled")
|
||||
|
||||
// ErrMaxDelayExceeded is returned for a delay longer than the
|
||||
// configured maximum.
|
||||
ErrMaxDelayExceeded = errors.New("the requested delay exceeds the allowed maximum")
|
||||
|
||||
// ErrTooMany is returned when the user already has as many delayed
|
||||
// events scheduled as they may.
|
||||
ErrTooMany = errors.New("too many delayed events are scheduled")
|
||||
|
||||
// ErrNoSuchDelayedEvent is returned for a delay ID that is unknown or
|
||||
// no longer scheduled.
|
||||
ErrNoSuchDelayedEvent = storage.ErrNoSuchDelayedEvent
|
||||
)
|
||||
|
||||
// finalisedRetention is how long a sent or cancelled event is kept for a
|
||||
// client to look up.
|
||||
const finalisedRetention = 24 * time.Hour
|
||||
|
||||
// idleCheck is how long the scheduler sleeps with nothing scheduled. It
|
||||
// also paces the purge of finalised events.
|
||||
const idleCheck = time.Hour
|
||||
|
||||
// dueBatch is how many due events are read at once.
|
||||
const dueBatch = 100
|
||||
|
||||
// Sender sends an event into a room as a user: the roomserver.
|
||||
type Sender interface {
|
||||
SendEvent(ctx context.Context, req roomserver.SendEventRequest) (string, error)
|
||||
}
|
||||
|
||||
// Options configure the service.
|
||||
type Options struct {
|
||||
Store storage.DelayedEventStore
|
||||
Rooms Sender
|
||||
|
||||
// MaxDelay is the longest delay allowed. Zero disables the service.
|
||||
MaxDelay time.Duration
|
||||
|
||||
// MaxPerUser caps the events one user may have scheduled.
|
||||
MaxPerUser int
|
||||
|
||||
// DescribeError turns the error a send failed with into the Matrix
|
||||
// error a client is shown. Nil reports every failure as M_UNKNOWN.
|
||||
DescribeError func(error) json.RawMessage
|
||||
|
||||
Logger *slog.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Service schedules and sends delayed events.
|
||||
type Service struct {
|
||||
store storage.DelayedEventStore
|
||||
rooms Sender
|
||||
maxDelay time.Duration
|
||||
maxPerUser int
|
||||
describeError func(error) json.RawMessage
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
// mu serialises everything that finalises or moves an event, so the
|
||||
// scheduler sending an event and its owner restarting or cancelling
|
||||
// it cannot both win.
|
||||
mu sync.Mutex
|
||||
|
||||
wake chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
started bool
|
||||
closing sync.Once
|
||||
|
||||
// cancels tracks the background cancellations the emit stage starts,
|
||||
// so Close can wait for them.
|
||||
cancels sync.WaitGroup
|
||||
}
|
||||
|
||||
// New builds the service. It does not start the scheduler.
|
||||
func New(opts Options) (*Service, error) {
|
||||
if opts.Store == nil {
|
||||
return nil, errors.New("delayedevents: Store must not be nil")
|
||||
}
|
||||
if opts.Rooms == nil {
|
||||
return nil, errors.New("delayedevents: Rooms must not be nil")
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.DescribeError == nil {
|
||||
opts.DescribeError = func(err error) json.RawMessage {
|
||||
raw, _ := json.Marshal(map[string]string{"errcode": "M_UNKNOWN", "error": err.Error()})
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return &Service{
|
||||
store: opts.Store,
|
||||
rooms: opts.Rooms,
|
||||
maxDelay: opts.MaxDelay,
|
||||
maxPerUser: opts.MaxPerUser,
|
||||
describeError: opts.DescribeError,
|
||||
log: opts.Logger.With("component", "delayedevents"),
|
||||
now: opts.Now,
|
||||
wake: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether clients may schedule delayed events.
|
||||
func (s *Service) Enabled() bool { return s != nil && s.maxDelay > 0 }
|
||||
|
||||
// MaxDelay is the longest delay a client may ask for.
|
||||
func (s *Service) MaxDelay() time.Duration { return s.maxDelay }
|
||||
|
||||
// ScheduleRequest is an event to send later.
|
||||
type ScheduleRequest struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
RoomID string
|
||||
Type string
|
||||
|
||||
// StateKey makes this a state event when it is non-nil.
|
||||
StateKey *string
|
||||
Content json.RawMessage
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
// Schedule stores an event to be sent after req.Delay and returns its
|
||||
// delay ID.
|
||||
func (s *Service) Schedule(ctx context.Context, req ScheduleRequest) (string, error) {
|
||||
if !s.Enabled() {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
if req.Delay < 0 {
|
||||
return "", fmt.Errorf("%w: the delay must not be negative", roomserver.ErrInvalidContent)
|
||||
}
|
||||
if req.Delay > s.maxDelay {
|
||||
return "", ErrMaxDelayExceeded
|
||||
}
|
||||
n, err := s.store.CountScheduledDelayedEvents(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n >= s.maxPerUser {
|
||||
return "", ErrTooMany
|
||||
}
|
||||
delayID, err := newDelayID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nowMS := s.now().UnixMilli()
|
||||
delayMS := req.Delay.Milliseconds()
|
||||
if err := s.store.InsertDelayedEvent(ctx, storage.DelayedEvent{
|
||||
DelayID: delayID,
|
||||
UserID: req.UserID,
|
||||
DeviceID: req.DeviceID,
|
||||
RoomID: req.RoomID,
|
||||
Type: req.Type,
|
||||
StateKey: req.StateKey,
|
||||
Content: req.Content,
|
||||
DelayMS: delayMS,
|
||||
RunningSinceMS: nowMS,
|
||||
SendAtMS: nowMS + delayMS,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.poke()
|
||||
return delayID, nil
|
||||
}
|
||||
|
||||
// Get returns one of a user's delayed events, scheduled or finalised.
|
||||
func (s *Service) Get(ctx context.Context, userID, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.store.DelayedEvent(ctx, userID, delayID)
|
||||
}
|
||||
|
||||
// List returns a user's delayed events.
|
||||
func (s *Service) List(ctx context.Context, userID string) ([]storage.DelayedEvent, error) {
|
||||
return s.store.DelayedEvents(ctx, userID)
|
||||
}
|
||||
|
||||
// Restart starts a scheduled event's delay again from now.
|
||||
//
|
||||
// The action endpoints may be called without an access token: the delay
|
||||
// ID is the capability. That is what lets a MatrixRTC client hand its
|
||||
// delayed leave to a service that keeps it alive on its behalf.
|
||||
func (s *Service) Restart(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.store.RestartDelayedEvent(ctx, delayID, s.now().UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.poke()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel drops a scheduled event without sending it.
|
||||
func (s *Service) Cancel(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.finalise(ctx, e, OutcomeCancel, ReasonAction, "", nil)
|
||||
}
|
||||
|
||||
// Send sends a scheduled event now, without waiting for its delay. The
|
||||
// error of a failed send is returned as well as recorded.
|
||||
func (s *Service) Send(ctx context.Context, delayID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendLocked(ctx, e, ReasonAction)
|
||||
}
|
||||
|
||||
// sendLocked sends e and records the outcome. s.mu must be held.
|
||||
func (s *Service) sendLocked(ctx context.Context, e storage.DelayedEvent, reason string) error {
|
||||
eventID, sendErr := s.rooms.SendEvent(ctx, roomserver.SendEventRequest{
|
||||
RoomID: e.RoomID,
|
||||
Sender: e.UserID,
|
||||
Type: e.Type,
|
||||
StateKey: e.StateKey,
|
||||
Content: e.Content,
|
||||
})
|
||||
if sendErr != nil {
|
||||
if err := s.finalise(ctx, e, OutcomeSend, ReasonError, "", s.describeError(sendErr)); err != nil {
|
||||
return err
|
||||
}
|
||||
return sendErr
|
||||
}
|
||||
return s.finalise(ctx, e, OutcomeSend, reason, eventID, nil)
|
||||
}
|
||||
|
||||
func (s *Service) finalise(ctx context.Context, e storage.DelayedEvent, outcome, reason, eventID string, errBody json.RawMessage) error {
|
||||
e.FinalisedAtMS = s.now().UnixMilli()
|
||||
e.Outcome = outcome
|
||||
e.Reason = reason
|
||||
e.EventID = eventID
|
||||
e.Error = errBody
|
||||
return s.store.FinaliseDelayedEvent(ctx, e)
|
||||
}
|
||||
|
||||
// EventPersisted implements roomserver.Notifier.
|
||||
//
|
||||
// When someone else sets a piece of state, the delayed state events that
|
||||
// would overwrite it are cancelled: a delayed event stands for "put this
|
||||
// back if I stop answering", and must not clobber a change another user
|
||||
// made in the meantime. The owner's own updates leave them alone, which is
|
||||
// what lets a call member refresh its membership while its delayed leave
|
||||
// keeps running.
|
||||
//
|
||||
// The emit stage must not block, so the lookup runs in the background.
|
||||
func (s *Service) EventPersisted(_ context.Context, n roomserver.Notification) {
|
||||
if !s.Enabled() || n.Event == nil || !n.StateChanged {
|
||||
return
|
||||
}
|
||||
key, isState := n.Event.StateKey()
|
||||
if !isState {
|
||||
return
|
||||
}
|
||||
roomID, eventType, sender := n.RoomID, n.Event.Type(), n.Event.Sender()
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.cancels.Add(1)
|
||||
go func() {
|
||||
defer s.cancels.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.cancelOverwritten(ctx, roomID, eventType, key, sender)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) cancelOverwritten(ctx context.Context, roomID, eventType, stateKey, sender string) {
|
||||
pending, err := s.store.ScheduledDelayedStateEvents(ctx, roomID, eventType, stateKey)
|
||||
if err != nil {
|
||||
s.log.Warn("could not look up delayed state events", "room_id", roomID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, e := range pending {
|
||||
if e.UserID == sender {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
err := s.finalise(ctx, e, OutcomeCancel, ReasonAction, "", nil)
|
||||
s.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
s.log.Warn("could not cancel an overwritten delayed event", "delay_id", e.DelayID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the scheduler until ctx is cancelled or Close is called.
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
if !s.Enabled() || s.started {
|
||||
return
|
||||
}
|
||||
s.started = true
|
||||
go s.run(ctx)
|
||||
}
|
||||
|
||||
// Close stops the scheduler and waits for it.
|
||||
func (s *Service) Close() {
|
||||
s.closing.Do(func() { close(s.stop) })
|
||||
if s.started {
|
||||
<-s.done
|
||||
}
|
||||
s.cancels.Wait()
|
||||
}
|
||||
|
||||
// poke wakes the scheduler to look at the schedule again.
|
||||
func (s *Service) poke() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) run(ctx context.Context) {
|
||||
defer close(s.done)
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
lastPurge := time.Time{}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-s.wake:
|
||||
}
|
||||
|
||||
s.SendDue(ctx)
|
||||
if now := s.now(); now.Sub(lastPurge) >= idleCheck {
|
||||
lastPurge = now
|
||||
if err := s.store.PurgeFinalisedDelayedEvents(ctx, now.Add(-finalisedRetention).UnixMilli()); err != nil {
|
||||
s.log.Warn("could not purge finalised delayed events", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
wait := idleCheck
|
||||
next, ok, err := s.store.NextDelayedEventDue(ctx)
|
||||
switch {
|
||||
case err != nil:
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.log.Warn("could not read the delayed event schedule", "error", err)
|
||||
wait = time.Second
|
||||
case ok:
|
||||
wait = min(max(time.Duration(next-s.now().UnixMilli())*time.Millisecond, 0), idleCheck)
|
||||
}
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// SendDue sends every event whose delay has run out. The scheduler calls
|
||||
// it whenever the next event comes due; tests call it after moving the
|
||||
// clock.
|
||||
func (s *Service) SendDue(ctx context.Context) {
|
||||
for {
|
||||
due, err := s.store.DueDelayedEvents(ctx, s.now().UnixMilli(), dueBatch)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
s.log.Warn("could not read due delayed events", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, e := range due {
|
||||
s.sendIfStillDue(ctx, e.DelayID)
|
||||
}
|
||||
if len(due) < dueBatch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendIfStillDue re-reads an event under the lock, because its owner may
|
||||
// have restarted or cancelled it since the batch was read.
|
||||
func (s *Service) sendIfStillDue(ctx context.Context, delayID string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, err := s.store.ScheduledDelayedEvent(ctx, delayID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if e.SendAtMS > s.now().UnixMilli() {
|
||||
return
|
||||
}
|
||||
if err := s.sendLocked(ctx, e, ReasonDelay); err != nil {
|
||||
s.log.Info("a delayed event could not be sent",
|
||||
"delay_id", e.DelayID, "room_id", e.RoomID, "type", e.Type, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newDelayID makes a delay ID. It is a capability — anyone holding it may
|
||||
// restart, cancel or send the event — so it is as long as an access token's
|
||||
// random part.
|
||||
func newDelayID() (string, error) {
|
||||
raw := make([]byte, 24)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate delay ID: %w", err)
|
||||
}
|
||||
return "warp_de_" + base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
@@ -81,6 +81,11 @@ type MatrixError struct {
|
||||
RemoteStatus int `json:"status,omitempty"`
|
||||
RemoteBody string `json:"body,omitempty"`
|
||||
|
||||
// DelayedErrCode and MaxDelayMS accompany a delayed event whose delay
|
||||
// is longer than the server allows (MSC4140).
|
||||
DelayedErrCode string `json:"org.matrix.msc4140.errcode,omitempty"`
|
||||
MaxDelayMS int64 `json:"org.matrix.msc4140.max_delay,omitempty"`
|
||||
|
||||
// status is the HTTP status to send; not part of the JSON body.
|
||||
status int
|
||||
}
|
||||
|
||||
@@ -432,6 +432,10 @@ func (r *API) powerLevelsContent(req CreateRoomRequest, preset string, rules roo
|
||||
"m.room.avatar": auth.DefaultStateDefault,
|
||||
TypeEncryption: 100,
|
||||
"m.room.server_acl": 100,
|
||||
// Joining a group call is a state event of the member's own
|
||||
// (MatrixRTC, MSC3401), so anyone who may talk in the room
|
||||
// may also join its call. Starting one is not a separate act.
|
||||
TypeCallMember: 0,
|
||||
},
|
||||
"notifications": map[string]any{"room": auth.DefaultNotification},
|
||||
}
|
||||
@@ -538,3 +542,7 @@ func mergeJSON(base, override json.RawMessage) (json.RawMessage, error) {
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
// TypeCallMember is a member's state in a room's group call (MatrixRTC).
|
||||
// It is still under its MSC3401 name, which is what clients send.
|
||||
const TypeCallMember = "org.matrix.msc3401.call.member"
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNoSuchDelayedEvent is returned for a delay ID that is unknown, belongs
|
||||
// to someone else, or is no longer scheduled.
|
||||
var ErrNoSuchDelayedEvent = errors.New("no such delayed event")
|
||||
|
||||
// DelayedEvent is an event a client asked to have sent later (MSC4140).
|
||||
//
|
||||
// While it is scheduled FinalisedAtMS is zero. Once it has been sent or
|
||||
// cancelled it is kept for a while with its outcome, so a client that comes
|
||||
// back can find out what became of it.
|
||||
type DelayedEvent struct {
|
||||
DelayID string
|
||||
UserID string
|
||||
DeviceID string
|
||||
RoomID string
|
||||
Type string
|
||||
|
||||
// StateKey is nil for a timeline event.
|
||||
StateKey *string
|
||||
Content json.RawMessage
|
||||
|
||||
// DelayMS is the delay the client asked for. A restart counts it again
|
||||
// from now.
|
||||
DelayMS int64
|
||||
|
||||
// RunningSinceMS is when the delay was last (re)started, and SendAtMS
|
||||
// when it runs out: RunningSinceMS + DelayMS.
|
||||
RunningSinceMS int64
|
||||
SendAtMS int64
|
||||
|
||||
// The outcome, once finalised: Outcome is "send" or "cancel", Reason
|
||||
// "delay", "action" or "error". EventID is the event that was sent,
|
||||
// and Error the Matrix error that stopped it.
|
||||
FinalisedAtMS int64
|
||||
Outcome string
|
||||
Reason string
|
||||
EventID string
|
||||
Error json.RawMessage
|
||||
}
|
||||
|
||||
// DelayedEventStore keeps the delayed events of every user.
|
||||
type DelayedEventStore interface {
|
||||
// InsertDelayedEvent schedules an event.
|
||||
InsertDelayedEvent(ctx context.Context, e DelayedEvent) error
|
||||
|
||||
// DelayedEvent returns one of userID's events, scheduled or finalised.
|
||||
DelayedEvent(ctx context.Context, userID, delayID string) (DelayedEvent, error)
|
||||
|
||||
// ScheduledDelayedEvent returns a scheduled event whoever owns it: the
|
||||
// action endpoints are addressed by the delay ID alone.
|
||||
ScheduledDelayedEvent(ctx context.Context, delayID string) (DelayedEvent, error)
|
||||
|
||||
// DelayedEvents lists userID's events, scheduled ones first by when
|
||||
// they are due, then finalised ones newest first.
|
||||
DelayedEvents(ctx context.Context, userID string) ([]DelayedEvent, error)
|
||||
|
||||
// CountScheduledDelayedEvents counts userID's scheduled events.
|
||||
CountScheduledDelayedEvents(ctx context.Context, userID string) (int, error)
|
||||
|
||||
// RestartDelayedEvent starts a scheduled event's delay again from nowMS.
|
||||
// It returns ErrNoSuchDelayedEvent if the event is not scheduled.
|
||||
RestartDelayedEvent(ctx context.Context, delayID string, nowMS int64) error
|
||||
|
||||
// FinaliseDelayedEvent records what became of a scheduled event. It
|
||||
// returns ErrNoSuchDelayedEvent if the event was already finalised,
|
||||
// which is how two racing finalisers are told apart: only one of them
|
||||
// gets to send.
|
||||
FinaliseDelayedEvent(ctx context.Context, e DelayedEvent) error
|
||||
|
||||
// DueDelayedEvents returns up to limit scheduled events whose delay
|
||||
// has run out at nowMS, the earliest first.
|
||||
DueDelayedEvents(ctx context.Context, nowMS int64, limit int) ([]DelayedEvent, error)
|
||||
|
||||
// NextDelayedEventDue reports when the next scheduled event is due,
|
||||
// and false when nothing is scheduled.
|
||||
NextDelayedEventDue(ctx context.Context) (int64, bool, error)
|
||||
|
||||
// ScheduledDelayedStateEvents lists the scheduled state events for one
|
||||
// piece of room state, of every user.
|
||||
ScheduledDelayedStateEvents(ctx context.Context, roomID, eventType, stateKey string) ([]DelayedEvent, error)
|
||||
|
||||
// PurgeFinalisedDelayedEvents forgets the events finalised before
|
||||
// beforeMS.
|
||||
PurgeFinalisedDelayedEvents(ctx context.Context, beforeMS int64) error
|
||||
}
|
||||
|
||||
// ErrNoSuchOpenIDToken is returned for an OpenID token that is unknown or
|
||||
// has expired.
|
||||
var ErrNoSuchOpenIDToken = errors.New("no such OpenID token")
|
||||
|
||||
// OpenIDTokenStore keeps the OpenID tokens users hand to third parties, so
|
||||
// those can find out who the user is (and nothing more) from this server.
|
||||
type OpenIDTokenStore interface {
|
||||
// InsertOpenIDToken stores a token by its hash.
|
||||
InsertOpenIDToken(ctx context.Context, tokenHash, userID string, expiresAtMS int64) error
|
||||
|
||||
// OpenIDTokenUser returns the user a token was issued to, or
|
||||
// ErrNoSuchOpenIDToken once it has expired at nowMS.
|
||||
OpenIDTokenUser(ctx context.Context, tokenHash string, nowMS int64) (string, error)
|
||||
|
||||
// PurgeOpenIDTokens forgets tokens that expired before beforeMS.
|
||||
PurgeOpenIDTokens(ctx context.Context, beforeMS int64) error
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// migratedDelayedDB opens a migrated database with empty delayed event and
|
||||
// OpenID tables.
|
||||
func migratedDelayedDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
db := openTestDB(t, driver)
|
||||
if err := db.Migrate(t.Context()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
if driver == storage.DriverPostgres {
|
||||
pool, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
defer func() { _ = pool.Close() }()
|
||||
if _, err := pool.ExecContext(t.Context(), `TRUNCATE delayed_events, openid_tokens`); err != nil {
|
||||
t.Fatalf("reset the tables: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDelayedEvents(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
store := migratedDelayedDB(t, driver).DelayedEvents()
|
||||
|
||||
if _, ok, err := store.NextDelayedEventDue(ctx); err != nil || ok {
|
||||
t.Fatalf("NextDelayedEventDue on an empty table = %v, %v", ok, err)
|
||||
}
|
||||
|
||||
key := "_@alice:warp.example_DEV_m.call"
|
||||
state := storage.DelayedEvent{
|
||||
DelayID: "d1", UserID: "@alice:warp.example", DeviceID: "DEV",
|
||||
RoomID: "!r:warp.example", Type: "org.matrix.msc3401.call.member",
|
||||
StateKey: &key, Content: []byte(`{}`),
|
||||
DelayMS: 5000, RunningSinceMS: 1000, SendAtMS: 6000,
|
||||
}
|
||||
timeline := storage.DelayedEvent{
|
||||
DelayID: "d2", UserID: "@alice:warp.example", RoomID: "!r:warp.example",
|
||||
Type: "m.room.message", Content: []byte(`{"body":"hi"}`),
|
||||
DelayMS: 1000, RunningSinceMS: 1000, SendAtMS: 2000,
|
||||
}
|
||||
for _, e := range []storage.DelayedEvent{state, timeline} {
|
||||
if err := store.InsertDelayedEvent(ctx, e); err != nil {
|
||||
t.Fatalf("InsertDelayedEvent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := store.CountScheduledDelayedEvents(ctx, "@alice:warp.example"); err != nil || n != 2 {
|
||||
t.Errorf("count = %d, %v", n, err)
|
||||
}
|
||||
if next, ok, err := store.NextDelayedEventDue(ctx); err != nil || !ok || next != 2000 {
|
||||
t.Errorf("next due = %d, %v, %v", next, ok, err)
|
||||
}
|
||||
|
||||
got, err := store.DelayedEvent(ctx, "@alice:warp.example", "d1")
|
||||
if err != nil || got.StateKey == nil || *got.StateKey != key || string(got.Content) != `{}` {
|
||||
t.Errorf("DelayedEvent = %+v, %v", got, err)
|
||||
}
|
||||
if got, err := store.DelayedEvent(ctx, "@alice:warp.example", "d2"); err != nil || got.StateKey != nil {
|
||||
t.Errorf("a timeline event has a state key: %+v, %v", got, err)
|
||||
}
|
||||
if _, err := store.DelayedEvent(ctx, "@bob:warp.example", "d1"); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("bob read alice's event: %v", err)
|
||||
}
|
||||
|
||||
due, err := store.DueDelayedEvents(ctx, 3000, 10)
|
||||
if err != nil || len(due) != 1 || due[0].DelayID != "d2" {
|
||||
t.Errorf("due at 3000 = %+v, %v", due, err)
|
||||
}
|
||||
|
||||
if err := store.RestartDelayedEvent(ctx, "d1", 4000); err != nil {
|
||||
t.Fatalf("RestartDelayedEvent: %v", err)
|
||||
}
|
||||
got, _ = store.ScheduledDelayedEvent(ctx, "d1")
|
||||
if got.RunningSinceMS != 4000 || got.SendAtMS != 9000 {
|
||||
t.Errorf("after restart = %+v", got)
|
||||
}
|
||||
|
||||
pending, err := store.ScheduledDelayedStateEvents(ctx, "!r:warp.example", "org.matrix.msc3401.call.member", key)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Errorf("ScheduledDelayedStateEvents = %+v, %v", pending, err)
|
||||
}
|
||||
|
||||
sent := timeline
|
||||
sent.FinalisedAtMS, sent.Outcome, sent.Reason, sent.EventID = 2500, "send", "delay", "$e"
|
||||
if err := store.FinaliseDelayedEvent(ctx, sent); err != nil {
|
||||
t.Fatalf("FinaliseDelayedEvent: %v", err)
|
||||
}
|
||||
if err := store.FinaliseDelayedEvent(ctx, sent); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("finalising twice = %v", err)
|
||||
}
|
||||
if err := store.RestartDelayedEvent(ctx, "d2", 3000); !errors.Is(err, storage.ErrNoSuchDelayedEvent) {
|
||||
t.Errorf("restarting a finalised event = %v", err)
|
||||
}
|
||||
|
||||
all, err := store.DelayedEvents(ctx, "@alice:warp.example")
|
||||
if err != nil || len(all) != 2 || all[0].DelayID != "d1" || all[1].EventID != "$e" {
|
||||
t.Errorf("DelayedEvents = %+v, %v", all, err)
|
||||
}
|
||||
|
||||
if err := store.PurgeFinalisedDelayedEvents(ctx, 3000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if all, _ := store.DelayedEvents(ctx, "@alice:warp.example"); len(all) != 1 {
|
||||
t.Errorf("after purge = %+v", all)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenIDTokens(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
store := migratedDelayedDB(t, driver).OpenIDTokens()
|
||||
|
||||
if err := store.InsertOpenIDToken(ctx, "hash", "@alice:warp.example", 5000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user, err := store.OpenIDTokenUser(ctx, "hash", 4000); err != nil || user != "@alice:warp.example" {
|
||||
t.Errorf("OpenIDTokenUser = %q, %v", user, err)
|
||||
}
|
||||
if _, err := store.OpenIDTokenUser(ctx, "hash", 6000); !errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
t.Errorf("an expired token = %v", err)
|
||||
}
|
||||
if err := store.PurgeOpenIDTokens(ctx, 6000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.OpenIDTokenUser(ctx, "hash", 0); !errors.Is(err, storage.ErrNoSuchOpenIDToken) {
|
||||
t.Errorf("a purged token = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Group calls: delayed events (MSC4140) and OpenID tokens.
|
||||
|
||||
-- An event a client asked to have sent later. A MatrixRTC client schedules
|
||||
-- its own "left the call" this way and keeps pushing it back while it is
|
||||
-- in the call, so a client that crashes or loses its connection still
|
||||
-- leaves.
|
||||
--
|
||||
-- state_key is NULL for a timeline event. finalised_at_ms is 0 while the
|
||||
-- event is scheduled; once it has been sent or cancelled the row is kept
|
||||
-- for a while with its outcome, so a client can find out what happened.
|
||||
CREATE TABLE IF NOT EXISTS delayed_events (
|
||||
delay_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
delay_ms BIGINT NOT NULL,
|
||||
running_since_ms BIGINT NOT NULL,
|
||||
send_at_ms BIGINT NOT NULL,
|
||||
finalised_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_user_idx
|
||||
ON delayed_events (user_id, finalised_at_ms);
|
||||
|
||||
-- The scheduler asks for what is due next among the scheduled events.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_due_idx
|
||||
ON delayed_events (finalised_at_ms, send_at_ms);
|
||||
|
||||
-- Someone else setting a piece of state cancels the delayed events that
|
||||
-- would have overwritten it.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_state_idx
|
||||
ON delayed_events (room_id, event_type, state_key, finalised_at_ms);
|
||||
|
||||
-- OpenID tokens: what a user hands a third party (the LiveKit JWT service)
|
||||
-- so it can ask this server who they are. Stored by hash, like access
|
||||
-- tokens.
|
||||
CREATE TABLE IF NOT EXISTS openid_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS openid_tokens_expiry_idx ON openid_tokens (expires_at_ms);
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Group calls: delayed events (MSC4140) and OpenID tokens.
|
||||
|
||||
-- An event a client asked to have sent later. A MatrixRTC client schedules
|
||||
-- its own "left the call" this way and keeps pushing it back while it is
|
||||
-- in the call, so a client that crashes or loses its connection still
|
||||
-- leaves.
|
||||
--
|
||||
-- state_key is NULL for a timeline event. finalised_at_ms is 0 while the
|
||||
-- event is scheduled; once it has been sent or cancelled the row is kept
|
||||
-- for a while with its outcome, so a client can find out what happened.
|
||||
CREATE TABLE IF NOT EXISTS delayed_events (
|
||||
delay_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
room_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
state_key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
delay_ms BIGINT NOT NULL,
|
||||
running_since_ms BIGINT NOT NULL,
|
||||
send_at_ms BIGINT NOT NULL,
|
||||
finalised_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_user_idx
|
||||
ON delayed_events (user_id, finalised_at_ms);
|
||||
|
||||
-- The scheduler asks for what is due next among the scheduled events.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_due_idx
|
||||
ON delayed_events (finalised_at_ms, send_at_ms);
|
||||
|
||||
-- Someone else setting a piece of state cancels the delayed events that
|
||||
-- would have overwritten it.
|
||||
CREATE INDEX IF NOT EXISTS delayed_events_state_idx
|
||||
ON delayed_events (room_id, event_type, state_key, finalised_at_ms);
|
||||
|
||||
-- OpenID tokens: what a user hands a third party (the LiveKit JWT service)
|
||||
-- so it can ask this server who they are. Stored by hash, like access
|
||||
-- tokens.
|
||||
CREATE TABLE IF NOT EXISTS openid_tokens (
|
||||
token_hash TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS openid_tokens_expiry_idx ON openid_tokens (expires_at_ms);
|
||||
@@ -0,0 +1,273 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// DelayedEvents is the shared implementation of storage.DelayedEventStore.
|
||||
type DelayedEvents struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
}
|
||||
|
||||
// NewDelayedEvents builds a delayed event store.
|
||||
func NewDelayedEvents(db *sql.DB, rebind func(string) string) *DelayedEvents {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
return &DelayedEvents{db: db, rebind: rebind}
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) q(query string) string { return s.rebind(query) }
|
||||
|
||||
const delayedEventColumns = `
|
||||
delay_id, user_id, device_id, room_id, event_type, state_key, content,
|
||||
delay_ms, running_since_ms, send_at_ms,
|
||||
finalised_at_ms, outcome, reason, event_id, error`
|
||||
|
||||
const (
|
||||
insertDelayedEventSQL = `
|
||||
INSERT INTO delayed_events (
|
||||
delay_id, user_id, device_id, room_id, event_type, state_key, content,
|
||||
delay_ms, running_since_ms, send_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
selectDelayedEventSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE user_id = ? AND delay_id = ?`
|
||||
|
||||
selectScheduledDelayedEventSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
selectUserDelayedEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE user_id = ?
|
||||
ORDER BY finalised_at_ms = 0 DESC, send_at_ms ASC, finalised_at_ms DESC`
|
||||
|
||||
countScheduledDelayedEventsSQL = `
|
||||
SELECT COUNT(*) FROM delayed_events WHERE user_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
restartDelayedEventSQL = `
|
||||
UPDATE delayed_events SET running_since_ms = ?, send_at_ms = ? + delay_ms
|
||||
WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
finaliseDelayedEventSQL = `
|
||||
UPDATE delayed_events
|
||||
SET finalised_at_ms = ?, outcome = ?, reason = ?, event_id = ?, error = ?
|
||||
WHERE delay_id = ? AND finalised_at_ms = 0`
|
||||
|
||||
selectDueDelayedEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events WHERE finalised_at_ms = 0 AND send_at_ms <= ?
|
||||
ORDER BY send_at_ms LIMIT ?`
|
||||
|
||||
selectNextDelayedEventDueSQL = `
|
||||
SELECT MIN(send_at_ms) FROM delayed_events WHERE finalised_at_ms = 0`
|
||||
|
||||
selectScheduledStateEventsSQL = `SELECT` + delayedEventColumns + `
|
||||
FROM delayed_events
|
||||
WHERE room_id = ? AND event_type = ? AND state_key = ? AND finalised_at_ms = 0`
|
||||
|
||||
purgeFinalisedDelayedEventsSQL = `
|
||||
DELETE FROM delayed_events WHERE finalised_at_ms <> 0 AND finalised_at_ms < ?`
|
||||
)
|
||||
|
||||
// InsertDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) InsertDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
||||
var stateKey sql.NullString
|
||||
if e.StateKey != nil {
|
||||
stateKey = sql.NullString{String: *e.StateKey, Valid: true}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, s.q(insertDelayedEventSQL),
|
||||
e.DelayID, e.UserID, e.DeviceID, e.RoomID, e.Type, stateKey, string(e.Content),
|
||||
e.DelayMS, e.RunningSinceMS, e.SendAtMS,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert delayed event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DelayedEvent(ctx context.Context, userID, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.one(ctx, selectDelayedEventSQL, userID, delayID)
|
||||
}
|
||||
|
||||
// ScheduledDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) ScheduledDelayedEvent(ctx context.Context, delayID string) (storage.DelayedEvent, error) {
|
||||
return s.one(ctx, selectScheduledDelayedEventSQL, delayID)
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) one(ctx context.Context, query string, args ...any) (storage.DelayedEvent, error) {
|
||||
events, err := s.list(ctx, query, args...)
|
||||
if err != nil {
|
||||
return storage.DelayedEvent{}, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return storage.DelayedEvent{}, storage.ErrNoSuchDelayedEvent
|
||||
}
|
||||
return events[0], nil
|
||||
}
|
||||
|
||||
// DelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DelayedEvents(ctx context.Context, userID string) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectUserDelayedEventsSQL, userID)
|
||||
}
|
||||
|
||||
// CountScheduledDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) CountScheduledDelayedEvents(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRowContext(ctx, s.q(countScheduledDelayedEventsSQL), userID).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("count delayed events: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RestartDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) RestartDelayedEvent(ctx context.Context, delayID string, nowMS int64) error {
|
||||
res, err := s.db.ExecContext(ctx, s.q(restartDelayedEventSQL), nowMS, nowMS, delayID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restart delayed event: %w", err)
|
||||
}
|
||||
return oneRow(res)
|
||||
}
|
||||
|
||||
// FinaliseDelayedEvent implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) FinaliseDelayedEvent(ctx context.Context, e storage.DelayedEvent) error {
|
||||
res, err := s.db.ExecContext(ctx, s.q(finaliseDelayedEventSQL),
|
||||
e.FinalisedAtMS, e.Outcome, e.Reason, e.EventID, string(e.Error), e.DelayID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finalise delayed event: %w", err)
|
||||
}
|
||||
return oneRow(res)
|
||||
}
|
||||
|
||||
func oneRow(res sql.Result) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delayed event: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return storage.ErrNoSuchDelayedEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) DueDelayedEvents(ctx context.Context, nowMS int64, limit int) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectDueDelayedEventsSQL, nowMS, limit)
|
||||
}
|
||||
|
||||
// NextDelayedEventDue implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) NextDelayedEventDue(ctx context.Context) (int64, bool, error) {
|
||||
var next sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx, s.q(selectNextDelayedEventDueSQL)).Scan(&next); err != nil {
|
||||
return 0, false, fmt.Errorf("next delayed event: %w", err)
|
||||
}
|
||||
return next.Int64, next.Valid, nil
|
||||
}
|
||||
|
||||
// ScheduledDelayedStateEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) ScheduledDelayedStateEvents(ctx context.Context, roomID, eventType, stateKey string) ([]storage.DelayedEvent, error) {
|
||||
return s.list(ctx, selectScheduledStateEventsSQL, roomID, eventType, stateKey)
|
||||
}
|
||||
|
||||
// PurgeFinalisedDelayedEvents implements storage.DelayedEventStore.
|
||||
func (s *DelayedEvents) PurgeFinalisedDelayedEvents(ctx context.Context, beforeMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q(purgeFinalisedDelayedEventsSQL), beforeMS); err != nil {
|
||||
return fmt.Errorf("purge delayed events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DelayedEvents) list(ctx context.Context, query string, args ...any) ([]storage.DelayedEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read delayed events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []storage.DelayedEvent
|
||||
for rows.Next() {
|
||||
var (
|
||||
e storage.DelayedEvent
|
||||
stateKey sql.NullString
|
||||
content, errorContent string
|
||||
)
|
||||
if err := rows.Scan(&e.DelayID, &e.UserID, &e.DeviceID, &e.RoomID, &e.Type, &stateKey, &content,
|
||||
&e.DelayMS, &e.RunningSinceMS, &e.SendAtMS,
|
||||
&e.FinalisedAtMS, &e.Outcome, &e.Reason, &e.EventID, &errorContent,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("read delayed event: %w", err)
|
||||
}
|
||||
if stateKey.Valid {
|
||||
e.StateKey = &stateKey.String
|
||||
}
|
||||
e.Content = []byte(content)
|
||||
if errorContent != "" {
|
||||
e.Error = []byte(errorContent)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OpenIDTokens is the shared implementation of storage.OpenIDTokenStore.
|
||||
type OpenIDTokens struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
}
|
||||
|
||||
// NewOpenIDTokens builds an OpenID token store.
|
||||
func NewOpenIDTokens(db *sql.DB, rebind func(string) string) *OpenIDTokens {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
return &OpenIDTokens{db: db, rebind: rebind}
|
||||
}
|
||||
|
||||
const (
|
||||
insertOpenIDTokenSQL = `
|
||||
INSERT INTO openid_tokens (token_hash, user_id, expires_at_ms) VALUES (?, ?, ?)`
|
||||
|
||||
selectOpenIDTokenSQL = `
|
||||
SELECT user_id FROM openid_tokens WHERE token_hash = ? AND expires_at_ms > ?`
|
||||
|
||||
purgeOpenIDTokensSQL = `DELETE FROM openid_tokens WHERE expires_at_ms < ?`
|
||||
)
|
||||
|
||||
// InsertOpenIDToken implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) InsertOpenIDToken(ctx context.Context, tokenHash, userID string, expiresAtMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.rebind(insertOpenIDTokenSQL), tokenHash, userID, expiresAtMS); err != nil {
|
||||
return fmt.Errorf("insert OpenID token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenIDTokenUser implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) OpenIDTokenUser(ctx context.Context, tokenHash string, nowMS int64) (string, error) {
|
||||
var userID string
|
||||
err := s.db.QueryRowContext(ctx, s.rebind(selectOpenIDTokenSQL), tokenHash, nowMS).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", storage.ErrNoSuchOpenIDToken
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read OpenID token: %w", err)
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// PurgeOpenIDTokens implements storage.OpenIDTokenStore.
|
||||
func (s *OpenIDTokens) PurgeOpenIDTokens(ctx context.Context, beforeMS int64) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.rebind(purgeOpenIDTokensSQL), beforeMS); err != nil {
|
||||
return fmt.Errorf("purge OpenID tokens: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time proof that the shared implementations satisfy the interfaces.
|
||||
var (
|
||||
_ storage.DelayedEventStore = (*DelayedEvents)(nil)
|
||||
_ storage.OpenIDTokenStore = (*OpenIDTokens)(nil)
|
||||
)
|
||||
@@ -69,6 +69,8 @@ type Store struct {
|
||||
push *Push
|
||||
admin *Admin
|
||||
apps *AppServices
|
||||
delayed *DelayedEvents
|
||||
openID *OpenIDTokens
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
@@ -93,6 +95,8 @@ func New(opts Options) (*Store, error) {
|
||||
push: NewPush(opts.DB, opts.Rebind),
|
||||
admin: NewAdmin(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
apps: NewAppServices(opts.DB, opts.Rebind),
|
||||
delayed: NewDelayedEvents(opts.DB, opts.Rebind),
|
||||
openID: NewOpenIDTokens(opts.DB, opts.Rebind),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -138,6 +142,12 @@ func (s *Store) Admin() storage.AdminStore { return s.admin }
|
||||
// AppServices implements storage.Database.
|
||||
func (s *Store) AppServices() storage.AppServiceStore { return s.apps }
|
||||
|
||||
// DelayedEvents implements storage.Database.
|
||||
func (s *Store) DelayedEvents() storage.DelayedEventStore { return s.delayed }
|
||||
|
||||
// OpenIDTokens implements storage.Database.
|
||||
func (s *Store) OpenIDTokens() storage.OpenIDTokenStore { return s.openID }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
|
||||
@@ -111,6 +111,14 @@ type Database interface {
|
||||
// stream.
|
||||
AppServices() AppServiceStore
|
||||
|
||||
// DelayedEvents is where the delayed events clients scheduled wait
|
||||
// to be sent (MSC4140).
|
||||
DelayedEvents() DelayedEventStore
|
||||
|
||||
// OpenIDTokens is where the OpenID tokens users hand to third parties
|
||||
// are kept.
|
||||
OpenIDTokens() OpenIDTokenStore
|
||||
|
||||
// Close releases the connection pool.
|
||||
Close() error
|
||||
}
|
||||
|
||||
+23
-3
@@ -103,9 +103,11 @@ client_api:
|
||||
# 0 means no expiry. WARP_REFRESH_TOKEN_LIFETIME
|
||||
refresh_token_lifetime: 0s
|
||||
|
||||
# Relay servers for voice and video calls, handed to clients by
|
||||
# GET /voip/turnServer. With no uris, calls only connect where the two
|
||||
# browsers can reach each other directly (the same network, mostly).
|
||||
# Relay servers for Matrix 1:1 VoIP, handed to clients by
|
||||
# GET /voip/turnServer. Warp's web client doesn't use them: its calls go
|
||||
# through LiveKit (rtc below), which needs no TURN of its own here. This
|
||||
# is for other Matrix clients that still place legacy 1:1 calls; with no
|
||||
# uris, theirs only connect where the two can reach each other directly.
|
||||
#
|
||||
# With shared_secret (coturn's `use-auth-secret` + `static-auth-secret`)
|
||||
# every user gets credentials that expire after user_lifetime. username
|
||||
@@ -120,6 +122,24 @@ client_api:
|
||||
username: "" # WARP_TURN_USERNAME
|
||||
password: "" # WARP_TURN_PASSWORD
|
||||
|
||||
# Group calls (MatrixRTC). Warp carries no call media itself: clients
|
||||
# publish to a LiveKit SFU and get a token for it from the LiveKit JWT
|
||||
# service (lk-jwt-service), which checks who they are with an OpenID
|
||||
# token from this server. Its URL is advertised to clients in
|
||||
# /.well-known/matrix/client; leave it empty for no group calls.
|
||||
# See docs/calls.md.
|
||||
rtc:
|
||||
livekit_service_url: "" # WARP_RTC_LIVEKIT_SERVICE_URL
|
||||
# e.g. "https://livekit-jwt.example.org"
|
||||
|
||||
# Delayed events (MSC4140): events a client schedules now for the server
|
||||
# to send later unless it pushes them back. A group call member's "left
|
||||
# the call" is one, so someone whose browser crashes still leaves.
|
||||
# max_delay 0 turns them off.
|
||||
delayed_events:
|
||||
max_delay: 24h # WARP_DELAYED_EVENTS_MAX_DELAY
|
||||
max_per_user: 100 # WARP_DELAYED_EVENTS_MAX_PER_USER
|
||||
|
||||
# Per-endpoint rate limiting. Each bucket allows `burst` requests back
|
||||
# to back and returns one slot every `interval`, per client address.
|
||||
# Login and registration get their own, tighter, buckets because they
|
||||
|
||||
Reference in new issue
Block a user