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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: mx.client
Type: Package
Title: Stateful Matrix Client Helpers
Version: 0.2.0.6
Version: 0.2.0.7
Date: 2026-08-20
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
Expand Down
13 changes: 13 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# mx.client 0.2.0.7

## Fixes

- `mx_extract_media_events()` threw "$ operator is invalid for atomic
vectors" on a cleartext image whose content carried a `filename` --
which is what Element sends for an ordinary photo, and what any
client sends when `body` is a caption. `$` partial-matches on a list,
so with no exact `file` key `content$file` resolved to the filename
string and `content$file$hashes` was `$` on a character vector. It
would also have reported that picture as `encrypted`. Every content
field is read with `[[` now.

# mx.client 0.2.0.6

## New
Expand Down
73 changes: 55 additions & 18 deletions R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,7 @@ mx_extract_text_events <- function(sync_resp, self_id, msgtypes = "m.text") {
#' 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")) {
msgtypes = c("m.image", "m.file", "m.audio", "m.video")) {
joined <- sync_resp$rooms$join
if (!length(joined)) {
return(list())
Expand All @@ -228,29 +227,67 @@ mx_extract_media_events <- function(sync_resp, self_id,
next
}
for (ev in events) {
if (!isTRUE(ev$type == "m.room.message") ||
!isTRUE(ev$content$msgtype %in% msgtypes)) {
# Every content read below is [[ ]], not $, and that is the
# whole of this function's history. `$` on a list partial-
# matches: an image whose content carries `filename` -- which
# is what a client sends when the body is a caption, and what
# Element sends for an ordinary photo -- makes
# `content$file` resolve to that filename string. Then
# `content$file$hashes` is `$` on a character vector, which
# is an error, and `encrypted` is TRUE for a cleartext
# picture. The first live image this saw threw exactly that.
#
# The fixtures did not catch it because none of them carried
# a `filename`, which is the field that creates the
# ambiguity. There is one below now.
content <- ev[["content"]]
if (!is.list(content) ||
!isTRUE(ev[["type"]] == "m.room.message") ||
!isTRUE(content[["msgtype"]] %in% msgtypes)) {
next
}
url <- ev$content$url %||% ev$content$file$url
file <- content[["file"]]
url <- content[["url"]] %||%
if (is.list(file)) {
file[["url"]]
} else {
NULL
}
if (is.null(url)) {
next
}
info <- content[["info"]]
if (!is.list(info)) {
info <- list()
}
if (is.list(file)) {
hashes <- file[["hashes"]]
} else {
hashes <- NULL
}
mentions <- content[["m.mentions"]]
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,
event_id = ev[["event_id"]], sender = ev[["sender"]],
is_self = isTRUE(ev[["sender"]] == self_id),
body = content[["body"]],
filename = content[["filename"]],
msgtype = 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`)
mime = info[["mimetype"]],
size = info[["size"]],
sha256 = if (is.list(hashes)) hashes[["sha256"]] else NULL,
# is.list, not !is.null: the `file` object is what makes
# a media event encrypted, and a stray string in that
# slot is not one.
encrypted = is.list(file),
file = if (is.list(file)) file else NULL,
mentions = if (is.list(mentions)) {
mentions[["user_ids"]]
} else {
NULL
},
relates_to = content[["m.relates_to"]])
}
}
out
Expand Down
64 changes: 64 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,70 @@ expect_null(media[[1]]$sha256)
expect_true(media[[2]]$is_self)
expect_null(media[[2]]$mime)

# Cleartext media that carries a filename. This is what a real client
# sends -- Element puts `filename` on an ordinary photo, and any client
# does when `body` is a caption rather than the file name -- and it is
# the case every fixture above is missing.
#
# It threw on the first live image this function ever saw. `$` on a
# list partial-matches, so with no exact `file` key `content$file`
# resolved to the filename string, and `content$file$hashes` is `$` on
# a character vector: "$ operator is invalid for atomic vectors". The
# encrypted fixture below carries a filename too, but it also carries a
# real `file`, so `$` matched exactly there and the ambiguity never
# arose. Nothing was wrong with the code that only an exactly-matching
# fixture ever ran.
local({
named <- list(rooms = list(join = list("!r:ex" = list(timeline = list(
events = list(
list(type = "m.room.message", sender = "@alice:ex",
event_id = "$6", origin_server_ts = 1700000001000,
content = list(msgtype = "m.image",
body = "look at this",
filename = "IMG_0942.png",
url = "mxc://ex/named",
info = list(mimetype = "image/png",
size = 4096)))
)
)))))
got <- mx.client::mx_extract_media_events(named, "@bot:ex")
expect_equal(length(got), 1L)
expect_equal(got[[1]]$url, "mxc://ex/named")
expect_equal(got[[1]]$filename, "IMG_0942.png")
# The caption, not the file name: with `filename` set, `body` is
# what the sender typed.
expect_equal(got[[1]]$body, "look at this")
expect_equal(got[[1]]$mime, "image/png")
expect_equal(got[[1]]$size, 4096)
# The two the partial match got wrong, by value. A cleartext
# picture reported as encrypted is one a consumer refuses to fetch.
expect_false(got[[1]]$encrypted)
expect_null(got[[1]]$file)
expect_null(got[[1]]$sha256)
})

# The same ambiguity one level out: `info` absent while `information`-
# like keys are not, and a content that is not a list at all. Neither
# can be reached from a well-formed homeserver, and both are one
# malformed event away from taking a whole poll's media with them.
local({
odd <- list(rooms = list(join = list("!r:ex" = list(timeline = list(
events = list(
list(type = "m.room.message", sender = "@alice:ex",
event_id = "$7",
content = list(msgtype = "m.image", body = "a.png",
url = "mxc://ex/noinfo")),
list(type = "m.room.message", sender = "@alice:ex",
event_id = "$8", content = "not an object")
)
)))))
got <- mx.client::mx_extract_media_events(odd, "@bot:ex")
expect_equal(length(got), 1L)
expect_equal(got[[1]]$event_id, "$7")
expect_null(got[[1]]$mime)
expect_null(got[[1]]$size)
})

# 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.
Expand Down
Loading