diff --git a/DESCRIPTION b/DESCRIPTION index 6ee6361..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", @@ -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.}