From 002634c5291b44cf21aaefc1df36c277a9e828ff Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 21 Aug 2026 12:17:45 -0500 Subject: [PATCH 1/2] mx_room_create(): pass creation_content through A space is an ordinary room with `type: "m.space"` in its m.room.create event. That field is fixed at creation and immutable afterwards, so there is no set-it-later path -- without a way to send creation_content, mx.api cannot make one at all. The new test captures the request body by stubbing mx_http, which is the only way to see the shape of this field without a server. It asserts the JSON, not the R value: mx_http auto-unboxes, so an unnamed list(...) of one string serializes to a bare "m.space" and the server answers with a perfectly ordinary room. The failure is a space-less space rather than an error, which is why an unnamed list is refused at the call instead. The man/ churn in mx_keys_upload, mx_read_receipt, mx_register and mx_send_media is tinyrox regenerating drift; no roxygen source changed for those. --- NEWS.md | 8 ++++ R/rooms.R | 23 +++++++++- inst/tinytest/test_room_create_body.R | 64 +++++++++++++++++++++++++++ man/mx_keys_upload.Rd | 8 +++- man/mx_read_receipt.Rd | 8 +++- man/mx_register.Rd | 10 ++++- man/mx_room_create.Rd | 22 ++++++++- man/mx_send_media.Rd | 51 ++++++++++++++++----- 8 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 inst/tinytest/test_room_create_body.R diff --git a/NEWS.md b/NEWS.md index 08837ba..120fb74 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# mx.api 0.3.0.1 + +* `mx_room_create()` gains `creation_content`, merged into the + `m.room.create` event. This is the only way to set properties fixed at + creation and immutable afterwards -- notably `type = "m.space"`, which + makes the room a space. A space cannot be converted from an ordinary + room later, so it has to be requested at creation or not at all. + # mx.api 0.3.0 * Generic room-event and state plumbing: `mx_send_event()` sends any diff --git a/R/rooms.R b/R/rooms.R index 0c88758..ebe54f3 100644 --- a/R/rooms.R +++ b/R/rooms.R @@ -27,16 +27,26 @@ mx_rooms <- function(session) { #' @param preset Character or NULL. A Matrix room preset #' ("private_chat", "trusted_private_chat", "public_chat"). #' @param invite Character vector. Matrix IDs to invite. +#' @param creation_content Named list or NULL. Merged into the +#' \code{m.room.create} event content. This is the only way to set +#' properties that are fixed at creation and immutable afterwards -- +#' notably \code{type = "m.space"}, which makes the room a space rather +#' than a conversation. A space cannot be converted from an ordinary +#' room later, so it has to be requested here or not at all. #' #' @return The new room ID as a character string. #' @examples #' \dontrun{ #' room_id <- mx_room_create(s, name = "test", topic = "hello") +#' +#' # A space, which holds other rooms instead of messages +#' space_id <- mx_room_create(s, name = "Topics", +#' creation_content = list(type = "m.space")) #' } #' @export mx_room_create <- function(session, name = NULL, topic = NULL, visibility = "private", preset = NULL, - invite = character()) { + invite = character(), creation_content = NULL) { body <- list(visibility = visibility) if (!is.null(name)) { body$name <- name @@ -50,6 +60,17 @@ mx_room_create <- function(session, name = NULL, topic = NULL, if (length(invite)) { body$invite <- as.list(invite) } + if (length(creation_content)) { + if (!is.list(creation_content) || + is.null(names(creation_content)) || + !all(nzchar(names(creation_content)))) { + stop("creation_content must be a fully named list", call. = FALSE) + } + # Sent as an object even with one entry: unnamed or auto-unboxed + # here would serialize to a bare string and the server would + # reject the create rather than quietly making a normal room. + body$creation_content <- creation_content + } resp <- mx_http( session$server, "POST", "/_matrix/client/v3/createRoom", diff --git a/inst/tinytest/test_room_create_body.R b/inst/tinytest/test_room_create_body.R new file mode 100644 index 0000000..5e04663 --- /dev/null +++ b/inst/tinytest/test_room_create_body.R @@ -0,0 +1,64 @@ +# What mx_room_create() actually puts on the wire. +# +# The existence checks in test_rooms.R cannot see this, and a live server +# is not needed to: the request body is built entirely in R. `mx_http` is +# stubbed and the body captured, which is the only way to assert the shape +# of a field whose wrong shape the server accepts. + +library(tinytest) + +ns <- asNamespace("mx.api") +capture_body <- function(expr) { + seen <- NULL + orig <- get("mx_http", envir = ns, inherits = FALSE) + assignInNamespace("mx_http", function(base_url, method, path, body = NULL, + query = NULL, token = NULL) { + seen <<- body + list(room_id = "!captured:example.org") + }, ns = "mx.api") + on.exit(assignInNamespace("mx_http", orig, ns = "mx.api"), add = TRUE) + force(expr) + seen +} + +s <- mx.api::mx_session("https://example.org", "tok", "@bot:example.org", "DEV") + +# ---- the default body is unchanged ---------------------------------- +# creation_content is absent, not empty. An empty object in m.room.create +# is a different request from no object at all. +b <- capture_body(mx.api::mx_room_create(s, name = "plain")) +expect_equal(b$name, "plain") +expect_equal(b$visibility, "private") +expect_null(b$creation_content) + +# ---- a space carries type through ----------------------------------- +b <- capture_body(mx.api::mx_room_create(s, name = "Topics", + creation_content = list(type = "m.space"))) +expect_equal(b$creation_content, list(type = "m.space")) + +# THE SHAPE, not just the value. `type = "m.space"` has to serialize as +# an object; mx_http auto-unboxes, and an unnamed list(...) of one string +# goes out as a bare "m.space" instead. The server's response to that is +# an ordinary room, so the failure is a working space-less space rather +# than an error. Asserted on the JSON because that is where it goes wrong. +json <- jsonlite::toJSON(b, auto_unbox = TRUE, null = "null") +expect_true(grepl('"creation_content":\\{"type":"m.space"\\}', json)) + +# ---- an unnamed list is refused rather than sent --------------------- +# This is the mistake the assertion above describes, caught at the call +# instead of becoming a room that looks fine and holds nothing. +expect_error(mx.api::mx_room_create(s, creation_content = list("m.space")), + "fully named") +expect_error(mx.api::mx_room_create(s, creation_content = list(type = "m.space", "x")), + "fully named") + +# ---- an empty creation_content is treated as absent ------------------ +b <- capture_body(mx.api::mx_room_create(s, creation_content = list())) +expect_null(b$creation_content) + +# ---- invite and creation_content coexist ---------------------------- +b <- capture_body(mx.api::mx_room_create(s, name = "Topics", + invite = c("@troy:example.org"), + creation_content = list(type = "m.space"))) +expect_equal(b$invite, list("@troy:example.org")) +expect_equal(b$creation_content, list(type = "m.space")) diff --git a/man/mx_keys_upload.Rd b/man/mx_keys_upload.Rd index 35a47be..ea6ca9a 100644 --- a/man/mx_keys_upload.Rd +++ b/man/mx_keys_upload.Rd @@ -3,8 +3,12 @@ \alias{mx_keys_upload} \title{Upload device identity and one-time keys} \usage{ -mx_keys_upload(session, device_keys = NULL, one_time_keys = NULL, - fallback_keys = NULL) +mx_keys_upload( + session, + device_keys = NULL, + one_time_keys = NULL, + fallback_keys = NULL +) } \arguments{ \item{session}{An \code{mx_session}.} diff --git a/man/mx_read_receipt.Rd b/man/mx_read_receipt.Rd index 76094d3..4392565 100644 --- a/man/mx_read_receipt.Rd +++ b/man/mx_read_receipt.Rd @@ -3,8 +3,12 @@ \alias{mx_read_receipt} \title{Send a read receipt for a room event} \usage{ -mx_read_receipt(session, room_id, event_id, - receipt_type = c("m.read", "m.read.private")) +mx_read_receipt( + session, + room_id, + event_id, + receipt_type = c("m.read", "m.read.private") +) } \arguments{ \item{session}{An "mx_session" object.} diff --git a/man/mx_register.Rd b/man/mx_register.Rd index 003c3ac..eed7052 100644 --- a/man/mx_register.Rd +++ b/man/mx_register.Rd @@ -3,8 +3,14 @@ \alias{mx_register} \title{Register a new account on a Matrix homeserver} \usage{ -mx_register(server, username, password, device_id = NULL, - initial_device_display_name = NULL, inhibit_login = FALSE) +mx_register( + server, + username, + password, + device_id = NULL, + initial_device_display_name = NULL, + inhibit_login = FALSE +) } \arguments{ \item{server}{Character. Homeserver base URL.} diff --git a/man/mx_room_create.Rd b/man/mx_room_create.Rd index dc02652..7828a6e 100644 --- a/man/mx_room_create.Rd +++ b/man/mx_room_create.Rd @@ -3,8 +3,15 @@ \alias{mx_room_create} \title{Create a room} \usage{ -mx_room_create(session, name = NULL, topic = NULL, visibility = "private", - preset = NULL, invite = character()) +mx_room_create( + session, + name = NULL, + topic = NULL, + visibility = "private", + preset = NULL, + invite = character(), + creation_content = NULL +) } \arguments{ \item{session}{An "mx_session" object.} @@ -19,6 +26,13 @@ mx_room_create(session, name = NULL, topic = NULL, visibility = "private", ("private_chat", "trusted_private_chat", "public_chat").} \item{invite}{Character vector. Matrix IDs to invite.} + +\item{creation_content}{Named list or NULL. Merged into the +\code{m.room.create} event content. This is the only way to set +properties that are fixed at creation and immutable afterwards -- +notably \code{type = "m.space"}, which makes the room a space rather +than a conversation. A space cannot be converted from an ordinary +room later, so it has to be requested here or not at all.} } \value{ The new room ID as a character string. @@ -29,5 +43,9 @@ Create a room \examples{ \dontrun{ room_id <- mx_room_create(s, name = "test", topic = "hello") + +# A space, which holds other rooms instead of messages +space_id <- mx_room_create(s, name = "Topics", + creation_content = list(type = "m.space")) } } diff --git a/man/mx_send_media.Rd b/man/mx_send_media.Rd index 5866f94..d63376b 100644 --- a/man/mx_send_media.Rd +++ b/man/mx_send_media.Rd @@ -7,20 +7,51 @@ \alias{mx_send_video} \title{Send a media file to a room} \usage{ -mx_send_media(session, room_id, path, body = basename(path), msgtype = NULL, - content_type = NULL, info = list()) +mx_send_media( + session, + room_id, + path, + body = basename(path), + msgtype = NULL, + content_type = NULL, + info = list() +) -mx_send_file(session, room_id, path, body = basename(path), - content_type = NULL, info = list()) +mx_send_file( + session, + room_id, + path, + body = basename(path), + content_type = NULL, + info = list() +) -mx_send_image(session, room_id, path, body = basename(path), - content_type = NULL, info = list()) +mx_send_image( + session, + room_id, + path, + body = basename(path), + content_type = NULL, + info = list() +) -mx_send_audio(session, room_id, path, body = basename(path), - content_type = NULL, info = list()) +mx_send_audio( + session, + room_id, + path, + body = basename(path), + content_type = NULL, + info = list() +) -mx_send_video(session, room_id, path, body = basename(path), - content_type = NULL, info = list()) +mx_send_video( + session, + room_id, + path, + body = basename(path), + content_type = NULL, + info = list() +) } \arguments{ \item{session}{An "mx_session" object.} From 5e446a73dfbcb4c103471b060a990c77b0c145d5 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 21 Aug 2026 12:17:45 -0500 Subject: [PATCH 2/2] Bump version to 0.3.0.1 --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 3130f3c..e36256b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.api Type: Package Title: Minimal Matrix Client-Server API -Version: 0.3.0 +Version: 0.3.0.1 Date: 2026-06-10 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"),