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/NAMESPACE b/NAMESPACE index 6ee347d..9d59b04 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,17 +1,26 @@ # 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) +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) 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) @@ -19,10 +28,19 @@ 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_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/NEWS.md b/NEWS.md index 5c200e7..08837ba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,33 @@ +# 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`, 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()`, + `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/R/account.R b/R/account.R new file mode 100644 index 0000000..cde3bd9 --- /dev/null +++ b/R/account.R @@ -0,0 +1,53 @@ +# 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), + mx_error_M_NOT_FOUND = function(e) NULL + ) +} + +#' 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/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/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 3222d76..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), @@ -46,9 +57,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 @@ -87,6 +98,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( @@ -100,6 +123,128 @@ 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 +} + + +#' 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 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. +#' @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 = 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)) + 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) +} + +# 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/R/messages.R b/R/messages.R index 1199f4d..2915d0b 100644 --- a/R/messages.R +++ b/R/messages.R @@ -99,6 +99,36 @@ 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), + mx_error_M_NOT_FOUND = function(e) NULL + ) +} + #' Fetch historical messages from a room #' #' Thin wrapper over the /rooms/{id}/messages endpoint. @@ -233,3 +263,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/README.md b/README.md index fcefc9d..17a242e 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` (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` | | 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,19 @@ 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`. +- Classed error conditions (`mx_error_`) for programmatic + handling; streamed uploads. See `NEWS.md` for the full changelog. 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/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") diff --git a/inst/tinytest/test_media.R b/inst/tinytest/test_media.R index 838c746..6de626b 100644 --- a/inst/tinytest/test_media.R +++ b/inst/tinytest/test_media.R @@ -6,3 +6,23 @@ 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))) +} + +# 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/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/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_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)) +} +} 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_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_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_send_media.Rd b/man/mx_send_media.Rd new file mode 100644 index 0000000..5866f94 --- /dev/null +++ b/man/mx_send_media.Rd @@ -0,0 +1,57 @@ +% 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 = NULL, + 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 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).} + +\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") +} +} 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")) +} +} 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) +} +}