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
6 changes: 3 additions & 3 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
147 changes: 138 additions & 9 deletions R/matrix.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = "",
...) {
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions inst/tinytest/test_contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading