Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.api
Type: Package
Title: Minimal Matrix Client-Server API
Version: 0.3.1
Date: 2026-09-10
Version: 0.3.1.1
Date: 2026-10-02
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
5 changes: 5 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export(mx_login)
export(mx_logout)
export(mx_media_config)
export(mx_messages)
export(mx_openid_token)
export(mx_profile)
export(mx_react)
export(mx_read_receipt)
Expand All @@ -27,8 +28,11 @@ export(mx_room_join)
export(mx_room_leave)
export(mx_room_members)
export(mx_room_name)
export(mx_room_state)
export(mx_room_topic)
export(mx_rooms)
export(mx_rtc_livekit_token)
export(mx_rtc_transports)
export(mx_send)
export(mx_send_audio)
export(mx_send_event)
Expand All @@ -45,4 +49,5 @@ export(mx_set_state)
export(mx_sync)
export(mx_typing)
export(mx_upload)
export(mx_well_known_client)
export(mx_whoami)
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# mx.api 0.3.1.1

* New: the endpoints a MatrixRTC client needs. `mx_openid_token()`
requests an OpenID token, `mx_rtc_transports()` reads the MSC4143
transport list, `mx_rtc_livekit_token()` exchanges the OpenID token at
a LiveKit JWT service for a media token, `mx_room_state()` returns a
room's full current state, and `mx_well_known_client()` reads a
server's client well-known file.

# mx.api 0.3.1

* Patch release collecting the room-creation and cross-signing transport
Expand Down
134 changes: 134 additions & 0 deletions R/rtc.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Endpoints a MatrixRTC client needs: the OpenID token it trades for a
# media-server credential, the server's list of RTC transports, a room's
# full state (to find call memberships), and the client well-known file.

#' Request an OpenID token for this user
#'
#' An OpenID token lets a third-party service confirm this user's
#' identity with the homeserver (via the federation
#' \code{openid/userinfo} endpoint) without seeing the access token.
#' MatrixRTC clients hand it to the LiveKit JWT service in exchange for a
#' room-scoped media token.
#'
#' @param session An "mx_session" object.
#' @return A list with \code{access_token}, \code{token_type},
#' \code{matrix_server_name} and \code{expires_in} (seconds), exactly as
#' the server returned them: a relying party expects the object whole.
#' @examples
#' \dontrun{
#' tok <- mx_openid_token(s)
#' tok$matrix_server_name
#' }
#' @export
mx_openid_token <- function(session) {
path <- sprintf("/_matrix/client/v3/user/%s/openid/request_token",
mx_encode_id(session$user_id))
mx_http(session$server, "POST", path, body = mx_empty_body(),
token = session$token)
}

#' List the homeserver's RTC transports
#'
#' Reads \code{GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports}
#' (MSC4143), where a homeserver advertises the media back ends its calls
#' use. For LiveKit each entry has \code{type} \code{"livekit"} and a
#' \code{livekit_service_url}, the JWT service that issues media tokens.
#'
#' @param session An "mx_session" object.
#' @return A list of transports, each a list, or an empty list when the
#' server advertises none or does not implement the endpoint.
#' @examples
#' \dontrun{
#' urls <- vapply(mx_rtc_transports(s), `[[`, "", "livekit_service_url")
#' }
#' @export
mx_rtc_transports <- function(session) {
resp <- tryCatch(
mx_http(session$server, "GET",
"/_matrix/client/unstable/org.matrix.msc4143/rtc/transports",
token = session$token),
mx_error_M_UNRECOGNIZED = function(e) NULL,
mx_error_M_NOT_FOUND = function(e) NULL)
resp$rtc_transports %||% list()
}

#' Exchange an OpenID token for a LiveKit media token
#'
#' Calls a LiveKit JWT service (\code{lk-jwt-service}) the way Element
#' Call and FluffyChat do: \code{POST <service_url>/sfu/get} with the
#' Matrix room, this user's OpenID token and device id. The service
#' verifies the token with the homeserver and answers with the SFU's
#' WebSocket URL and a room-scoped JWT whose LiveKit identity is
#' \code{"<user_id>:<device_id>"}.
#'
#' @param service_url Character. Base URL of the JWT service, from
#' \code{\link{mx_rtc_transports}} or a call member's
#' \code{foci_preferred}.
#' @param room_id Character. The Matrix room of the call.
#' @param openid_token The list returned by \code{\link{mx_openid_token}}.
#' @param device_id Character. This device's id.
#' @return A list with \code{url} (the LiveKit server) and \code{jwt}.
#' @examples
#' \dontrun{
#' tok <- mx_rtc_livekit_token("https://jwt.example", "!abc:example",
#' mx_openid_token(s), s$device_id)
#' tok$url
#' }
#' @export
mx_rtc_livekit_token <- function(service_url, room_id, openid_token,
device_id) {
resp <- mx_http(sub("/+$", "", service_url), "POST", "/sfu/get",
body = list(room = room_id, openid_token = openid_token,
device_id = device_id))
if (!is.character(resp$url) || !is.character(resp$jwt)) {
stop("the LiveKit JWT service at ", service_url,
" did not return url and jwt", call. = FALSE)
}
list(url = resp$url, jwt = resp$jwt)
}

#' Get the full current state of a room
#'
#' Returns every current state event of a room, one list per event with
#' \code{type}, \code{state_key}, \code{sender}, \code{content},
#' \code{origin_server_ts} and \code{event_id}. Unlike
#' \code{\link{mx_get_state}} this covers all state keys of a type, which
#' is how per-device state such as call memberships is found.
#'
#' @param session An "mx_session" object.
#' @param room_id Character. The room ID.
#' @return A list of state events.
#' @examples
#' \dontrun{
#' state <- mx_room_state(s, "!abc:example")
#' Filter(function(ev) ev$type == "m.room.member", state)
#' }
#' @export
mx_room_state <- function(session, room_id) {
path <- sprintf("/_matrix/client/v3/rooms/%s/state", mx_encode_id(room_id))
resp <- mx_http(session$server, "GET", path, token = session$token)
if (is.null(resp)) list() else resp
}

#' Read a server's client well-known file
#'
#' Fetches \code{https://<server_name>/.well-known/matrix/client}, the
#' discovery document that names the homeserver's base URL and, for some
#' deployments, its RTC foci (\code{org.matrix.msc4143.rtc_foci}).
#'
#' @param server_name Character. The server name part of a Matrix ID,
#' e.g. \code{"example.org"}, or a URL whose host is used.
#' @return The parsed document as a list, or NULL when there is none.
#' @examples
#' \dontrun{
#' mx_well_known_client("example.org")$`m.homeserver`$base_url
#' }
#' @export
mx_well_known_client <- function(server_name) {
host <- sub("^[a-z]+://", "", server_name)
host <- sub("/.*$", "", host)
tryCatch(
mx_http(paste0("https://", host), "GET", "/.well-known/matrix/client"),
mx_error = function(e) NULL,
error = function(e) NULL)
}
151 changes: 151 additions & 0 deletions inst/tinytest/test_rtc.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
library(tinytest)

# The RTC endpoints: request shapes and response handling, with mx_http
# replaced so no network is needed.

fake <- mx.api::mx_session(
server = "https://example",
token = "tok",
user_id = "@u:example",
device_id = "DEV"
)

with_http <- function(handler, code) {
ns <- asNamespace("mx.api")
original <- get("mx_http", envir = ns, inherits = FALSE)
assignInNamespace("mx_http", handler, ns = "mx.api")
on.exit(assignInNamespace("mx_http", original, ns = "mx.api"), add = TRUE)
force(code)
}

# OpenID token: POST with an empty JSON object, response passed through whole
local({
calls <- list()
token <- list(access_token = "oid", token_type = "Bearer",
matrix_server_name = "example", expires_in = 3600L)
out <- with_http(function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
calls[[length(calls) + 1L]] <<- list(method = method, path = path,
body = body, token = token)
list(access_token = "oid", token_type = "Bearer",
matrix_server_name = "example", expires_in = 3600L)
}, mx.api::mx_openid_token(fake))
expect_identical(out, token)
expect_identical(calls[[1]]$method, "POST")
expect_identical(calls[[1]]$path,
"/_matrix/client/v3/user/%40u%3Aexample/openid/request_token")
expect_identical(calls[[1]]$token, "tok")
expect_true(is.list(calls[[1]]$body))
expect_identical(length(calls[[1]]$body), 0L)
expect_identical(jsonlite::toJSON(calls[[1]]$body, auto_unbox = TRUE),
structure("{}", class = "json"))
})

# RTC transports: the list when present, empty when the server lacks it
local({
seen <- NULL
out <- with_http(function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
seen <<- list(method = method, path = path, token = token)
list(rtc_transports = list(list(type = "livekit",
livekit_service_url = "https://jwt.example")))
}, mx.api::mx_rtc_transports(fake))
expect_identical(seen$method, "GET")
expect_identical(seen$path,
"/_matrix/client/unstable/org.matrix.msc4143/rtc/transports")
expect_identical(seen$token, "tok")
expect_identical(out[[1]]$livekit_service_url, "https://jwt.example")

expect_identical(with_http(function(...) list(rtc_transports = list()),
mx.api::mx_rtc_transports(fake)), list())
unrecognized <- function(...) {
mx.api:::mx_raise("M_UNRECOGNIZED", "Not Found", status = 404L)
}
expect_identical(with_http(unrecognized, mx.api::mx_rtc_transports(fake)),
list())
not_found <- function(...) mx.api:::mx_raise("M_NOT_FOUND", "no", status = 404L)
expect_identical(with_http(not_found, mx.api::mx_rtc_transports(fake)),
list())
forbidden <- function(...) mx.api:::mx_raise("M_FORBIDDEN", "no", status = 403L)
expect_error(with_http(forbidden, mx.api::mx_rtc_transports(fake)),
"M_FORBIDDEN")
})

# LiveKit token exchange: POST /sfu/get at the service, body as the
# Element and FluffyChat clients send it, no Matrix token attached
local({
seen <- NULL
openid <- list(access_token = "oid", token_type = "Bearer",
matrix_server_name = "example", expires_in = 3600L)
out <- with_http(function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
seen <<- list(base_url = base_url, method = method, path = path,
body = body, token = token)
list(url = "wss://sfu.example", jwt = "eyJ.x.y")
}, mx.api::mx_rtc_livekit_token("https://jwt.example/", "!abc:example",
openid, "DEV"))
expect_identical(out, list(url = "wss://sfu.example", jwt = "eyJ.x.y"))
expect_identical(seen$base_url, "https://jwt.example")
expect_identical(seen$method, "POST")
expect_identical(seen$path, "/sfu/get")
expect_null(seen$token)
expect_identical(seen$body,
list(room = "!abc:example", openid_token = openid,
device_id = "DEV"))
expect_error(with_http(function(...) list(error = "nope"),
mx.api::mx_rtc_livekit_token("https://jwt.example",
"!abc:example", openid,
"DEV")),
"did not return url and jwt")
denied <- function(...) {
mx.api:::mx_raise("M_UNAUTHORIZED", "The request could not be authorised.",
status = 401L)
}
expect_error(with_http(denied, mx.api::mx_rtc_livekit_token(
"https://jwt.example", "!abc:example", openid, "DEV")), "M_UNAUTHORIZED")
})

# Full room state: GET on the room, events returned as a list
local({
seen <- NULL
events <- list(list(type = "m.room.member", state_key = "@u:example",
content = list(membership = "join")))
out <- with_http(function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
seen <<- list(method = method, path = path)
events
}, mx.api::mx_room_state(fake, "!abc:example"))
expect_identical(seen$method, "GET")
expect_identical(seen$path, "/_matrix/client/v3/rooms/%21abc%3Aexample/state")
expect_identical(out, events)
expect_identical(with_http(function(...) NULL,
mx.api::mx_room_state(fake, "!abc:example")),
list())
})

# Well-known: fetched from the server name over https, NULL when absent
local({
seen <- NULL
doc <- list("m.homeserver" = list(base_url = "https://hs.example"),
"org.matrix.msc4143.rtc_foci" = list(list(
type = "livekit", livekit_service_url = "https://jwt.example")))
out <- with_http(function(base_url, method, path, body = NULL,
query = NULL, token = NULL) {
seen <<- list(base_url = base_url, method = method, path = path,
token = token)
doc
}, mx.api::mx_well_known_client("example.org"))
expect_identical(out, doc)
expect_identical(seen$base_url, "https://example.org")
expect_identical(seen$path, "/.well-known/matrix/client")
expect_null(seen$token)

with_http(function(base_url, ...) { seen <<- base_url; NULL },
mx.api::mx_well_known_client("https://matrix.example.org/path"))
expect_identical(seen, "https://matrix.example.org")

not_found <- function(...) mx.api:::mx_raise("HTTP", "HTTP 404", status = 404L)
expect_null(with_http(not_found, mx.api::mx_well_known_client("example.org")))
expect_null(with_http(function(...) stop("Could not resolve host"),
mx.api::mx_well_known_client("nowhere.invalid")))
})
28 changes: 28 additions & 0 deletions man/mx_openid_token.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_openid_token}
\alias{mx_openid_token}
\title{Request an OpenID token for this user}
\usage{
mx_openid_token(session)
}
\arguments{
\item{session}{An "mx_session" object.}
}
\value{
A list with \code{access_token}, \code{token_type},
\code{matrix_server_name} and \code{expires_in} (seconds), exactly as
the server returned them: a relying party expects the object whole.
}
\description{
An OpenID token lets a third-party service confirm this user's
identity with the homeserver (via the federation
\code{openid/userinfo} endpoint) without seeing the access token.
MatrixRTC clients hand it to the LiveKit JWT service in exchange for a
room-scoped media token.
}
\examples{
\dontrun{
tok <- mx_openid_token(s)
tok$matrix_server_name
}
}
28 changes: 28 additions & 0 deletions man/mx_room_state.Rd
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
% tinyrox says don't edit this manually, but it can't stop you!
\name{mx_room_state}
\alias{mx_room_state}
\title{Get the full current state of a room}
\usage{
mx_room_state(session, room_id)
}
\arguments{
\item{session}{An "mx_session" object.}

\item{room_id}{Character. The room ID.}
}
\value{
A list of state events.
}
\description{
Returns every current state event of a room, one list per event with
\code{type}, \code{state_key}, \code{sender}, \code{content},
\code{origin_server_ts} and \code{event_id}. Unlike
\code{\link{mx_get_state}} this covers all state keys of a type, which
is how per-device state such as call memberships is found.
}
\examples{
\dontrun{
state <- mx_room_state(s, "!abc:example")
Filter(function(ev) ev$type == "m.room.member", state)
}
}
Loading
Loading