Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.client
Type: Package
Title: Stateful Matrix Client Helpers
Version: 0.2.0.5
Date: 2026-08-04
Version: 0.2.0.6
Date: 2026-08-20
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# mx.client 0.2.0.6

## New

* `mx_extract_media_events()` returns the media messages
`mx_extract_text_events()` filters out: images, files, audio, and
video. Each record carries where the bytes live (`url`, filled from
the cleartext `content$url` or an encrypted `content$file$url`),
`mime`, `size`, `sha256`, and the `file` object verbatim, so a
consumer can tell an encrypted attachment from a cleartext one and
fetch either.

# mx.client 0.2.0.5

## New
Expand Down
88 changes: 88 additions & 0 deletions R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,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.
Expand Down
66 changes: 66 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
63 changes: 63 additions & 0 deletions man/mx_extract_media_events.Rd
Original file line number Diff line number Diff line change
@@ -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")
}
Loading