diff --git a/DESCRIPTION b/DESCRIPTION index 6e9d5ed..980d911 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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", diff --git a/NAMESPACE b/NAMESPACE index 31a8483..6c4963e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) @@ -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) @@ -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) diff --git a/NEWS.md b/NEWS.md index b261c01..22fbb53 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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 diff --git a/R/rtc.R b/R/rtc.R new file mode 100644 index 0000000..d4a1f32 --- /dev/null +++ b/R/rtc.R @@ -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 /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{":"}. +#' +#' @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:///.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) +} diff --git a/inst/tinytest/test_rtc.R b/inst/tinytest/test_rtc.R new file mode 100644 index 0000000..cd78437 --- /dev/null +++ b/inst/tinytest/test_rtc.R @@ -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"))) +}) diff --git a/man/mx_openid_token.Rd b/man/mx_openid_token.Rd new file mode 100644 index 0000000..e3a9a57 --- /dev/null +++ b/man/mx_openid_token.Rd @@ -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 +} +} diff --git a/man/mx_room_state.Rd b/man/mx_room_state.Rd new file mode 100644 index 0000000..9f759c9 --- /dev/null +++ b/man/mx_room_state.Rd @@ -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) +} +} diff --git a/man/mx_rtc_livekit_token.Rd b/man/mx_rtc_livekit_token.Rd new file mode 100644 index 0000000..5e32006 --- /dev/null +++ b/man/mx_rtc_livekit_token.Rd @@ -0,0 +1,36 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_rtc_livekit_token} +\alias{mx_rtc_livekit_token} +\title{Exchange an OpenID token for a LiveKit media token} +\usage{ +mx_rtc_livekit_token(service_url, room_id, openid_token, device_id) +} +\arguments{ +\item{service_url}{Character. Base URL of the JWT service, from +\code{\link{mx_rtc_transports}} or a call member's +\code{foci_preferred}.} + +\item{room_id}{Character. The Matrix room of the call.} + +\item{openid_token}{The list returned by \code{\link{mx_openid_token}}.} + +\item{device_id}{Character. This device's id.} +} +\value{ +A list with \code{url} (the LiveKit server) and \code{jwt}. +} +\description{ +Calls a LiveKit JWT service (\code{lk-jwt-service}) the way Element +Call and FluffyChat do: \code{POST /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{":"}. +} +\examples{ +\dontrun{ +tok <- mx_rtc_livekit_token("https://jwt.example", "!abc:example", + mx_openid_token(s), s$device_id) +tok$url +} +} diff --git a/man/mx_rtc_transports.Rd b/man/mx_rtc_transports.Rd new file mode 100644 index 0000000..7337169 --- /dev/null +++ b/man/mx_rtc_transports.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_rtc_transports} +\alias{mx_rtc_transports} +\title{List the homeserver's RTC transports} +\usage{ +mx_rtc_transports(session) +} +\arguments{ +\item{session}{An "mx_session" object.} +} +\value{ +A list of transports, each a list, or an empty list when the + server advertises none or does not implement the endpoint. +} +\description{ +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. +} +\examples{ +\dontrun{ +urls <- vapply(mx_rtc_transports(s), `[[`, "", "livekit_service_url") +} +} diff --git a/man/mx_well_known_client.Rd b/man/mx_well_known_client.Rd new file mode 100644 index 0000000..1c2985d --- /dev/null +++ b/man/mx_well_known_client.Rd @@ -0,0 +1,24 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_well_known_client} +\alias{mx_well_known_client} +\title{Read a server's client well-known file} +\usage{ +mx_well_known_client(server_name) +} +\arguments{ +\item{server_name}{Character. The server name part of a Matrix ID, +e.g. \code{"example.org"}, or a URL whose host is used.} +} +\value{ +The parsed document as a list, or NULL when there is none. +} +\description{ +Fetches \code{https:///.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}). +} +\examples{ +\dontrun{ +mx_well_known_client("example.org")$`m.homeserver`$base_url +} +}