From c0e684c7967520c1fcc75c9ca7d517b8aed6bd1f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:18:19 -0500 Subject: [PATCH 1/9] Add mx_get_state() and generic account-data get/set mx_get_state() is the read-side counterpart of mx_set_state() (e.g. check m.room.encryption before joining the send path); both it and mx_get_account_data() return NULL on M_NOT_FOUND rather than erroring. Account data stays generic (type + content) -- no DM-semantics helpers. --- NAMESPACE | 3 ++ R/account.R | 58 ++++++++++++++++++++++++++++++ R/messages.R | 35 ++++++++++++++++++ inst/tinytest/test_state_account.R | 28 +++++++++++++++ man/mx_get_account_data.Rd | 27 ++++++++++++++ man/mx_get_state.Rd | 31 ++++++++++++++++ man/mx_set_account_data.Rd | 28 +++++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 R/account.R create mode 100644 inst/tinytest/test_state_account.R create mode 100644 man/mx_get_account_data.Rd create mode 100644 man/mx_get_state.Rd create mode 100644 man/mx_set_account_data.Rd diff --git a/NAMESPACE b/NAMESPACE index 6ee347d..8b29f60 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,8 @@ export(mx_canonical_json) export(mx_download) +export(mx_get_account_data) +export(mx_get_state) export(mx_keys_claim) export(mx_keys_query) export(mx_keys_upload) @@ -22,6 +24,7 @@ export(mx_send) export(mx_send_event) export(mx_send_to_device) export(mx_session) +export(mx_set_account_data) export(mx_set_state) export(mx_sync) export(mx_upload) diff --git a/R/account.R b/R/account.R new file mode 100644 index 0000000..c5a4b3b --- /dev/null +++ b/R/account.R @@ -0,0 +1,58 @@ +# Account data: per-user key-value storage on the homeserver. + +#' Get account data +#' +#' Reads a global account-data event for a user, e.g. \code{"m.direct"} +#' (the DM room map) or any custom namespaced type. +#' +#' @param session An "mx_session" object. +#' @param type Character. Event type, e.g. \code{"m.direct"}. +#' @param user_id Character. Defaults to the session's own user. +#' @return The account-data content as a list, or NULL when the type has +#' never been set. +#' @examples +#' \dontrun{ +#' direct <- mx_get_account_data(s, "m.direct") +#' } +#' @export +mx_get_account_data <- function(session, type, user_id = session$user_id) { + path <- sprintf( + "/_matrix/client/v3/user/%s/account_data/%s", + mx_encode_id(user_id), mx_encode_id(type) + ) + tryCatch( + mx_http(session$server, "GET", path, token = session$token), + error = function(e) { + if (grepl("M_NOT_FOUND", conditionMessage(e))) { + return(NULL) + } + stop(e) + } + ) +} + +#' Set account data +#' +#' Writes a global account-data event for a user. The content replaces +#' whatever was stored under \code{type}. +#' +#' @param session An "mx_session" object. +#' @param type Character. Event type, e.g. \code{"m.direct"}. +#' @param content List. The content, sent as-is. +#' @param user_id Character. Defaults to the session's own user. +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' mx_set_account_data(s, "ai.cornball.notes", list(theme = "dark")) +#' } +#' @export +mx_set_account_data <- function(session, type, content, + user_id = session$user_id) { + path <- sprintf( + "/_matrix/client/v3/user/%s/account_data/%s", + mx_encode_id(user_id), mx_encode_id(type) + ) + mx_http(session$server, "PUT", path, body = content, + token = session$token) + invisible(TRUE) +} diff --git a/R/messages.R b/R/messages.R index 1199f4d..9f0f113 100644 --- a/R/messages.R +++ b/R/messages.R @@ -99,6 +99,41 @@ mx_set_state <- function(session, room_id, event_type, content, resp$event_id } +#' Get a room state event +#' +#' Read-side counterpart of \code{\link{mx_set_state}}, e.g. to check +#' whether a room is encrypted before joining the send path. +#' +#' @param session An "mx_session" object. +#' @param room_id Character. The room ID. +#' @param event_type Character. State event type, e.g. +#' \code{"m.room.encryption"}. +#' @param state_key Character. State key (default empty string). +#' @return The state event content as a list, or NULL when the state +#' event is not set. +#' @examples +#' \dontrun{ +#' enc <- mx_get_state(s, "!abc:example", "m.room.encryption") +#' is.null(enc) # FALSE in an encrypted room +#' } +#' @export +mx_get_state <- function(session, room_id, event_type, state_key = "") { + path <- sprintf( + "/_matrix/client/v3/rooms/%s/state/%s/%s", + mx_encode_id(room_id), mx_encode_id(event_type), + mx_encode_id(state_key) + ) + tryCatch( + mx_http(session$server, "GET", path, token = session$token), + error = function(e) { + if (grepl("M_NOT_FOUND", conditionMessage(e))) { + return(NULL) + } + stop(e) + } + ) +} + #' Fetch historical messages from a room #' #' Thin wrapper over the /rooms/{id}/messages endpoint. diff --git a/inst/tinytest/test_state_account.R b/inst/tinytest/test_state_account.R new file mode 100644 index 0000000..8bdc165 --- /dev/null +++ b/inst/tinytest/test_state_account.R @@ -0,0 +1,28 @@ +library(tinytest) + +# Smoke: functions exist with the expected signatures. +expect_true(is.function(mx.api::mx_get_state)) +expect_true(is.function(mx.api::mx_set_state)) +expect_true(is.function(mx.api::mx_get_account_data)) +expect_true(is.function(mx.api::mx_set_account_data)) + +expect_equal( + names(formals(mx.api::mx_get_state)), + c("session", "room_id", "event_type", "state_key") +) +expect_equal( + names(formals(mx.api::mx_set_state)), + c("session", "room_id", "event_type", "content", "state_key") +) +expect_equal( + names(formals(mx.api::mx_get_account_data)), + c("session", "type", "user_id") +) +expect_equal( + names(formals(mx.api::mx_set_account_data)), + c("session", "type", "content", "user_id") +) + +if (at_home() && nzchar(Sys.getenv("MX_TEST_SERVER"))) { + # live get/set round-trips go here +} diff --git a/man/mx_get_account_data.Rd b/man/mx_get_account_data.Rd new file mode 100644 index 0000000..8f26fe6 --- /dev/null +++ b/man/mx_get_account_data.Rd @@ -0,0 +1,27 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_get_account_data} +\alias{mx_get_account_data} +\title{Get account data} +\usage{ +mx_get_account_data(session, type, user_id = session$user_id) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{type}{Character. Event type, e.g. \code{"m.direct"}.} + +\item{user_id}{Character. Defaults to the session's own user.} +} +\value{ +The account-data content as a list, or NULL when the type has + never been set. +} +\description{ +Reads a global account-data event for a user, e.g. \code{"m.direct"} +(the DM room map) or any custom namespaced type. +} +\examples{ +\dontrun{ +direct <- mx_get_account_data(s, "m.direct") +} +} diff --git a/man/mx_get_state.Rd b/man/mx_get_state.Rd new file mode 100644 index 0000000..cdd0f5e --- /dev/null +++ b/man/mx_get_state.Rd @@ -0,0 +1,31 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_get_state} +\alias{mx_get_state} +\title{Get a room state event} +\usage{ +mx_get_state(session, room_id, event_type, state_key = "") +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{room_id}{Character. The room ID.} + +\item{event_type}{Character. State event type, e.g. +\code{"m.room.encryption"}.} + +\item{state_key}{Character. State key (default empty string).} +} +\value{ +The state event content as a list, or NULL when the state + event is not set. +} +\description{ +Read-side counterpart of \code{\link{mx_set_state}}, e.g. to check +whether a room is encrypted before joining the send path. +} +\examples{ +\dontrun{ +enc <- mx_get_state(s, "!abc:example", "m.room.encryption") +is.null(enc) # FALSE in an encrypted room +} +} diff --git a/man/mx_set_account_data.Rd b/man/mx_set_account_data.Rd new file mode 100644 index 0000000..7b5d56c --- /dev/null +++ b/man/mx_set_account_data.Rd @@ -0,0 +1,28 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_set_account_data} +\alias{mx_set_account_data} +\title{Set account data} +\usage{ +mx_set_account_data(session, type, content, user_id = session$user_id) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{type}{Character. Event type, e.g. \code{"m.direct"}.} + +\item{content}{List. The content, sent as-is.} + +\item{user_id}{Character. Defaults to the session's own user.} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Writes a global account-data event for a user. The content replaces +whatever was stored under \code{type}. +} +\examples{ +\dontrun{ +mx_set_account_data(s, "ai.cornball.notes", list(theme = "dark")) +} +} From b24df3325977b49b05288a46614011aba62de73f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:18:59 -0500 Subject: [PATCH 2/9] Add mx_send_media() with file/image/audio/video wrappers Generic upload-then-post: mx_upload() to the media repo, then an m.room.message with url + info (mimetype, size). Sugar wrappers fix the msgtype. Richer metadata (dimensions, duration) is caller-supplied via info -- no media-inspection dependencies. --- NAMESPACE | 5 +++ R/media.R | 70 ++++++++++++++++++++++++++++++++++++++ inst/tinytest/test_media.R | 11 ++++++ man/mx_send_media.Rd | 55 ++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 man/mx_send_media.Rd diff --git a/NAMESPACE b/NAMESPACE index 8b29f60..b385d20 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -21,8 +21,13 @@ export(mx_room_name) export(mx_room_topic) export(mx_rooms) export(mx_send) +export(mx_send_audio) export(mx_send_event) +export(mx_send_file) +export(mx_send_image) +export(mx_send_media) export(mx_send_to_device) +export(mx_send_video) export(mx_session) export(mx_set_account_data) export(mx_set_state) diff --git a/R/media.R b/R/media.R index 3222d76..771367e 100644 --- a/R/media.R +++ b/R/media.R @@ -103,3 +103,73 @@ mx_guess_mime <- function(path) { unname(table[ext] %||% "application/octet-stream") } + +#' Send a media file to a room +#' +#' Uploads \code{path} to the media repository and posts an +#' \code{m.room.message} referencing it. The default \code{info} carries +#' \code{mimetype} and \code{size}; pass richer metadata (width, height, +#' duration) yourself -- mx.api deliberately does not inspect media files. +#' +#' @param session An "mx_session" object. +#' @param room_id Character. The room ID. +#' @param path Character. Path to the file to upload. +#' @param body Character. Message body / filename shown by clients. +#' @param msgtype Character. One of \code{"m.file"}, \code{"m.image"}, +#' \code{"m.audio"}, \code{"m.video"}. +#' @param content_type Character or NULL. MIME type (guessed from the +#' extension when NULL). +#' @param info List. Extra fields merged into the \code{info} object. +#' @return The event ID of the sent message. +#' @examples +#' \dontrun{ +#' mx_send_media(s, "!abc:example", "clip.mp4", msgtype = "m.video") +#' } +#' @export +mx_send_media <- function(session, room_id, path, body = basename(path), + msgtype = "m.file", content_type = NULL, + info = list()) { + if (is.null(content_type)) { + content_type <- mx_guess_mime(path) + } + uri <- mx_upload(session, path, content_type = content_type, + filename = basename(path)) + base_info <- list(mimetype = content_type, size = file.size(path)) + if (length(info)) { + base_info <- utils::modifyList(base_info, info) + } + mx_send(session, room_id, body, msgtype = msgtype, + extra = list(url = uri, info = base_info)) +} + +#' @rdname mx_send_media +#' @export +mx_send_file <- function(session, room_id, path, body = basename(path), + content_type = NULL, info = list()) { + mx_send_media(session, room_id, path, body = body, msgtype = "m.file", + content_type = content_type, info = info) +} + +#' @rdname mx_send_media +#' @export +mx_send_image <- function(session, room_id, path, body = basename(path), + content_type = NULL, info = list()) { + mx_send_media(session, room_id, path, body = body, msgtype = "m.image", + content_type = content_type, info = info) +} + +#' @rdname mx_send_media +#' @export +mx_send_audio <- function(session, room_id, path, body = basename(path), + content_type = NULL, info = list()) { + mx_send_media(session, room_id, path, body = body, msgtype = "m.audio", + content_type = content_type, info = info) +} + +#' @rdname mx_send_media +#' @export +mx_send_video <- function(session, room_id, path, body = basename(path), + content_type = NULL, info = list()) { + mx_send_media(session, room_id, path, body = body, msgtype = "m.video", + content_type = content_type, info = info) +} diff --git a/inst/tinytest/test_media.R b/inst/tinytest/test_media.R index 838c746..894e4de 100644 --- a/inst/tinytest/test_media.R +++ b/inst/tinytest/test_media.R @@ -6,3 +6,14 @@ expect_true(is.function(mx.api::mx_download)) if (at_home() && nzchar(Sys.getenv("MX_TEST_SERVER"))) { # live upload/download round-trip goes here } + +# Media message helpers: signatures + msgtype routing. +expect_true(is.function(mx.api::mx_send_media)) +expect_equal( + names(formals(mx.api::mx_send_media)), + c("session", "room_id", "path", "body", "msgtype", "content_type", "info") +) +for (fn in c("mx_send_file", "mx_send_image", "mx_send_audio", + "mx_send_video")) { + expect_true(is.function(getExportedValue("mx.api", fn))) +} diff --git a/man/mx_send_media.Rd b/man/mx_send_media.Rd new file mode 100644 index 0000000..103c3c5 --- /dev/null +++ b/man/mx_send_media.Rd @@ -0,0 +1,55 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_send_media} +\alias{mx_send_media} +\alias{mx_send_file} +\alias{mx_send_image} +\alias{mx_send_audio} +\alias{mx_send_video} +\title{Send a media file to a room} +\usage{ +mx_send_media(session, room_id, path, body = basename(path), + msgtype = "m.file", 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_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()) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{room_id}{Character. The room ID.} + +\item{path}{Character. Path to the file to upload.} + +\item{body}{Character. Message body / filename shown by clients.} + +\item{msgtype}{Character. One of \code{"m.file"}, \code{"m.image"}, +\code{"m.audio"}, \code{"m.video"}.} + +\item{content_type}{Character or NULL. MIME type (guessed from the +extension when NULL).} + +\item{info}{List. Extra fields merged into the \code{info} object.} +} +\value{ +The event ID of the sent message. +} +\description{ +Uploads \code{path} to the media repository and posts an +\code{m.room.message} referencing it. The default \code{info} carries +\code{mimetype} and \code{size}; pass richer metadata (width, height, +duration) yourself -- mx.api deliberately does not inspect media files. +} +\examples{ +\dontrun{ +mx_send_media(s, "!abc:example", "clip.mp4", msgtype = "m.video") +} +} From 69734b37ff574561fd54310247b5d0fc4c661a1c Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:19:39 -0500 Subject: [PATCH 3/9] Add profile get/set, mx_room_invite(), mx_redact(), mx_typing() mx_profile() reads displayname/avatar_url; mx_set_displayname() and mx_set_avatar_url() let a bot dress itself after mx_upload(). mx_room_invite() covers inviting into an existing room (creation-time invites already existed). mx_redact() is Matrix deletion, rounding out reactions. mx_typing() shows/clears the typing indicator while a slow reply generates. --- NAMESPACE | 6 +++ R/messages.R | 68 +++++++++++++++++++++++++++ R/profile.R | 59 +++++++++++++++++++++++ R/rooms.R | 25 ++++++++++ inst/tinytest/test_profile_room_ops.R | 23 +++++++++ man/mx_profile.Rd | 24 ++++++++++ man/mx_redact.Rd | 30 ++++++++++++ man/mx_room_invite.Rd | 27 +++++++++++ man/mx_set_avatar_url.Rd | 25 ++++++++++ man/mx_set_displayname.Rd | 23 +++++++++ man/mx_typing.Rd | 31 ++++++++++++ 11 files changed, 341 insertions(+) create mode 100644 R/profile.R create mode 100644 inst/tinytest/test_profile_room_ops.R create mode 100644 man/mx_profile.Rd create mode 100644 man/mx_redact.Rd create mode 100644 man/mx_room_invite.Rd create mode 100644 man/mx_set_avatar_url.Rd create mode 100644 man/mx_set_displayname.Rd create mode 100644 man/mx_typing.Rd diff --git a/NAMESPACE b/NAMESPACE index b385d20..4b25305 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -10,10 +10,13 @@ export(mx_keys_upload) export(mx_login) export(mx_logout) export(mx_messages) +export(mx_profile) export(mx_react) export(mx_read_receipt) +export(mx_redact) export(mx_register) export(mx_room_create) +export(mx_room_invite) export(mx_room_join) export(mx_room_leave) export(mx_room_members) @@ -30,7 +33,10 @@ export(mx_send_to_device) export(mx_send_video) export(mx_session) export(mx_set_account_data) +export(mx_set_avatar_url) +export(mx_set_displayname) export(mx_set_state) export(mx_sync) +export(mx_typing) export(mx_upload) export(mx_whoami) diff --git a/R/messages.R b/R/messages.R index 9f0f113..f4551ca 100644 --- a/R/messages.R +++ b/R/messages.R @@ -268,3 +268,71 @@ mx_sync <- function(session, since = NULL, timeout = 0L, filter = NULL) { ) } + +#' Redact an event +#' +#' Removes the content of a message, reaction, or other event. This is +#' how Matrix deletes things. +#' +#' @param session An "mx_session" object. +#' @param room_id Character. The room ID. +#' @param event_id Character. The event to redact. +#' @param reason Character or NULL. Optional human-readable reason. +#' @param txn_id Character or NULL. Transaction id (generated if NULL). +#' @return The event ID of the redaction event. +#' @examples +#' \dontrun{ +#' mx_redact(s, "!abc:example", "$someevent", reason = "typo") +#' } +#' @export +mx_redact <- function(session, room_id, event_id, reason = NULL, + txn_id = NULL) { + if (is.null(txn_id)) { + txn_id <- mx_txn_id() + } + body <- if (is.null(reason)) { + mx_empty_body() + } else { + list(reason = reason) + } + path <- sprintf( + "/_matrix/client/v3/rooms/%s/redact/%s/%s", + mx_encode_id(room_id), mx_encode_id(event_id), + mx_encode_id(txn_id) + ) + resp <- mx_http(session$server, "PUT", path, body = body, + token = session$token) + resp$event_id +} + +#' Send a typing notification +#' +#' Shows (or clears) the session user's typing indicator in a room. +#' Useful bot polish while a slow reply is being generated. +#' +#' @param session An "mx_session" object. +#' @param room_id Character. The room ID. +#' @param typing Logical. TRUE to show typing, FALSE to clear it. +#' @param timeout Integer. How long the indicator lasts, in +#' milliseconds (ignored when \code{typing = FALSE}). +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' mx_typing(s, "!abc:example", TRUE) +#' # ... generate the reply ... +#' mx_typing(s, "!abc:example", FALSE) +#' } +#' @export +mx_typing <- function(session, room_id, typing = TRUE, timeout = 30000L) { + body <- list(typing = isTRUE(typing)) + if (isTRUE(typing)) { + body$timeout <- as.integer(timeout) + } + path <- sprintf( + "/_matrix/client/v3/rooms/%s/typing/%s", + mx_encode_id(room_id), mx_encode_id(session$user_id) + ) + mx_http(session$server, "PUT", path, body = body, + token = session$token) + invisible(TRUE) +} diff --git a/R/profile.R b/R/profile.R new file mode 100644 index 0000000..9c028fd --- /dev/null +++ b/R/profile.R @@ -0,0 +1,59 @@ +# User profile: displayname and avatar. + +#' Get a user's profile +#' +#' @param session An "mx_session" object. +#' @param user_id Character. Defaults to the session's own user. +#' @return A list with \code{displayname} and \code{avatar_url} (either +#' may be absent when unset). +#' @examples +#' \dontrun{ +#' mx_profile(s)$displayname +#' } +#' @export +mx_profile <- function(session, user_id = session$user_id) { + path <- sprintf("/_matrix/client/v3/profile/%s", mx_encode_id(user_id)) + mx_http(session$server, "GET", path, token = session$token) +} + +#' Set this user's display name +#' +#' @param session An "mx_session" object. +#' @param displayname Character. The new display name. +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' mx_set_displayname(s, "cornelius") +#' } +#' @export +mx_set_displayname <- function(session, displayname) { + path <- sprintf( + "/_matrix/client/v3/profile/%s/displayname", + mx_encode_id(session$user_id) + ) + mx_http(session$server, "PUT", path, + body = list(displayname = displayname), token = session$token) + invisible(TRUE) +} + +#' Set this user's avatar +#' +#' @param session An "mx_session" object. +#' @param avatar_url Character. An \code{mxc://} URI, typically from +#' \code{\link{mx_upload}}. +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' uri <- mx_upload(s, "avatar.png") +#' mx_set_avatar_url(s, uri) +#' } +#' @export +mx_set_avatar_url <- function(session, avatar_url) { + path <- sprintf( + "/_matrix/client/v3/profile/%s/avatar_url", + mx_encode_id(session$user_id) + ) + mx_http(session$server, "PUT", path, + body = list(avatar_url = avatar_url), token = session$token) + invisible(TRUE) +} diff --git a/R/rooms.R b/R/rooms.R index 7990b9a..0c88758 100644 --- a/R/rooms.R +++ b/R/rooms.R @@ -172,3 +172,28 @@ mx_room_topic <- function(session, room_id) { resp$topic } + +#' Invite a user to a room +#' +#' Invitation at creation time is covered by \code{mx_room_create()}; +#' this covers the other common lifecycle case, inviting into an +#' existing room. +#' +#' @param session An "mx_session" object. +#' @param room_id Character. The room ID. +#' @param user_id Character. The Matrix ID to invite. +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' mx_room_invite(s, "!abc:example", "@friend:example.org") +#' } +#' @export +mx_room_invite <- function(session, room_id, user_id) { + path <- sprintf( + "/_matrix/client/v3/rooms/%s/invite", + mx_encode_id(room_id) + ) + mx_http(session$server, "POST", path, + body = list(user_id = user_id), token = session$token) + invisible(TRUE) +} diff --git a/inst/tinytest/test_profile_room_ops.R b/inst/tinytest/test_profile_room_ops.R new file mode 100644 index 0000000..11a5d38 --- /dev/null +++ b/inst/tinytest/test_profile_room_ops.R @@ -0,0 +1,23 @@ +library(tinytest) + +# Smoke: profile, invite, redact, typing signatures. +expect_true(is.function(mx.api::mx_profile)) +expect_true(is.function(mx.api::mx_set_displayname)) +expect_true(is.function(mx.api::mx_set_avatar_url)) +expect_true(is.function(mx.api::mx_room_invite)) +expect_true(is.function(mx.api::mx_redact)) +expect_true(is.function(mx.api::mx_typing)) + +expect_equal(names(formals(mx.api::mx_profile)), c("session", "user_id")) +expect_equal( + names(formals(mx.api::mx_redact)), + c("session", "room_id", "event_id", "reason", "txn_id") +) +expect_equal( + names(formals(mx.api::mx_typing)), + c("session", "room_id", "typing", "timeout") +) +expect_equal( + names(formals(mx.api::mx_room_invite)), + c("session", "room_id", "user_id") +) diff --git a/man/mx_profile.Rd b/man/mx_profile.Rd new file mode 100644 index 0000000..ca63546 --- /dev/null +++ b/man/mx_profile.Rd @@ -0,0 +1,24 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_profile} +\alias{mx_profile} +\title{Get a user's profile} +\usage{ +mx_profile(session, user_id = session$user_id) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{user_id}{Character. Defaults to the session's own user.} +} +\value{ +A list with \code{displayname} and \code{avatar_url} (either + may be absent when unset). +} +\description{ +Get a user's profile +} +\examples{ +\dontrun{ +mx_profile(s)$displayname +} +} diff --git a/man/mx_redact.Rd b/man/mx_redact.Rd new file mode 100644 index 0000000..a0d8e7e --- /dev/null +++ b/man/mx_redact.Rd @@ -0,0 +1,30 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_redact} +\alias{mx_redact} +\title{Redact an event} +\usage{ +mx_redact(session, room_id, event_id, reason = NULL, txn_id = NULL) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{room_id}{Character. The room ID.} + +\item{event_id}{Character. The event to redact.} + +\item{reason}{Character or NULL. Optional human-readable reason.} + +\item{txn_id}{Character or NULL. Transaction id (generated if NULL).} +} +\value{ +The event ID of the redaction event. +} +\description{ +Removes the content of a message, reaction, or other event. This is +how Matrix deletes things. +} +\examples{ +\dontrun{ +mx_redact(s, "!abc:example", "$someevent", reason = "typo") +} +} diff --git a/man/mx_room_invite.Rd b/man/mx_room_invite.Rd new file mode 100644 index 0000000..eb0a050 --- /dev/null +++ b/man/mx_room_invite.Rd @@ -0,0 +1,27 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_room_invite} +\alias{mx_room_invite} +\title{Invite a user to a room} +\usage{ +mx_room_invite(session, room_id, user_id) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{room_id}{Character. The room ID.} + +\item{user_id}{Character. The Matrix ID to invite.} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Invitation at creation time is covered by \code{mx_room_create()}; +this covers the other common lifecycle case, inviting into an +existing room. +} +\examples{ +\dontrun{ +mx_room_invite(s, "!abc:example", "@friend:example.org") +} +} diff --git a/man/mx_set_avatar_url.Rd b/man/mx_set_avatar_url.Rd new file mode 100644 index 0000000..1d8000e --- /dev/null +++ b/man/mx_set_avatar_url.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_set_avatar_url} +\alias{mx_set_avatar_url} +\title{Set this user's avatar} +\usage{ +mx_set_avatar_url(session, avatar_url) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{avatar_url}{Character. An \code{mxc://} URI, typically from +\code{\link{mx_upload}}.} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Set this user's avatar +} +\examples{ +\dontrun{ +uri <- mx_upload(s, "avatar.png") +mx_set_avatar_url(s, uri) +} +} diff --git a/man/mx_set_displayname.Rd b/man/mx_set_displayname.Rd new file mode 100644 index 0000000..33490eb --- /dev/null +++ b/man/mx_set_displayname.Rd @@ -0,0 +1,23 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_set_displayname} +\alias{mx_set_displayname} +\title{Set this user's display name} +\usage{ +mx_set_displayname(session, displayname) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{displayname}{Character. The new display name.} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Set this user's display name +} +\examples{ +\dontrun{ +mx_set_displayname(s, "cornelius") +} +} diff --git a/man/mx_typing.Rd b/man/mx_typing.Rd new file mode 100644 index 0000000..eb67ab5 --- /dev/null +++ b/man/mx_typing.Rd @@ -0,0 +1,31 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_typing} +\alias{mx_typing} +\title{Send a typing notification} +\usage{ +mx_typing(session, room_id, typing = TRUE, timeout = 30000L) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{room_id}{Character. The room ID.} + +\item{typing}{Logical. TRUE to show typing, FALSE to clear it.} + +\item{timeout}{Integer. How long the indicator lasts, in +milliseconds (ignored when \code{typing = FALSE}).} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Shows (or clears) the session user's typing indicator in a room. +Useful bot polish while a slow reply is being generated. +} +\examples{ +\dontrun{ +mx_typing(s, "!abc:example", TRUE) +# ... generate the reply ... +mx_typing(s, "!abc:example", FALSE) +} +} From f137f0a93631cf9d1a6b74e3033e49066fe7eab6 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:22:59 -0500 Subject: [PATCH 4/9] Add mx_devices() and mx_delete_device() Listing is a plain GET. Deletion passes any user-interactive auth payload through verbatim and documents the 401-then-retry dance; mx.api deliberately does not automate the password exchange (that needs stored credentials, which is client-layer state). --- NAMESPACE | 2 ++ R/devices.R | 55 ++++++++++++++++++++++++++++++++++++ inst/tinytest/test_devices.R | 9 ++++++ man/mx_delete_device.Rd | 35 +++++++++++++++++++++++ man/mx_devices.Rd | 22 +++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 R/devices.R create mode 100644 inst/tinytest/test_devices.R create mode 100644 man/mx_delete_device.Rd create mode 100644 man/mx_devices.Rd diff --git a/NAMESPACE b/NAMESPACE index 4b25305..ad456b1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,8 @@ # tinyrox says don't edit this manually, but it can't stop you! export(mx_canonical_json) +export(mx_delete_device) +export(mx_devices) export(mx_download) export(mx_get_account_data) export(mx_get_state) diff --git a/R/devices.R b/R/devices.R new file mode 100644 index 0000000..318bb3b --- /dev/null +++ b/R/devices.R @@ -0,0 +1,55 @@ +# Device management. + +#' List this account's devices +#' +#' @param session An "mx_session" object. +#' @return A list of devices, each with \code{device_id}, +#' \code{display_name}, \code{last_seen_ip}, and \code{last_seen_ts}. +#' @examples +#' \dontrun{ +#' vapply(mx_devices(s), function(d) d$device_id, character(1)) +#' } +#' @export +mx_devices <- function(session) { + resp <- mx_http(session$server, "GET", "/_matrix/client/v3/devices", + token = session$token) + resp$devices %||% list() +} + +#' Delete a device +#' +#' Removes a device and invalidates its access token. Most homeservers +#' protect this with user-interactive authentication: the first call +#' fails with M_FORBIDDEN or a 401 carrying a \code{flows} object, and +#' the caller retries with a completed \code{auth} payload, e.g. +#' \code{list(type = "m.login.password", identifier = list(type = +#' "m.id.user", user = "bot"), password = "...", session = "")}. mx.api deliberately does not automate that exchange. +#' +#' @param session An "mx_session" object. +#' @param device_id Character. The device to delete. +#' @param auth List or NULL. Completed user-interactive auth payload. +#' @return Invisibly TRUE on success. +#' @examples +#' \dontrun{ +#' mx_delete_device(s, "OLDDEVICE", auth = list( +#' type = "m.login.password", +#' identifier = list(type = "m.id.user", user = "bot"), +#' password = "secret" +#' )) +#' } +#' @export +mx_delete_device <- function(session, device_id, auth = NULL) { + body <- if (is.null(auth)) { + mx_empty_body() + } else { + list(auth = auth) + } + path <- sprintf( + "/_matrix/client/v3/devices/%s", + mx_encode_id(device_id) + ) + mx_http(session$server, "DELETE", path, body = body, + token = session$token) + invisible(TRUE) +} diff --git a/inst/tinytest/test_devices.R b/inst/tinytest/test_devices.R new file mode 100644 index 0000000..fdcf3a4 --- /dev/null +++ b/inst/tinytest/test_devices.R @@ -0,0 +1,9 @@ +library(tinytest) + +expect_true(is.function(mx.api::mx_devices)) +expect_true(is.function(mx.api::mx_delete_device)) +expect_equal(names(formals(mx.api::mx_devices)), "session") +expect_equal( + names(formals(mx.api::mx_delete_device)), + c("session", "device_id", "auth") +) diff --git a/man/mx_delete_device.Rd b/man/mx_delete_device.Rd new file mode 100644 index 0000000..8708f0c --- /dev/null +++ b/man/mx_delete_device.Rd @@ -0,0 +1,35 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_delete_device} +\alias{mx_delete_device} +\title{Delete a device} +\usage{ +mx_delete_device(session, device_id, auth = NULL) +} +\arguments{ +\item{session}{An "mx_session" object.} + +\item{device_id}{Character. The device to delete.} + +\item{auth}{List or NULL. Completed user-interactive auth payload.} +} +\value{ +Invisibly TRUE on success. +} +\description{ +Removes a device and invalidates its access token. Most homeservers +protect this with user-interactive authentication: the first call +fails with M_FORBIDDEN or a 401 carrying a \code{flows} object, and +the caller retries with a completed \code{auth} payload, e.g. +\code{list(type = "m.login.password", identifier = list(type = +"m.id.user", user = "bot"), password = "...", session = "")}. mx.api deliberately does not automate that exchange. +} +\examples{ +\dontrun{ +mx_delete_device(s, "OLDDEVICE", auth = list( + type = "m.login.password", + identifier = list(type = "m.id.user", user = "bot"), + password = "secret" +)) +} +} diff --git a/man/mx_devices.Rd b/man/mx_devices.Rd new file mode 100644 index 0000000..150500d --- /dev/null +++ b/man/mx_devices.Rd @@ -0,0 +1,22 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_devices} +\alias{mx_devices} +\title{List this account's devices} +\usage{ +mx_devices(session) +} +\arguments{ +\item{session}{An "mx_session" object.} +} +\value{ +A list of devices, each with \code{device_id}, + \code{display_name}, \code{last_seen_ip}, and \code{last_seen_ts}. +} +\description{ +List this account's devices +} +\examples{ +\dontrun{ +vapply(mx_devices(s), function(d) d$device_id, character(1)) +} +} From d65aaa409806ae3b37834b823511905a4b2596ba Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:30:46 -0500 Subject: [PATCH 5/9] README + NEWS for 0.3.0; bump version to 0.3.0 README: refresh the stale dev-version references (was still describing the pre-0.2.0 delta), extend the coverage table with the new endpoint families, and point the E2EE framing at mx.client as the stateful integration layer. NEWS: 0.3.0 entry covering the full batch, including mx_send_event()/mx_set_state() from #13 which never got an entry. --- DESCRIPTION | 2 +- NEWS.md | 21 +++++++++++++++++++++ README.md | 37 +++++++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 4ad549a..03ef64e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.api Type: Package Title: Minimal Matrix Client-Server API -Version: 0.2.0.1 +Version: 0.3.0 Date: 2026-05-13 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 5c200e7..cca92cc 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,24 @@ +# mx.api 0.3.0 + +* Generic room-event and state plumbing: `mx_send_event()` sends any + event type (e.g. `m.room.encrypted`), `mx_set_state()` / + `mx_get_state()` write and read state events (e.g. + `m.room.encryption`). Together these are what an external + end-to-end-encryption layer (such as 'mx.client') needs. +* Media messages: `mx_send_media()` uploads a file and posts the + referencing `m.room.message`; `mx_send_file()`, `mx_send_image()`, + `mx_send_audio()`, and `mx_send_video()` fix the msgtype. Metadata + beyond mimetype and size is caller-supplied; mx.api does not inspect + media files. +* Bot lifecycle endpoints: `mx_room_invite()` (invite into an existing + room), `mx_redact()` (Matrix deletion), `mx_typing()` (typing + indicator), and profile helpers `mx_profile()`, + `mx_set_displayname()`, `mx_set_avatar_url()`. +* Account data: `mx_get_account_data()` / `mx_set_account_data()`, + kept generic (no DM-semantics helpers). +* Devices: `mx_devices()` lists; `mx_delete_device()` deletes, passing + any user-interactive auth payload through verbatim. + # mx.api 0.2.0 * New transport endpoints for end-to-end-encryption coordination: diff --git a/README.md b/README.md index fcefc9d..12dcaa1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ which handles Olm + Megolm; mx.api itself does no cryptography. # CRAN install.packages("mx.api") -# GitHub (development version, 0.1.0.1) +# GitHub (development version) remotes::install_github("cornball-ai/mx.api") ``` @@ -92,9 +92,13 @@ Code blocks use `
`; inline code is `` | Area | Functions | |---|---| | Session | `mx_register`, `mx_login`, `mx_logout`, `mx_whoami`, `mx_session` | -| Rooms | `mx_rooms`, `mx_room_create`, `mx_room_join`, `mx_room_leave`, `mx_room_members`, `mx_room_name`, `mx_room_topic` | -| Messages | `mx_send`, `mx_messages`, `mx_sync`, `mx_react`, `mx_read_receipt` | -| Media | `mx_upload`, `mx_download` | +| Rooms | `mx_rooms`, `mx_room_create`, `mx_room_join`, `mx_room_leave`, `mx_room_invite`, `mx_room_members`, `mx_room_name`, `mx_room_topic` | +| Messages | `mx_send`, `mx_send_event`, `mx_messages`, `mx_sync`, `mx_react`, `mx_redact`, `mx_read_receipt`, `mx_typing` | +| Room state | `mx_get_state`, `mx_set_state` | +| Media | `mx_upload`, `mx_download`, `mx_send_media` (+ `mx_send_file` / `mx_send_image` / `mx_send_audio` / `mx_send_video`) | +| Profile | `mx_profile`, `mx_set_displayname`, `mx_set_avatar_url` | +| Account data | `mx_get_account_data`, `mx_set_account_data` | +| Devices | `mx_devices`, `mx_delete_device` | | E2EE transport | `mx_keys_upload`, `mx_keys_query`, `mx_keys_claim`, `mx_send_to_device` | | E2EE signing helper | `mx_canonical_json` | @@ -103,10 +107,11 @@ End-to-end **cryptography** is out of scope; pair with `mx.crypto` endpoints carry. Helpful framing: - mx.api speaks Matrix HTTP. It does no signing, no key management, - no key validation. + no key validation, and holds no state between calls. - mx.crypto speaks Olm + Megolm. It does no HTTP. -- An integration script that wants encrypted rooms uses both. The - current reference is `mx.crypto/inst/integration/e2e_demo.R`. +- [mx.client](https://github.com/cornball-ai/mx.client) is the stateful + layer that uses both: config persistence, sync cursors, and E2EE + orchestration (its `vignette("e2ee")` walks the whole flow). ## Canonical JSON @@ -129,13 +134,17 @@ mx_canonical_json(1.5) ## Status -**0.1.0.1** dev marker on `main` (2026-05-13). The 0.1.0 release is on -CRAN. The 0.1.0.1 delta is additive: - -- New transport endpoints for E2EE coordination: - `mx_keys_upload`, `mx_keys_query`, `mx_keys_claim`, - `mx_send_to_device`. -- New `mx_canonical_json` for signature payload encoding. +**0.3.0** on `main`. The 0.2.0 release is on CRAN. The 0.3.0 delta is +additive: + +- Generic event and state plumbing: `mx_send_event`, `mx_set_state`, + `mx_get_state` (needed for `m.room.encrypted` / `m.room.encryption`). +- Media messages: `mx_send_media` and the file/image/audio/video + wrappers. +- Bot lifecycle: `mx_room_invite`, `mx_redact`, `mx_typing`, + `mx_profile` / `mx_set_displayname` / `mx_set_avatar_url`. +- Account data: `mx_get_account_data`, `mx_set_account_data`. +- Devices: `mx_devices`, `mx_delete_device`. See `NEWS.md` for the full changelog. From be6feacf65f2700fdd7107f4a761c787fb419c48 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:54:51 -0500 Subject: [PATCH 6/9] Media polish: msgtype auto-detect, public mx_guess_mime(), mx_media_config() mx_send_media(msgtype = NULL) now derives m.image/m.audio/m.video from the MIME family, so pointing it at a file just works. mx_guess_mime() is exported so callers don't maintain their own extension table. mx_media_config() asks the server for its upload cap (m.upload.size), v1 endpoint with legacy fallback. --- NAMESPACE | 2 ++ R/media.R | 65 ++++++++++++++++++++++++++++++++++++-- inst/tinytest/test_media.R | 9 ++++++ man/mx_guess_mime.Rd | 22 +++++++++++++ man/mx_media_config.Rd | 26 +++++++++++++++ man/mx_send_media.Rd | 10 +++--- 6 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 man/mx_guess_mime.Rd create mode 100644 man/mx_media_config.Rd diff --git a/NAMESPACE b/NAMESPACE index ad456b1..9d59b04 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,11 +6,13 @@ export(mx_devices) export(mx_download) export(mx_get_account_data) export(mx_get_state) +export(mx_guess_mime) export(mx_keys_claim) export(mx_keys_query) export(mx_keys_upload) export(mx_login) export(mx_logout) +export(mx_media_config) export(mx_messages) export(mx_profile) export(mx_react) diff --git a/R/media.R b/R/media.R index 771367e..c2bac52 100644 --- a/R/media.R +++ b/R/media.R @@ -87,6 +87,18 @@ mx_download <- function(session, mxc_url, dest) { invisible(dest) } +#' Guess a MIME type from a file extension +#' +#' The extension table mx.api uses for uploads, exported so callers do +#' not maintain their own. Unknown extensions fall back to +#' \code{"application/octet-stream"}. +#' +#' @param path Character. File path or name. +#' @return Character MIME type. +#' @examples +#' mx_guess_mime("clip.mp4") +#' mx_guess_mime("notes.txt") +#' @export mx_guess_mime <- function(path) { ext <- tolower(tools::file_ext(path)) table <- c( @@ -115,8 +127,10 @@ mx_guess_mime <- function(path) { #' @param room_id Character. The room ID. #' @param path Character. Path to the file to upload. #' @param body Character. Message body / filename shown by clients. -#' @param msgtype Character. One of \code{"m.file"}, \code{"m.image"}, -#' \code{"m.audio"}, \code{"m.video"}. +#' @param msgtype Character or NULL. One of \code{"m.file"}, +#' \code{"m.image"}, \code{"m.audio"}, \code{"m.video"}. NULL (the +#' default) derives it from the MIME type, so a .mp4 posts as m.video +#' without being told. #' @param content_type Character or NULL. MIME type (guessed from the #' extension when NULL). #' @param info List. Extra fields merged into the \code{info} object. @@ -127,11 +141,14 @@ mx_guess_mime <- function(path) { #' } #' @export mx_send_media <- function(session, room_id, path, body = basename(path), - msgtype = "m.file", content_type = NULL, + msgtype = NULL, content_type = NULL, info = list()) { if (is.null(content_type)) { content_type <- mx_guess_mime(path) } + if (is.null(msgtype)) { + msgtype <- mx_msgtype_for_mime(content_type) + } uri <- mx_upload(session, path, content_type = content_type, filename = basename(path)) base_info <- list(mimetype = content_type, size = file.size(path)) @@ -173,3 +190,45 @@ mx_send_video <- function(session, room_id, path, body = basename(path), mx_send_media(session, room_id, path, body = body, msgtype = "m.video", content_type = content_type, info = info) } + +# m.image / m.audio / m.video from the MIME family; m.file otherwise. +mx_msgtype_for_mime <- function(content_type) { + if (startsWith(content_type, "image/")) { + return("m.image") + } + if (startsWith(content_type, "audio/")) { + return("m.audio") + } + if (startsWith(content_type, "video/")) { + return("m.video") + } + "m.file" +} + +#' Query the homeserver's media configuration +#' +#' Asks the server for its media limits, chiefly \code{m.upload.size} +#' (maximum upload bytes), so callers can check a file fits before +#' uploading. Tries the v1 endpoint and falls back to the legacy +#' location for older homeservers. +#' +#' @param session An "mx_session" object. +#' @return A list; \code{$`m.upload.size`} is the upload cap in bytes +#' (may be absent if the server does not advertise one). +#' @examples +#' \dontrun{ +#' cap <- mx_media_config(s)$`m.upload.size` +#' file.size("clip.mp4") <= cap +#' } +#' @export +mx_media_config <- function(session) { + tryCatch( + mx_http(session$server, "GET", + "/_matrix/client/v1/media/config", + token = session$token), + error = function(e) { + mx_http(session$server, "GET", "/_matrix/media/v3/config", + token = session$token) + } + ) +} diff --git a/inst/tinytest/test_media.R b/inst/tinytest/test_media.R index 894e4de..6de626b 100644 --- a/inst/tinytest/test_media.R +++ b/inst/tinytest/test_media.R @@ -17,3 +17,12 @@ for (fn in c("mx_send_file", "mx_send_image", "mx_send_audio", "mx_send_video")) { expect_true(is.function(getExportedValue("mx.api", fn))) } + +# msgtype auto-detection + public mime guesser + media config. +expect_equal(mx.api:::mx_msgtype_for_mime("video/mp4"), "m.video") +expect_equal(mx.api:::mx_msgtype_for_mime("audio/ogg"), "m.audio") +expect_equal(mx.api:::mx_msgtype_for_mime("image/png"), "m.image") +expect_equal(mx.api:::mx_msgtype_for_mime("application/pdf"), "m.file") +expect_equal(mx.api::mx_guess_mime("clip.MP4"), "video/mp4") +expect_equal(mx.api::mx_guess_mime("unknown.xyz"), "application/octet-stream") +expect_true(is.function(mx.api::mx_media_config)) diff --git a/man/mx_guess_mime.Rd b/man/mx_guess_mime.Rd new file mode 100644 index 0000000..91a0c56 --- /dev/null +++ b/man/mx_guess_mime.Rd @@ -0,0 +1,22 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_guess_mime} +\alias{mx_guess_mime} +\title{Guess a MIME type from a file extension} +\usage{ +mx_guess_mime(path) +} +\arguments{ +\item{path}{Character. File path or name.} +} +\value{ +Character MIME type. +} +\description{ +The extension table mx.api uses for uploads, exported so callers do +not maintain their own. Unknown extensions fall back to +\code{"application/octet-stream"}. +} +\examples{ +mx_guess_mime("clip.mp4") +mx_guess_mime("notes.txt") +} diff --git a/man/mx_media_config.Rd b/man/mx_media_config.Rd new file mode 100644 index 0000000..eccf52b --- /dev/null +++ b/man/mx_media_config.Rd @@ -0,0 +1,26 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_media_config} +\alias{mx_media_config} +\title{Query the homeserver's media configuration} +\usage{ +mx_media_config(session) +} +\arguments{ +\item{session}{An "mx_session" object.} +} +\value{ +A list; \code{$`m.upload.size`} is the upload cap in bytes + (may be absent if the server does not advertise one). +} +\description{ +Asks the server for its media limits, chiefly \code{m.upload.size} +(maximum upload bytes), so callers can check a file fits before +uploading. Tries the v1 endpoint and falls back to the legacy +location for older homeservers. +} +\examples{ +\dontrun{ +cap <- mx_media_config(s)$`m.upload.size` +file.size("clip.mp4") <= cap +} +} diff --git a/man/mx_send_media.Rd b/man/mx_send_media.Rd index 103c3c5..5866f94 100644 --- a/man/mx_send_media.Rd +++ b/man/mx_send_media.Rd @@ -7,8 +7,8 @@ \alias{mx_send_video} \title{Send a media file to a room} \usage{ -mx_send_media(session, room_id, path, body = basename(path), - msgtype = "m.file", 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()) @@ -31,8 +31,10 @@ mx_send_video(session, room_id, path, body = basename(path), \item{body}{Character. Message body / filename shown by clients.} -\item{msgtype}{Character. One of \code{"m.file"}, \code{"m.image"}, -\code{"m.audio"}, \code{"m.video"}.} +\item{msgtype}{Character or NULL. One of \code{"m.file"}, +\code{"m.image"}, \code{"m.audio"}, \code{"m.video"}. NULL (the +default) derives it from the MIME type, so a .mp4 posts as m.video +without being told.} \item{content_type}{Character or NULL. MIME type (guessed from the extension when NULL).} From 263568cc50740fb922ea86eccc1ce478d4519430 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:55:35 -0500 Subject: [PATCH 7/9] Raise classed Matrix error conditions All HTTP failures now signal a condition classed c("mx_error_", "mx_error", "error"), carrying $errcode, $status, and the parsed $body, so code can react to specific failures (tryCatch(..., mx_error_M_UNKNOWN_TOKEN = relogin)) instead of grepling the message. Message text keeps the historical "Matrix error [CODE]: msg" shape, so string-matching callers keep working. mx_get_state() and mx_get_account_data() now catch by class. --- R/account.R | 7 +------ R/http.R | 28 +++++++++++++++++++++++++--- R/media.R | 6 +++--- R/messages.R | 7 +------ inst/tinytest/test_errors.R | 23 +++++++++++++++++++++++ 5 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 inst/tinytest/test_errors.R diff --git a/R/account.R b/R/account.R index c5a4b3b..cde3bd9 100644 --- a/R/account.R +++ b/R/account.R @@ -22,12 +22,7 @@ mx_get_account_data <- function(session, type, user_id = session$user_id) { ) tryCatch( mx_http(session$server, "GET", path, token = session$token), - error = function(e) { - if (grepl("M_NOT_FOUND", conditionMessage(e))) { - return(NULL) - } - stop(e) - } + mx_error_M_NOT_FOUND = function(e) NULL ) } diff --git a/R/http.R b/R/http.R index 1fc00e1..91d8c8b 100644 --- a/R/http.R +++ b/R/http.R @@ -37,14 +37,36 @@ mx_http <- function(base_url, method, path, body = NULL, query = NULL, } if (resp$status_code >= 400) { - errcode <- parsed$errcode %||% "HTTP" - msg <- parsed$error %||% paste("HTTP", resp$status_code) - stop(sprintf("Matrix error [%s]: %s", errcode, msg), call. = FALSE) + mx_raise(parsed$errcode %||% "HTTP", + parsed$error %||% paste("HTTP", resp$status_code), + status = resp$status_code, body = parsed) } parsed } +# Raise a classed Matrix error. Code can catch specific failures by +# class -- e.g. tryCatch(..., mx_error_M_NOT_FOUND = function(e) NULL) +# or test inherits(e, "mx_error_M_UNKNOWN_TOKEN") to re-login -- instead +# of grepl()ing the message. The message text keeps the historical +# "Matrix error [CODE]: msg" shape, and $errcode, $status, and $body +# carry the structured details. +mx_raise <- function(errcode, msg, status = NULL, body = NULL) { + cond <- structure( + class = c(paste0("mx_error_", errcode), "mx_error", + "error", "condition"), + list( + message = sprintf("Matrix error [%s]: %s", + errcode, msg), + call = NULL, + errcode = errcode, + status = status, + body = body + ) + ) + stop(cond) +} + `%||%` <- function(a, b) if (is.null(a)) b else a mx_txn_id <- function() { diff --git a/R/media.R b/R/media.R index c2bac52..d08a824 100644 --- a/R/media.R +++ b/R/media.R @@ -46,9 +46,9 @@ mx_upload <- function(session, path, content_type = NULL, filename = NULL) { simplifyVector = FALSE) if (resp$status_code >= 400) { - errcode <- parsed$errcode %||% "HTTP" - msg <- parsed$error %||% paste("HTTP", resp$status_code) - stop(sprintf("Matrix error [%s]: %s", errcode, msg), call. = FALSE) + mx_raise(parsed$errcode %||% "HTTP", + parsed$error %||% paste("HTTP", resp$status_code), + status = resp$status_code, body = parsed) } parsed$content_uri diff --git a/R/messages.R b/R/messages.R index f4551ca..2915d0b 100644 --- a/R/messages.R +++ b/R/messages.R @@ -125,12 +125,7 @@ mx_get_state <- function(session, room_id, event_type, state_key = "") { ) tryCatch( mx_http(session$server, "GET", path, token = session$token), - error = function(e) { - if (grepl("M_NOT_FOUND", conditionMessage(e))) { - return(NULL) - } - stop(e) - } + mx_error_M_NOT_FOUND = function(e) NULL ) } diff --git a/inst/tinytest/test_errors.R b/inst/tinytest/test_errors.R new file mode 100644 index 0000000..cc1dcdb --- /dev/null +++ b/inst/tinytest/test_errors.R @@ -0,0 +1,23 @@ +library(tinytest) + +# mx_raise() produces classed conditions with structured fields and the +# historical message shape. +e <- tryCatch(mx.api:::mx_raise("M_NOT_FOUND", "Event not found.", + status = 404L), + error = function(e) e) +expect_true(inherits(e, "mx_error")) +expect_true(inherits(e, "mx_error_M_NOT_FOUND")) +expect_equal(e$errcode, "M_NOT_FOUND") +expect_equal(e$status, 404L) +expect_equal(conditionMessage(e), + "Matrix error [M_NOT_FOUND]: Event not found.") + +# class-targeted handlers fire +got <- tryCatch(mx.api:::mx_raise("M_UNKNOWN_TOKEN", "expired"), + mx_error_M_UNKNOWN_TOKEN = function(e) "relogin") +expect_equal(got, "relogin") + +# generic mx_error handler catches any code +got2 <- tryCatch(mx.api:::mx_raise("M_LIMIT_EXCEEDED", "slow down"), + mx_error = function(e) e$errcode) +expect_equal(got2, "M_LIMIT_EXCEEDED") From 54427d7f7dda6e3190e72d58810b9ed9f4168b29 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:57:06 -0500 Subject: [PATCH 8/9] Stream uploads from disk; fix mx_guess_mime() NA on unknown extension mx_upload() read the whole file into RAM before sending; switch to curl upload mode with a readfunction so multi-GB files stream from a connection (customrequest keeps the method POST). Verified live with an 8MB byte-identical round-trip. mx_guess_mime() returned NA instead of the documented octet-stream fallback on unknown extensions (named-vector miss is NA, not NULL, so %||% never fired) -- now load-bearing for msgtype auto-detection. --- R/media.R | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/R/media.R b/R/media.R index d08a824..2377aad 100644 --- a/R/media.R +++ b/R/media.R @@ -31,10 +31,21 @@ mx_upload <- function(session, path, content_type = NULL, filename = NULL) { utils::URLencode(filename, reserved = TRUE) ) - payload <- readBin(path, "raw", n = file.info(path)$size) + # Stream from disk rather than reading the whole file into RAM: + # upload mode + a readfunction lets curl pull chunks as it sends, + # which keeps multi-GB videos out of memory. customrequest keeps + # the method POST (upload mode alone would PUT). + con <- file(path, "rb") + on.exit(close(con), add = TRUE) h <- curl::new_handle() - curl::handle_setopt(h, customrequest = "POST", postfields = payload) + curl::handle_setopt( + h, + upload = TRUE, + customrequest = "POST", + readfunction = function(n) readBin(con, "raw", n), + infilesize_large = file.size(path) + ) curl::handle_setheaders( h, Authorization = paste("Bearer", session$token), @@ -112,7 +123,12 @@ mx_guess_mime <- function(path) { zip = "application/zip", gz = "application/gzip", tar = "application/x-tar" ) - unname(table[ext] %||% "application/octet-stream") + hit <- unname(table[ext]) + # A named-vector miss is NA, not NULL, so %||% alone won't catch it. + if (length(hit) != 1L || is.na(hit)) { + return("application/octet-stream") + } + hit } From c38c63e9736e01c72428b66404aaa80f831ac569 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 10 Jun 2026 11:59:59 -0500 Subject: [PATCH 9/9] NEWS + README for the media/errors/streaming additions --- NEWS.md | 17 +++++++++++++---- README.md | 4 +++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index cca92cc..08837ba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,10 +6,19 @@ `m.room.encryption`). Together these are what an external end-to-end-encryption layer (such as 'mx.client') needs. * Media messages: `mx_send_media()` uploads a file and posts the - referencing `m.room.message`; `mx_send_file()`, `mx_send_image()`, - `mx_send_audio()`, and `mx_send_video()` fix the msgtype. Metadata - beyond mimetype and size is caller-supplied; mx.api does not inspect - media files. + referencing `m.room.message`, deriving m.image/m.audio/m.video from + the MIME type by default; `mx_send_file()`, `mx_send_image()`, + `mx_send_audio()`, and `mx_send_video()` fix the msgtype explicitly. + Metadata beyond mimetype and size is caller-supplied; mx.api does not + inspect media files. `mx_guess_mime()` is now exported, and + `mx_media_config()` reports the server's upload cap. +* `mx_upload()` streams files from disk instead of reading them into + memory, and `mx_guess_mime()` falls back to octet-stream on unknown + extensions as documented (was NA). +* HTTP failures signal classed conditions + (`mx_error_` / `mx_error`) carrying `$errcode`, `$status`, + and the parsed `$body`, so callers can react to specific failures + without parsing message strings. Message text is unchanged. * Bot lifecycle endpoints: `mx_room_invite()` (invite into an existing room), `mx_redact()` (Matrix deletion), `mx_typing()` (typing indicator), and profile helpers `mx_profile()`, diff --git a/README.md b/README.md index 12dcaa1..17a242e 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Code blocks use `
`; inline code is `` | Rooms | `mx_rooms`, `mx_room_create`, `mx_room_join`, `mx_room_leave`, `mx_room_invite`, `mx_room_members`, `mx_room_name`, `mx_room_topic` | | Messages | `mx_send`, `mx_send_event`, `mx_messages`, `mx_sync`, `mx_react`, `mx_redact`, `mx_read_receipt`, `mx_typing` | | Room state | `mx_get_state`, `mx_set_state` | -| Media | `mx_upload`, `mx_download`, `mx_send_media` (+ `mx_send_file` / `mx_send_image` / `mx_send_audio` / `mx_send_video`) | +| Media | `mx_upload` (streaming), `mx_download`, `mx_send_media` (+ `mx_send_file` / `mx_send_image` / `mx_send_audio` / `mx_send_video`), `mx_guess_mime`, `mx_media_config` | | Profile | `mx_profile`, `mx_set_displayname`, `mx_set_avatar_url` | | Account data | `mx_get_account_data`, `mx_set_account_data` | | Devices | `mx_devices`, `mx_delete_device` | @@ -145,6 +145,8 @@ additive: `mx_profile` / `mx_set_displayname` / `mx_set_avatar_url`. - Account data: `mx_get_account_data`, `mx_set_account_data`. - Devices: `mx_devices`, `mx_delete_device`. +- Classed error conditions (`mx_error_`) for programmatic + handling; streamed uploads. See `NEWS.md` for the full changelog.