From 84b72cd2c58d95849a17f7b41b841630d39bd973 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 13:32:34 -0500 Subject: [PATCH 1/2] Read inbound media, and fetch its bytes The Matrix adapter's only event source was mx_extract_text_events(), which filters to text msgtypes. An image sent into a room was not merely unreadable to a consumer, it was invisible: nothing in the poll's output said a picture had ever been sent, so an agent asked about one answered that there was no image attached. chat_poll() now runs mx_extract_media_events() over the same sync and maps each media event onto its own chat_message carrying one chat_attachment -- the shape chat_send() already produces outbound, where each file goes as a separate event. Media rides the existing position ordering, so a picture sent before a comment does not arrive after it, and a throwing extractor costs the poll its media rather than the room's conversation. An inbound attachment names where its content lives, not what it is: the mxc URL needs this client's credentials, so a consumer cannot fetch it on its own. chat_download() is the verb that turns one into a file on disk, capability-gated on chat_capabilities()$attachments and throwing by default rather than quietly leaving the caller pointing at an empty path. The loopback adapter implements it as a copy, so a consumer writes one code path and tests it against the reference. Encrypted attachments are refused rather than fetched. The bytes behind the URL are ciphertext and this adapter has no media decryption path, so downloading would write a file that is not the picture, with nothing to say so -- the same bargain chat_send() makes refusing to upload into an encrypted room. The capability stays TRUE on an e2ee client, because the events are still reported; it is the fetch that cannot happen. The mx.client floor moves to 0.2.0.6, where mx_extract_media_events() arrived. It is probed rather than assumed, so an older build degrades to no media instead of failing every poll, and CI enforces the floor so the tests that meet the real extractor actually run. That last part matters more than it looks. The first version of matrix_media_records() passed an undefined self_id, and all thirteen seamed assertions passed: R hands the argument to the seam as a promise, and a seam double that ignores it never forces it. The real-extractor test in test_matrix_mxclient.R is what went red. The argument is forced up front now, and the seamed test reads it. --- DESCRIPTION | 2 +- NAMESPACE | 4 + R/contract.R | 58 +++++++++++ R/loopback.R | 19 ++++ R/matrix.R | 147 +++++++++++++++++++++++++-- inst/tinytest/test_contract.R | 41 ++++++++ inst/tinytest/test_matrix.R | 127 +++++++++++++++++++++++ inst/tinytest/test_matrix_mxclient.R | 49 +++++++++ man/chat_download.Rd | 39 +++++++ man/chat_matrix.Rd | 17 ++++ 10 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 man/chat_download.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 6ee6361..9c102a7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -22,7 +22,7 @@ BugReports: https://github.com/cornball-ai/chat.api/issues Suggests: httr, mx.api, - mx.client (>= 0.2.0.4), + mx.client (>= 0.2.0.6), mx.crypto, slackr, tinytest diff --git a/NAMESPACE b/NAMESPACE index c91f39a..b38b00e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,6 +9,7 @@ export(chat_channels) export(chat_config) export(chat_config_save) export(chat_disconnect) +export(chat_download) export(chat_edit) export(chat_get_state) export(chat_history) @@ -58,6 +59,9 @@ S3method(chat_channels,chat_slack) S3method(chat_channels,default) S3method(chat_disconnect,chat_irc) S3method(chat_disconnect,default) +S3method(chat_download,chat_loopback) +S3method(chat_download,chat_matrix) +S3method(chat_download,default) S3method(chat_edit,chat_loopback) S3method(chat_edit,chat_matrix) S3method(chat_edit,chat_slack) diff --git a/R/contract.R b/R/contract.R index 4b61b89..176cb4d 100644 --- a/R/contract.R +++ b/R/contract.R @@ -529,6 +529,64 @@ chat_attachment <- function(id, name = NA_character_, mime = NA_character_, class = "chat_attachment") } +#' Fetch an attachment's bytes to a local file +#' +#' Inbound attachments name where their content lives, not what it is: +#' \code{\link{chat_attachment}}'s \code{url} is a platform handle that +#' usually needs this client's credentials, so a consumer cannot simply +#' download it. This is the verb that turns one into a file on disk. +#' +#' Capability-gated on \code{chat_capabilities()$attachments}, the same +#' flag that says inbound media arrives at all. The default method +#' throws, on \code{\link{chat_react}}'s reasoning: a fetch that +#' quietly did nothing leaves the caller pointing at a path with no +#' bytes behind it. +#' +#' An attachment already on disk (\code{path} set, as the loopback +#' adapter records) is copied rather than fetched, so a consumer needs +#' one code path for both. +#' +#' @param client A \code{chat_client}. +#' @param attachment A \code{\link{chat_attachment}} record, as carried +#' on a \code{\link{chat_message}}'s \code{attachments}. +#' @param dest Destination path. NULL picks a temporary file, keeping +#' the attachment's extension where it has one. +#' @param ... Adapter-specific options. +#' @return The destination path, invisibly. +#' @export +chat_download <- function(client, attachment, dest = NULL, ...) { + UseMethod("chat_download") +} + +#' @export +chat_download.default <- function(client, attachment, dest = NULL, ...) { + stop("chat_download() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$attachments.", call. = FALSE) +} + +# A destination for an attachment with no caller-supplied one. The +# extension rides along from the name, because a consumer handing the +# file to something that sniffs by extension (an image encoder, a +# viewer) should not have to rename it first. +# +# Matched here rather than with tools::file_ext(), which would put a +# package in the Imports of one that has none. +attachment_dest <- function(attachment, dest = NULL) { + if (!is.null(dest)) { + return(dest) + } + ext <- "" + nm <- attachment$name + if (is.character(nm) && length(nm) == 1L && !is.na(nm)) { + e <- regmatches(nm, regexpr("[.][[:alnum:]]+$", nm)) + if (length(e)) { + ext <- e + } + } + tempfile("chat-attachment-", fileext = ext) +} + #' @export print.chat_attachment <- function(x, ...) { cat(sprintf("%s%s%s\n", x$id, diff --git a/R/loopback.R b/R/loopback.R index 9feac87..496c065 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -102,6 +102,25 @@ chat_send.chat_loopback <- function(client, channel, text, invisible(id) } +#' @export +chat_download.chat_loopback <- function(client, attachment, dest = NULL, ...) { + dest <- attachment_dest(attachment, dest) + src <- attachment$path + # The reference adapter records the paths it was handed, so its + # attachments are already on disk and this is a copy. It still goes + # through the same verb, which is the point: a consumer writes one + # code path and loopback is the double it tests against. + if (!is.character(src) || length(src) != 1L || is.na(src) || + !file.exists(src)) { + stop("chat_download(): attachment ", attachment$id, + " has no readable content.", call. = FALSE) + } + if (!file.copy(src, dest, overwrite = TRUE)) { + stop("chat_download(): could not write ", dest, call. = FALSE) + } + invisible(dest) +} + #' @export chat_poll.chat_loopback <- function(client, since = NULL, timeout = NULL, ...) { from <- if (is.null(since)) { diff --git a/R/matrix.R b/R/matrix.R index d7cfbe1..ad6b400 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -127,6 +127,17 @@ #' \code{mx.api::mx_room_create}. Leave NULL in production. #' @param .leave Testing seam: replacement for #' \code{mx.api::mx_room_leave}. Leave NULL in production. +#' @param .state Testing seam: replacement for +#' \code{mx.api::mx_set_state}. Leave NULL in production. +#' @param .get_state Testing seam: replacement for +#' \code{mx.api::mx_get_state}. Leave NULL in production. +#' @param .extract_media Testing seam: replacement for +#' \code{mx.client::mx_extract_media_events}. Leave NULL in +#' production. Supplying it also flips +#' \code{chat_capabilities()$attachments} on, since a seam is a media +#' source like any other. +#' @param .download Testing seam: replacement for +#' \code{mx.api::mx_download}. Leave NULL in production. #' @param .channels Testing seam: replacement for #' \code{mx.api::mx_rooms}. Leave NULL in production. #' @param .history Testing seam: replacement for @@ -159,7 +170,8 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .create = NULL, .leave = NULL, .state = NULL, - .get_state = NULL, .channels = NULL, .history = NULL, + .get_state = NULL, .extract_media = NULL, + .download = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, .edit = NULL, .rich = NULL) { seams <- list(.sync, .extract, .send, .media) @@ -217,6 +229,8 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, info_fn = .info, members_fn = .members, join_fn = .join, create_fn = .create, leave_fn = .leave, state_fn = .state, get_state_fn = .get_state, + extract_media_fn = .extract_media, + download_fn = .download, channels_fn = .channels, history_fn = .history, pending_fn = .pending, read_fn = .read, identity_fn = .identity, edit_fn = .edit, @@ -310,6 +324,50 @@ matrix_threads_available <- function() { "thread" %in% names(formals(mx.client::mx_send_text)) } +# The sync's media events, as extractor records. Empty on an mx.client +# with no media extractor, and empty rather than an error when the +# extractor throws: a malformed media event must not cost the poll its +# text messages, which are the traffic the room is actually having. +matrix_media_records <- function(client, sync, self_id) { + # Forced here, outside the tryCatch, so a caller that cannot supply + # one is an error rather than a room with no pictures in it. R hands + # the argument to the seam as a promise, and a seam double that + # ignores it never forces it: the first version of this passed an + # undefined `self_id` and every seamed test agreed there was no + # problem. + force(self_id) + fn <- client$extract_media_fn + if (is.null(fn)) { + if (!matrix_media_available()) { + return(list()) + } + fn <- mx.client::mx_extract_media_events + } + tryCatch(fn(sync, self_id), error = function(e) { + message("chat.api: media extraction failed: ", conditionMessage(e)) + list() + }) +} + +# Whether the installed mx.client can report inbound media. Probed for +# the same reason threads are: mx_extract_media_events() arrived in +# 0.2.0.6, and on an older build the poll has no source for media at +# all. A consumer reads the capability rather than discovering that +# every image in a room is invisible. +matrix_media_available <- function() { + requireNamespace("mx.client", quietly = TRUE) && + "mx_extract_media_events" %in% getNamespaceExports("mx.client") +} + +# What chat_capabilities() reports. A supplied seam is a media source +# too, so a client built with one reports media on a runner where +# mx.client is not installed at all -- the configuration the seamed +# tests run in, and the one that would otherwise have the capability +# disagreeing with the poll sitting right next to it. +matrix_media_capable <- function(client) { + !is.null(client$extract_media_fn) || matrix_media_available() +} + # The sync's pending invites, as contract records. # # mx_extract_invite_records() carries the inviter, which is the whole of @@ -517,6 +575,45 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { sender_verified = isTRUE(d$sender_verified), raw = d) } + # Inbound media. A picture is its own m.room.message event on + # Matrix, so it becomes its own chat_message carrying one + # attachment -- the same shape chat_send() produces outbound, where + # each file is sent as a separate event. + # + # The text extractor filters to text msgtypes, so without this pass + # an image is not merely unreadable, it is invisible: nothing in the + # room's traffic says a picture was ever sent. + for (a in matrix_media_records(client, res$sync, res$client$user_id)) { + ms <- a$ts %||% event_ts[[as.character(a$event_id)]] + name <- a$filename %||% a$body + messages[[length(messages) + 1L]] <- chat_message( + id = as.character(a$event_id), + channel = as.character(a$room_id), + sender = as.character(a$sender), + # The body is the filename Matrix ships as the event's + # text fallback. Kept, because it is what a client that + # cannot render the picture shows, and what a consumer + # writing a transcript has to write. + body = as.character(a$body %||% ""), + ts = if (is.null(ms)) as.POSIXct(NA) else + as.POSIXct(ms / 1000, origin = "1970-01-01"), + thread = matrix_thread_root(a$relates_to), + markup = "plain", kind = "message", + self = isTRUE(a$is_self), + mentions = unlist(a$mentions, use.names = FALSE), + encrypted = isTRUE(a$encrypted), + attachments = list(chat_attachment( + id = as.character(a$url), + name = if (is.null(name)) NA_character_ else + as.character(name), + mime = a$mime %||% NA_character_, + bytes = if (is.null(a$size)) NA_integer_ else + as.integer(a$size), + url = as.character(a$url), + sha256 = a$sha256 %||% NA_character_, + raw = a)), + raw = a) + } # Back into the order the homeserver sent them. Appending the # decrypted events put every one of them after every cleartext one, # so an encrypted message followed by a plain reply came out @@ -738,6 +835,33 @@ chat_leave.chat_matrix <- function(client, channel, ...) { invisible(channel) } +#' @export +chat_download.chat_matrix <- function(client, attachment, dest = NULL, ...) { + dest <- attachment_dest(attachment, dest) + # An encrypted attachment is refused rather than fetched. The bytes + # behind the mxc URL are ciphertext, and this adapter has no + # decryption path for media -- downloading them would hand the + # caller a file that is not the picture, with nothing to say so. + # The same bargain chat_send() makes refusing to upload into an + # encrypted room. + if (isTRUE(attachment$raw$encrypted)) { + stop("chat.api: attachment ", attachment$id, " is encrypted, and ", + "this adapter cannot decrypt media. Fetching it would ", + "write ciphertext to ", dest, ".", call. = FALSE) + } + url <- attachment$url + if (!is.character(url) || length(url) != 1L || is.na(url) || !nzchar(url)) { + stop("chat.api: attachment ", attachment$id, + " names no content to fetch.", call. = FALSE) + } + # Errors propagate, chat_react()'s reasoning: a fetch that quietly + # failed leaves the caller pointing at a path with no bytes. + sess <- mx.client::mx_client_session(client$env$mx) + download_fn <- client$download_fn %||% mx.api::mx_download + download_fn(sess, url, dest) + invisible(dest) +} + #' @export chat_get_state.chat_matrix <- function(client, channel, type, state_key = "", ...) { @@ -834,14 +958,19 @@ chat_capabilities.chat_matrix <- function(client, ...) { set_identity = TRUE, relogin = TRUE, channel_create = TRUE, leave = TRUE, set_state = TRUE, files = !isTRUE(client$e2ee), - # attachments is FALSE for the same structural reason - # thread_replies is: the only event source is - # mx.client::mx_extract_text_events(), which filters to text - # msgtypes, so m.image/m.file/m.audio/m.video never reach this - # adapter. Flip it when mx.client grows a media-aware - # extractor and chat_poll maps those events onto - # chat_attachment records. - attachments = FALSE, + # attachments tracks the installed mx.client, the way + # reaction_events does: mx_extract_media_events() arrived in + # 0.2.0.6, and on an older build the poll has no source for + # media at all. It covers both halves of inbound media -- + # media messages come back out of chat_poll(), and + # chat_download() fetches their bytes. + # + # TRUE on an e2ee client too, unlike outbound files. The + # events are reported either way; what an encrypted + # attachment cannot do is be fetched, and chat_download() + # refuses that one rather than the whole room's media going + # unmentioned. + attachments = matrix_media_capable(client), typing = TRUE, e2ee = isTRUE(client$e2ee), identity_override = FALSE, # Empty on an e2ee client: the Megolm path builds its own HTML diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index 3b5327d..dcd51ae 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -368,6 +368,47 @@ local({ "not supported by this adapter") }) +# ---- Fetching an attachment ---- +# The reference adapter's attachments are already on disk, so this is a +# copy -- but it goes through the same verb a Matrix consumer calls, +# which is what lets that consumer have one code path. +local({ + lo <- chat_loopback() + f <- tempfile(fileext = ".png") + writeBin(as.raw(1:32), f) + chat_send(lo, "general", "see this", files = f) + att <- chat_poll(lo)$messages[[1L]]$attachments[[1L]] + + dest <- chat_download(lo, att) + expect_true(file.exists(dest)) + expect_identical(readBin(dest, "raw", 32L), as.raw(1:32)) + # The extension rides along, because a consumer handing the file to + # something that sniffs by extension should not have to rename it. + expect_true(grepl("[.]png$", dest)) + # And an explicit destination is honoured. + d2 <- tempfile(fileext = ".png") + expect_identical(chat_download(lo, att, d2), d2) + expect_true(file.exists(d2)) +}) + +# An attachment naming content that is not there fails loudly rather +# than leaving the caller pointing at an empty path. +local({ + lo <- chat_loopback() + gone <- chat_attachment(id = "x", name = "gone.png", + path = file.path(tempdir(), "nope.png")) + expect_error(chat_download(lo, gone), "no readable content") + expect_error(chat_download(lo, chat_attachment(id = "y")), + "no readable content") +}) + +# An adapter that reports no inbound media says so. +local({ + nothing <- structure(list(), class = c("chat_nothing", "chat_client")) + expect_error(chat_download(nothing, chat_attachment(id = "x")), + "not supported by this adapter") +}) + # Reading state back is the other half of the same capability. local({ lo <- chat_loopback() diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 8c2040a..eb7ef30 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1526,6 +1526,133 @@ local({ expect_error(chat_leave(seam_client(.leave = function(...) stop("M_UNKNOWN")), "!a:ex"), "M_UNKNOWN") +# ---- Inbound media ---- +# A picture is its own m.room.message event on Matrix, so it comes back +# as its own chat_message carrying one attachment -- the shape +# chat_send() produces outbound, where each file is a separate event. +media_rec <- function(event_id, url = "mxc://ex/abc", msgtype = "m.image", + body = "IMG_0942.png", encrypted = FALSE, + mime = "image/png", size = 4096L, ...) { + list(room_id = "!room:ex", event_id = event_id, sender = "@alice:ex", + is_self = FALSE, body = body, filename = NULL, msgtype = msgtype, + ts = 1700000000000, url = url, mime = mime, size = size, + sha256 = NULL, encrypted = encrypted, file = NULL, + mentions = NULL, relates_to = NULL, ...) +} + +local({ + # self_id is read, not ignored. The extractor tags the bot's own + # uploads with it, and a seam that never touches the argument leaves + # it an unforced promise -- which is how the first version of this + # shipped with an undefined variable there and every test agreed. + seen_self <- NULL + cl <- seam_client(recs = list(rec("$t1", body = "look at this")), + .extract_media = function(sync, self_id) { + seen_self <<- self_id + list(media_rec("$img1")) + }) + msgs <- chat_poll(cl)$messages + expect_identical(seen_self, "@bot:ex") + expect_identical(length(msgs), 2L) + img <- Filter(function(m) length(m$attachments), msgs)[[1L]] + expect_identical(img$id, "$img1") + expect_identical(img$channel, "!room:ex") + # The filename Matrix ships as the event's text fallback is kept: + # it is what a client that cannot render the picture shows, and + # what a consumer writing a transcript has to write. + expect_identical(img$body, "IMG_0942.png") + expect_identical(img$kind, "message") + att <- img$attachments[[1L]] + expect_inherits(att, "chat_attachment") + expect_identical(att$url, "mxc://ex/abc") + expect_identical(att$mime, "image/png") + expect_identical(att$bytes, 4096L) + expect_identical(att$name, "IMG_0942.png") + # A text message in the same sync is untouched and carries none. + txt <- Filter(function(m) !length(m$attachments), msgs)[[1L]] + expect_identical(txt$body, "look at this") + expect_null(txt$attachments) +}) + +# Media rides the same ordering as everything else: a picture sent +# before a comment must not arrive after it. +local({ + sync <- wrap_sync(list(ev("$img1"), ev("$t1"))) + cl <- seam_client(recs = list(rec("$t1", body = "second")), + sync = sync, + .extract_media = function(sync, self_id) { + list(media_rec("$img1", body = "first.png")) + }) + ids <- vapply(chat_poll(cl)$messages, function(m) m$id, character(1)) + expect_identical(ids, c("$img1", "$t1")) +}) + +# A media extractor that throws costs the poll its media, not its text: +# the room's actual conversation still arrives. +local({ + cl <- seam_client(recs = list(rec("$t1")), + .extract_media = function(...) stop("malformed")) + expect_message(res <- chat_poll(cl), "media extraction failed") + expect_identical(length(res$messages), 1L) + expect_identical(res$messages[[1L]]$id, "$t1") +}) + +# ---- Fetching media ---- +local({ + seen <- NULL + cl <- seam_client(.download = function(session, mxc_url, dest) { + seen <<- list(url = mxc_url, dest = dest) + writeBin(as.raw(1:8), dest) + dest + }) + att <- chat_attachment(id = "mxc://ex/abc", name = "shot.png", + url = "mxc://ex/abc", + raw = list(encrypted = FALSE)) + dest <- chat_download(cl, att) + expect_identical(seen$url, "mxc://ex/abc") + # The extension survives, so a consumer handing the file to + # something that sniffs by extension does not have to rename it. + expect_true(grepl("[.]png$", dest)) + expect_true(file.exists(dest)) +}) + +# An encrypted attachment is refused rather than fetched: the bytes +# behind the URL are ciphertext and this adapter cannot decrypt media, +# so downloading would write a file that is not the picture. +local({ + cl <- seam_client(.download = function(...) stop("must not fetch")) + enc_att <- chat_attachment(id = "mxc://ex/enc", name = "secret.png", + url = "mxc://ex/enc", + raw = list(encrypted = TRUE)) + expect_error(chat_download(cl, enc_att), "cannot decrypt media") +}) + +# An attachment naming nothing to fetch fails before the network. +expect_error( + chat_download(seam_client(.download = function(...) stop("must not fetch")), + chat_attachment(id = "x")), "names no content") + +# The capability tracks the installed mx.client, and covers both +# halves: media arriving, and its bytes being fetchable. A supplied +# seam is a media source too, so it must not disagree with the poll +# running right next to it in this file. +local({ + expect_identical(chat_capabilities(seam_client())$attachments, + chat.api:::matrix_media_available()) + expect_true(chat_capabilities( + seam_client(.extract_media = function(...) list()))$attachments) +}) + +# On an e2ee client media is still reported: the events arrive either +# way, and what an encrypted attachment cannot do is be fetched. +local({ + cl <- seam_client(.extract_media = function(...) list(), + e2ee = TRUE, .crypto = fake_crypto()$ops) + caps <- chat_capabilities(cl) + expect_true(caps$attachments) + expect_false(caps$files) +}) + # ---- Threads ---- # Inbound: only rel_type m.thread becomes $thread. A rich reply carries # an m.in_reply_to and no rel_type, and reading that as a thread would diff --git a/inst/tinytest/test_matrix_mxclient.R b/inst/tinytest/test_matrix_mxclient.R index 361ba04..1d22df7 100644 --- a/inst/tinytest/test_matrix_mxclient.R +++ b/inst/tinytest/test_matrix_mxclient.R @@ -217,6 +217,55 @@ local({ expect_null(msgs[[2L]]$thread) }) +# ---- Media through the real extractor ---- +# Every media assertion in test_matrix.R hands the adapter a record the +# test wrote itself, so all of them would keep passing if +# mx_extract_media_events() named its fields differently. This is the +# one place the mapping meets the installed build. +if ("mx_extract_media_events" %in% getNamespaceExports("mx.client")) { + med_formals <- names(formals(mx.client::mx_extract_media_events)) + expect_identical(med_formals[1:2], c("sync_resp", "self_id")) + + med_sync <- list(rooms = list(join = list("!room:ex" = list( + timeline = list(events = list( + list(type = "m.room.message", event_id = "$m1", + sender = "@alice:ex", origin_server_ts = 1700000005000, + content = list(msgtype = "m.image", body = "IMG_0942.png", + url = "mxc://ex/abc", + info = list(mimetype = "image/png", size = 4096))), + list(type = "m.room.message", event_id = "$m2", + sender = "@alice:ex", origin_server_ts = 1700000006000, + content = list(msgtype = "m.text", body = "what is this")) + )))))) + mp <- chat_matrix(mx = fake_mx(), save_cursor = FALSE, + .sync = function(client, ...) { + list(sync = med_sync, client = fake_mx("s3"), + first_run = FALSE) + }, + .send = function(...) "$id", + .media = function(...) NULL) + mmsgs <- chat_poll(mp)$messages + # The picture and the question, in the order the room had them. + expect_identical(vapply(mmsgs, `[[`, "", "id"), c("$m1", "$m2")) + img <- mmsgs[[1L]] + expect_identical(img$body, "IMG_0942.png") + expect_identical(length(img$attachments), 1L) + # By value, not by class: every one of these is a field name the + # adapter reads off the record, and a rename upstream would leave + # an attachment that is present and empty. + att <- img$attachments[[1L]] + expect_identical(att$url, "mxc://ex/abc") + expect_identical(att$mime, "image/png") + expect_identical(att$bytes, 4096L) + expect_identical(att$name, "IMG_0942.png") + expect_false(isTRUE(att$raw$encrypted)) + expect_equal(as.numeric(img$ts), 1700000005, tolerance = 1e-6) + # The text message beside it carries none, so a consumer testing + # length(attachments) is not answering yes for every message. + expect_null(mmsgs[[2L]]$attachments) + expect_true(chat_capabilities(mp)$attachments) +} + # ---- Resolve ---- # Offline: mx_resolve_room short-circuits on a !-prefixed id and on an # empty name, so both paths run without a homeserver. diff --git a/man/chat_download.Rd b/man/chat_download.Rd new file mode 100644 index 0000000..8b6e703 --- /dev/null +++ b/man/chat_download.Rd @@ -0,0 +1,39 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_download} +\alias{chat_download} +\title{Fetch an attachment's bytes to a local file} +\usage{ +chat_download(client, attachment, dest = NULL, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{attachment}{A \code{\link{chat_attachment}} record, as carried +on a \code{\link{chat_message}}'s \code{attachments}.} + +\item{dest}{Destination path. NULL picks a temporary file, keeping +the attachment's extension where it has one.} + +\item{...}{Adapter-specific options.} +} +\value{ +The destination path, invisibly. +} +\description{ +Inbound attachments name where their content lives, not what it is: +\code{\link{chat_attachment}}'s \code{url} is a platform handle that +usually needs this client's credentials, so a consumer cannot simply +download it. This is the verb that turns one into a file on disk. +} +\details{ +Capability-gated on \code{chat_capabilities()$attachments}, the same +flag that says inbound media arrives at all. The default method +throws, on \code{\link{chat_react}}'s reasoning: a fetch that +quietly did nothing leaves the caller pointing at a path with no +bytes behind it. + +An attachment already on disk (\code{path} set, as the loopback +adapter records) is copied rather than fetched, so a consumer needs +one code path for both. + +} diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 6bf9a17..c030a23 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -26,6 +26,8 @@ chat_matrix( .leave = NULL, .state = NULL, .get_state = NULL, + .extract_media = NULL, + .download = NULL, .channels = NULL, .history = NULL, .pending = NULL, @@ -174,6 +176,21 @@ replacing \code{mx.api::mx_room_name} and \item{.leave}{Testing seam: replacement for \code{mx.api::mx_room_leave}. Leave NULL in production.} +\item{.state}{Testing seam: replacement for +\code{mx.api::mx_set_state}. Leave NULL in production.} + +\item{.get_state}{Testing seam: replacement for +\code{mx.api::mx_get_state}. Leave NULL in production.} + +\item{.extract_media}{Testing seam: replacement for +\code{mx.client::mx_extract_media_events}. Leave NULL in +production. Supplying it also flips +\code{chat_capabilities()$attachments} on, since a seam is a media +source like any other.} + +\item{.download}{Testing seam: replacement for +\code{mx.api::mx_download}. Leave NULL in production.} + \item{.channels}{Testing seam: replacement for \code{mx.api::mx_rooms}. Leave NULL in production.} From 19e917d9c2c38532020600211ab633e7884abeb9 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 13:32:49 -0500 Subject: [PATCH 2/2] Bump version to 0.0.1.24 --- DESCRIPTION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 9c102a7..6d03638 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.23 -Date: 2026-08-18 +Version: 0.0.1.24 +Date: 2026-08-20 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",