From b54b74c7103b37e2a5843bd288e58a91afae04a8 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 18:01:17 -0500 Subject: [PATCH 1/2] Read media content with [[, not $ mx_extract_media_events() threw "$ operator is invalid for atomic vectors" on the first live image it ever saw. `$` partial-matches on a list. An image whose content carries `filename` -- what Element sends for an ordinary photo, and what any client sends when `body` is a caption rather than the file name -- has no exact `file` key, so `content$file` resolved to that filename string. `content$file$hashes` is then `$` on a character vector, which is the error, and `encrypted` would have been TRUE for a cleartext picture had it got that far. Every content field is read with [[ now, and the three that can hold a non-list are checked with is.list() before being indexed into: `file`, `info`, and `m.mentions`. A content that is not an object at all is skipped rather than taken apart. The fixtures did not catch it because none of the cleartext ones carried a `filename`, which is the field that creates the ambiguity. The encrypted fixture does carry one, but it also carries a real `file`, so `$` matched exactly there and nothing was ever wrong. A cleartext-with-filename case is in now, asserting `encrypted` and `file` by value rather than only that the call returns. --- R/messages.R | 73 +++++++++++++++++++++++++--------- inst/tinytest/test_mx.client.R | 64 +++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/R/messages.R b/R/messages.R index f857603..c7d75a6 100644 --- a/R/messages.R +++ b/R/messages.R @@ -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()) @@ -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 diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index d4dcbe7..3270ae8 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -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. From 663ea0ba001b493a31c9feb15d0c398d1c18fbbb Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 18:01:38 -0500 Subject: [PATCH 2/2] Bump version to 0.2.0.7 --- DESCRIPTION | 2 +- NEWS.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index d8b45e6..db3df7d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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"), diff --git a/NEWS.md b/NEWS.md index 4e8ec91..44dbded 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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