From a09d701557986ff4fbdc3503807d4dd75e030d76 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 13:12:08 -0500 Subject: [PATCH 1/2] Add mx_extract_media_events() The extractor family covered text and reactions but not media, so a consumer that wanted an image had to re-walk the raw sync and duplicate the msgtype filtering. This walks joined-room timelines for m.image/m.file/m.audio/m.video message events and returns normalized records: url filled from content$url or the encrypted content$file, sha256 surfaced from the encrypted file object (cleartext carries no hash), and the file object passed through verbatim, the relates_to treatment, since decrypting is the caller's business. Events with no address in either place are skipped: nothing fetchable, nothing to report. --- NAMESPACE | 1 + R/messages.R | 88 ++++++++++++++++++++++++++++++++++ inst/tinytest/test_mx.client.R | 66 +++++++++++++++++++++++++ man/mx_extract_media_events.Rd | 63 ++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 man/mx_extract_media_events.Rd diff --git a/NAMESPACE b/NAMESPACE index 5d56149..8a6e956 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -28,6 +28,7 @@ export(mx_crypto_sessions_save) export(mx_crypto_store_dir) export(mx_extract_invite_records) export(mx_extract_invites) +export(mx_extract_media_events) export(mx_extract_reaction_verdict) export(mx_extract_reactions) export(mx_extract_text_events) diff --git a/R/messages.R b/R/messages.R index acf4d30..aded635 100644 --- a/R/messages.R +++ b/R/messages.R @@ -148,6 +148,94 @@ mx_extract_text_events <- function(sync_resp, self_id, msgtypes = "m.text") { out } +#' Extract media message events from a sync response +#' +#' Walks joined-room timeline events and returns normalized media +#' records: images, files, audio, and video sent as +#' \code{m.room.message} events. Self events are retained and tagged +#' with \code{is_self}, as in \code{\link{mx_extract_text_events}}. +#' +#' Cleartext media carries its content address in \code{content$url}; +#' end-to-end encrypted media carries a \code{content$file} object +#' (address, key material, sha256) instead. \code{url} is filled from +#' whichever is present, so a consumer that only wants to know where +#' the bytes live reads one field. \code{file} rides verbatim, the +#' \code{relates_to} treatment: decrypting is the caller's business, +#' and an extractor inventing a partial view of key material would +#' help no one. +#' +#' \code{m.sticker} is its own event type, not an +#' \code{m.room.message} msgtype, and is not reported here. +#' +#' @param sync_resp Parsed \code{/sync} response. +#' @param self_id Current user's Matrix id. +#' @param msgtypes Character vector of media message types to include. +#' @return List of normalized records, each carrying \code{room_id}, +#' \code{event_id}, \code{sender}, \code{is_self}, \code{body} (the +#' filename, or the caption when \code{filename} is set), +#' \code{filename} (or NULL), \code{msgtype}, \code{ts} (or NULL), +#' \code{url} (the mxc address, from \code{content$url} or +#' \code{content$file$url}), \code{mime} (or NULL), \code{size} (or +#' NULL), \code{sha256} (from the encrypted file object; cleartext +#' media carries no hash, so NULL there), \code{encrypted}, +#' \code{file} (the \code{content$file} object verbatim, or NULL), +#' \code{mentions}, and \code{relates_to}. +#' +#' An event with no address in either place is skipped: there is +#' nothing a consumer could ever fetch, so reporting it would hand +#' out a record whose one job is impossible. +#' @examples +#' sync_resp <- list(rooms = list(join = list("!room:example.org" = list( +#' timeline = list(events = list(list(type = "m.room.message", +#' event_id = "$1", sender = "@alice:example.org", +#' content = list(msgtype = "m.image", body = "plot.png", +#' url = "mxc://example.org/abc", +#' info = list(mimetype = "image/png", size = 1024))))))))) +#' mx_extract_media_events(sync_resp, self_id = "@bot:example.org") +#' @export +mx_extract_media_events <- function(sync_resp, self_id, + msgtypes = c("m.image", "m.file", + "m.audio", "m.video")) { + joined <- sync_resp$rooms$join + if (!length(joined)) { + return(list()) + } + + out <- list() + for (rid in names(joined)) { + events <- joined[[rid]]$timeline$events + if (!length(events)) { + next + } + for (ev in events) { + if (!isTRUE(ev$type == "m.room.message") || + !isTRUE(ev$content$msgtype %in% msgtypes)) { + next + } + url <- ev$content$url %||% ev$content$file$url + if (is.null(url)) { + next + } + out[[length(out) + 1L]] <- list(room_id = rid, + event_id = ev$event_id, sender = ev$sender, + is_self = isTRUE(ev$sender == self_id), + body = ev$content$body, + filename = ev$content$filename, + msgtype = ev$content$msgtype, + ts = ev$origin_server_ts, + url = url, + mime = ev$content$info$mimetype, + size = ev$content$info$size, + sha256 = ev$content$file$hashes$sha256, + encrypted = !is.null(ev$content$file), + file = ev$content$file, + mentions = ev$content$`m.mentions`$user_ids, + relates_to = ev$content$`m.relates_to`) + } + } + out +} + #' Extract pending invite room ids from a sync response #' #' @param sync_resp Parsed \code{/sync} response. diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index 724c468..0ffef44 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -93,6 +93,72 @@ expect_equal(invites, c("!a:ex", "!b:ex")) expect_equal(mx.client::mx_extract_invites(list(rooms = list(join = list()))), character()) +# --- Media extraction --- + +# Cleartext media: address in content$url, description in info. Text +# events belong to mx_extract_text_events, and an m.image with no +# address in either place is skipped: nothing a consumer could fetch. +media_sync <- list(rooms = list(join = list("!r:ex" = list(timeline = list( + events = list( + list(type = "m.room.message", sender = "@alice:ex", event_id = "$1", + origin_server_ts = 1700000000000, + content = list(msgtype = "m.image", body = "plot.png", + url = "mxc://ex/abc", + info = list(mimetype = "image/png", + size = 1024))), + list(type = "m.room.message", sender = "@bot:ex", event_id = "$2", + content = list(msgtype = "m.file", body = "notes.pdf", + url = "mxc://ex/def")), + list(type = "m.room.message", sender = "@alice:ex", event_id = "$3", + content = list(msgtype = "m.text", body = "not media")), + list(type = "m.room.message", sender = "@alice:ex", event_id = "$4", + content = list(msgtype = "m.image", body = "no-address.png")) + ) +))))) +media <- mx.client::mx_extract_media_events(media_sync, "@bot:ex") +expect_equal(length(media), 2L) +expect_equal(media[[1]]$url, "mxc://ex/abc") +expect_equal(media[[1]]$mime, "image/png") +expect_equal(media[[1]]$size, 1024) +expect_equal(media[[1]]$ts, 1700000000000) +expect_false(media[[1]]$is_self) +expect_false(media[[1]]$encrypted) +expect_null(media[[1]]$sha256) +expect_true(media[[2]]$is_self) +expect_null(media[[2]]$mime) + +# Encrypted media: the address moves into content$file, which also +# carries the sha256 and the key material. url fills from it, the hash +# surfaces, and the file object rides verbatim for whoever holds keys. +enc_sync <- list(rooms = list(join = list("!r:ex" = list(timeline = list( + events = list( + list(type = "m.room.message", sender = "@alice:ex", event_id = "$5", + content = list(msgtype = "m.video", body = "clip.mp4", + filename = "clip.mp4", + info = list(mimetype = "video/mp4", + size = 2048), + file = list(url = "mxc://ex/enc", + key = list(k = "secret"), + iv = "iv0", + hashes = list(sha256 = "deadbeef"), + v = "v2"))) + ) +))))) +enc <- mx.client::mx_extract_media_events(enc_sync, "@bot:ex") +expect_equal(length(enc), 1L) +expect_equal(enc[[1]]$url, "mxc://ex/enc") +expect_equal(enc[[1]]$sha256, "deadbeef") +expect_true(enc[[1]]$encrypted) +expect_equal(enc[[1]]$file$key$k, "secret") +expect_equal(enc[[1]]$filename, "clip.mp4") +expect_equal(enc[[1]]$msgtype, "m.video") + +# Malformed and empty syncs extract to nothing, not errors +expect_equal(length(mx.client::mx_extract_media_events(list(), "@bot:ex")), + 0L) +expect_equal(length(mx.client::mx_extract_media_events( + list(rooms = list(join = list())), "@bot:ex")), 0L) + # --- Reaction verdict extraction --- reaction_sync <- list(rooms = list(join = list( diff --git a/man/mx_extract_media_events.Rd b/man/mx_extract_media_events.Rd new file mode 100644 index 0000000..cf5d1ee --- /dev/null +++ b/man/mx_extract_media_events.Rd @@ -0,0 +1,63 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_extract_media_events} +\alias{mx_extract_media_events} +\title{Extract media message events from a sync response} +\usage{ +mx_extract_media_events( + sync_resp, + self_id, + msgtypes = c("m.image", "m.file", "m.audio", "m.video") +) +} +\arguments{ +\item{sync_resp}{Parsed \code{/sync} response.} + +\item{self_id}{Current user's Matrix id.} + +\item{msgtypes}{Character vector of media message types to include.} +} +\value{ +List of normalized records, each carrying \code{room_id}, + \code{event_id}, \code{sender}, \code{is_self}, \code{body} (the + filename, or the caption when \code{filename} is set), + \code{filename} (or NULL), \code{msgtype}, \code{ts} (or NULL), + \code{url} (the mxc address, from \code{content$url} or + \code{content$file$url}), \code{mime} (or NULL), \code{size} (or + NULL), \code{sha256} (from the encrypted file object; cleartext + media carries no hash, so NULL there), \code{encrypted}, + \code{file} (the \code{content$file} object verbatim, or NULL), + \code{mentions}, and \code{relates_to}. + + An event with no address in either place is skipped: there is + nothing a consumer could ever fetch, so reporting it would hand + out a record whose one job is impossible. +} +\description{ +Walks joined-room timeline events and returns normalized media +records: images, files, audio, and video sent as +\code{m.room.message} events. Self events are retained and tagged +with \code{is_self}, as in \code{\link{mx_extract_text_events}}. +} +\details{ +Cleartext media carries its content address in \code{content$url}; +end-to-end encrypted media carries a \code{content$file} object +(address, key material, sha256) instead. \code{url} is filled from +whichever is present, so a consumer that only wants to know where +the bytes live reads one field. \code{file} rides verbatim, the +\code{relates_to} treatment: decrypting is the caller's business, +and an extractor inventing a partial view of key material would +help no one. + +\code{m.sticker} is its own event type, not an +\code{m.room.message} msgtype, and is not reported here. + +} +\examples{ +sync_resp <- list(rooms = list(join = list("!room:example.org" = list( + timeline = list(events = list(list(type = "m.room.message", + event_id = "$1", sender = "@alice:example.org", + content = list(msgtype = "m.image", body = "plot.png", + url = "mxc://example.org/abc", + info = list(mimetype = "image/png", size = 1024))))))))) +mx_extract_media_events(sync_resp, self_id = "@bot:example.org") +} From 4d44a49c850cd185fd9d07b20d02c57607e401d1 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 13:12:48 -0500 Subject: [PATCH 2/2] Bump version to 0.2.0.5 --- DESCRIPTION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 8df026c..341c8ea 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.0.4 -Date: 2026-08-04 +Version: 0.2.0.5 +Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",