From 9612e845792c0cd8f135694a4f0877cfdfa73f17 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 12:43:50 -0500 Subject: [PATCH 1/2] Add chat_channel_create(), chat_leave(), and message attachments Bots open rooms about as often as they are invited to them, and the contract had no verb for either creating or leaving: consumers were forced below the seam into adapter-native calls (mx_room_create is how the resident fleet does it today). Both new generics are capability-gated with loud defaults. Inbound media had no home either: chat_send() takes files= but a received file could only ride raw. chat_message() gains attachments, a list of chat_attachment records (id, name, mime, bytes, url, path, sha256) where sha256 is only ever transport-supplied, never invented, so NA means unverified and a consumer that needs provenance hashes at ingest. Adapters: loopback implements create (declared channels count before any traffic) and the files round trip, making it the media test double; matrix wires mx_room_create/mx_room_leave behind .create and .leave seams, attachments stays FALSE until mx.client grows a media-aware extractor (same structural reason as thread_replies); irc and slack answer the new flags honestly FALSE. --- NAMESPACE | 9 +++ R/contract.R | 124 +++++++++++++++++++++++++++++++++- R/irc.R | 5 +- R/loopback.R | 50 +++++++++++++- R/matrix.R | 38 ++++++++++- R/slack.R | 5 +- inst/tinytest/test_contract.R | 96 ++++++++++++++++++++++++++ inst/tinytest/test_matrix.R | 37 ++++++++++ man/chat_attachment.Rd | 53 +++++++++++++++ man/chat_capabilities.Rd | 7 +- man/chat_channel_create.Rd | 30 ++++++++ man/chat_leave.Rd | 23 +++++++ man/chat_matrix.Rd | 8 +++ man/chat_message.Rd | 8 ++- 14 files changed, 482 insertions(+), 11 deletions(-) create mode 100644 man/chat_attachment.Rd create mode 100644 man/chat_channel_create.Rd create mode 100644 man/chat_leave.Rd diff --git a/NAMESPACE b/NAMESPACE index 5dcce39..0b5167b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,7 +1,9 @@ # tinyrox says don't edit this manually, but it can't stop you! export(chat_addressed) +export(chat_attachment) export(chat_capabilities) +export(chat_channel_create) export(chat_channel_info) export(chat_channels) export(chat_config) @@ -13,6 +15,7 @@ export(chat_identity) export(chat_invite) export(chat_irc) export(chat_join) +export(chat_leave) export(chat_loopback) export(chat_mark_read) export(chat_matrix) @@ -41,6 +44,9 @@ S3method(chat_capabilities,chat_irc) S3method(chat_capabilities,chat_loopback) S3method(chat_capabilities,chat_matrix) S3method(chat_capabilities,chat_slack) +S3method(chat_channel_create,chat_loopback) +S3method(chat_channel_create,chat_matrix) +S3method(chat_channel_create,default) S3method(chat_channel_info,chat_matrix) S3method(chat_channel_info,chat_slack) S3method(chat_channel_info,default) @@ -61,6 +67,8 @@ S3method(chat_history,default) S3method(chat_join,chat_matrix) S3method(chat_join,chat_slack) S3method(chat_join,default) +S3method(chat_leave,chat_matrix) +S3method(chat_leave,default) S3method(chat_mark_read,chat_matrix) S3method(chat_mark_read,chat_slack) S3method(chat_mark_read,default) @@ -97,6 +105,7 @@ S3method(chat_whoami,chat_loopback) S3method(chat_whoami,chat_matrix) S3method(chat_whoami,chat_slack) S3method(chat_whoami,default) +S3method(print,chat_attachment) S3method(print,chat_config) S3method(print,chat_identity) S3method(print,chat_invite) diff --git a/R/contract.R b/R/contract.R index 731a687..3263967 100644 --- a/R/contract.R +++ b/R/contract.R @@ -115,7 +115,12 @@ chat_resolve <- function(client, name, ...) { #' back out of \code{\link{chat_poll}}), \code{join} #' (\code{\link{chat_join}} works), \code{whoami} #' (\code{\link{chat_whoami}} works, and with it the default -#' \code{\link{chat_addressed}}), \code{files}, \code{typing}, +#' \code{\link{chat_addressed}}), \code{channel_create} +#' (\code{\link{chat_channel_create}} works), \code{leave} +#' (\code{\link{chat_leave}} works), \code{files} (outbound: +#' \code{chat_send(files =)} works), \code{attachments} (inbound: +#' media comes back out of \code{\link{chat_poll}} as +#' \code{\link{chat_attachment}} records), \code{typing}, #' \code{e2ee}, \code{identity_override} (logicals), #' \code{markup_dialects} (character), \code{max_message_bytes} #' (integer or NA). @@ -197,6 +202,58 @@ chat_join.default <- function(client, channel, ...) { "). Check chat_capabilities()$join.", call. = FALSE) } +#' Create a channel +#' +#' Capability-gated: check \code{chat_capabilities()$channel_create}. +#' Bots open rooms about as often as they are invited to them; a +#' contract without creation forces every such consumer below the +#' seam, into adapter-native calls. +#' +#' @param client A \code{chat_client}. +#' @param name Character. Human-readable name for the new channel. +#' @param ... Adapter-specific options (topic, visibility, invitees). +#' @return The new channel's identifier, invisibly. Everything else -- +#' sends, joins, membership -- takes the identifier, not the name, +#' so the return value is the point of the call. +#' @examples +#' cl <- chat_loopback() +#' id <- chat_channel_create(cl, "general") +#' chat_send(cl, id, "hello") +#' @export +chat_channel_create <- function(client, name, ...) { + UseMethod("chat_channel_create") +} + +#' @export +chat_channel_create.default <- function(client, name, ...) { + stop("chat_channel_create() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$channel_create.", call. = FALSE) +} + +#' Leave a channel +#' +#' The inverse of \code{\link{chat_join}}: after it returns, the +#' client stops receiving the channel's traffic, on platforms where +#' membership is a thing at all. Capability-gated: check +#' \code{chat_capabilities()$leave}. +#' +#' @param client A \code{chat_client}. +#' @param channel Channel/room identifier. +#' @param ... Adapter-specific options. +#' @return The left channel's identifier, invisibly. +#' @export +chat_leave <- function(client, channel, ...) { + UseMethod("chat_leave") +} + +#' @export +chat_leave.default <- function(client, channel, ...) { + stop("chat_leave() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$leave.", call. = FALSE) +} + #' Construct a normalized invitation record #' #' The record \code{\link{chat_poll}} returns in \code{$invites} on @@ -392,6 +449,10 @@ chat_disconnect.default <- function(client, ...) { #' adapter-specific and may already be normalized by the transport #' package (Matrix hands over an extracted record, not the timeline #' event), so it is an escape hatch, not a guarantee of completeness. +#' @param attachments List of \code{\link{chat_attachment}} records, +#' or NULL. Inbound media: what a sent \code{files =} looks like +#' from the receiving side, on adapters whose +#' \code{chat_capabilities()$attachments} is TRUE. #' @param encrypted Logical: did this message arrive end-to-end #' encrypted? FALSE on transports without E2EE and on cleartext #' messages in rooms that have it. @@ -407,18 +468,75 @@ chat_disconnect.default <- function(client, ...) { chat_message <- function(id, channel, sender, body, ts, thread = NULL, markup = "plain", kind = "message", self = NULL, mentions = NULL, raw = NULL, encrypted = FALSE, - sender_verified = NULL) { + sender_verified = NULL, attachments = NULL) { stopifnot(is.character(id), is.character(channel), is.character(sender), is.character(body)) + if (!is.null(attachments)) { + ok <- is.list(attachments) && length(attachments) > 0L && + all(vapply(attachments, inherits, logical(1), + "chat_attachment")) + if (!ok) { + stop("attachments must be a non-empty list of ", + "chat_attachment records, or NULL.", call. = FALSE) + } + } structure(list(id = id, channel = channel, sender = sender, body = body, ts = ts, thread = thread, markup = markup, kind = kind, self = self, - mentions = mentions, raw = raw, + mentions = mentions, attachments = attachments, + raw = raw, encrypted = isTRUE(encrypted), sender_verified = sender_verified), class = "chat_message") } +#' Construct a normalized attachment record +#' +#' Inbound media on a \code{\link{chat_message}}: \code{chat_poll()} +#' and \code{chat_history()} put these in the message's +#' \code{attachments} on adapters whose +#' \code{chat_capabilities()$attachments} is TRUE. +#' +#' @param id Adapter-native identifier for the content (a Matrix mxc +#' URI, a Slack file id). The stable handle; everything else here is +#' description. +#' @param name Filename as the sender labeled it, or NA. +#' @param mime MIME type, or NA when the transport does not say. +#' @param bytes Size in bytes, or NA. +#' @param url A fetchable location, or NA. It may require this +#' client's credentials; a consumer must not assume it is public. +#' @param path Local filesystem path when the content is already on +#' disk, or NA. +#' @param sha256 Content hash, or NA. Adapters fill it only when the +#' transport carries one (Matrix encrypted attachments do); they +#' must not compute it speculatively, because NA meaning +#' "unverified" is what tells a consumer that needs provenance to +#' hash at ingest and record the result. +#' @param raw The adapter's platform-native payload. +#' @return A list with class \code{chat_attachment}. +#' @examples +#' chat_attachment("mxc://ex/abc", name = "plot.png", mime = "image/png") +#' @export +chat_attachment <- function(id, name = NA_character_, + mime = NA_character_, bytes = NA_integer_, + url = NA_character_, path = NA_character_, + sha256 = NA_character_, raw = NULL) { + stopifnot(is.character(id), length(id) == 1L, nzchar(id)) + structure(list(id = id, name = name, mime = mime, bytes = bytes, + url = url, path = path, sha256 = sha256, raw = raw), + class = "chat_attachment") +} + +#' @export +print.chat_attachment <- function(x, ...) { + cat(sprintf("%s%s%s\n", x$id, + if (is.na(x$name)) "" else sprintf(" (%s)", x$name), + if (is.na(x$bytes)) "" else { + sprintf(", %d bytes", as.integer(x$bytes)) + })) + invisible(x) +} + #' @export print.chat_message <- function(x, ...) { cat(sprintf("[%s] %s in %s: %s\n", format(x$ts, "%H:%M:%S"), x$sender, diff --git a/R/irc.R b/R/irc.R index 84a149d..c54a2b4 100644 --- a/R/irc.R +++ b/R/irc.R @@ -129,7 +129,10 @@ chat_capabilities.chat_irc <- function(client, ...) { members = FALSE, invites = FALSE, join = FALSE, whoami = TRUE, channels = FALSE, history = FALSE, pending = FALSE, mark_read = FALSE, set_identity = TRUE, relogin = FALSE, - files = FALSE, typing = FALSE, e2ee = FALSE, + # IRC JOIN would create a channel implicitly, but this adapter + # has no join verb yet, so neither flag can be TRUE honestly. + channel_create = FALSE, leave = FALSE, + files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, identity_override = FALSE, rich_markup = character(), markup_dialects = "plain", max_message_bytes = 400L) } diff --git a/R/loopback.R b/R/loopback.R index a7863a1..54db1de 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -17,9 +17,28 @@ chat_loopback <- function() { env <- new.env(parent = emptyenv()) env$log <- list() + # Channels declared by chat_channel_create(); chat_channels() + # reports these plus every channel the log has seen traffic in. + env$channels <- character() structure(list(env = env), class = c("chat_loopback", "chat_client")) } +#' @export +chat_channel_create.chat_loopback <- function(client, name, ...) { + stopifnot(is.character(name), length(name) == 1L, nzchar(name)) + # The name is the identifier, which is the simplest honest answer + # for an in-memory adapter -- and creating twice is an error, not a + # no-op, because a consumer that creates a channel it already made + # has lost track of its own state, and the reference adapter is + # where that should be loudest. + if (name %in% chat_channels(client)) { + stop("chat_channel_create(): channel '", name, + "' already exists.", call. = FALSE) + } + client$env$channels <- c(client$env$channels, name) + invisible(name) +} + #' @export chat_send.chat_loopback <- function(client, channel, text, markup = c("plain", "markdown"), @@ -28,12 +47,32 @@ chat_send.chat_loopback <- function(client, channel, text, kind = "message", notify = TRUE, rich = NULL, ...) { markup <- match.arg(markup) + attachments <- NULL + if (!is.null(files)) { + # A missing file errors rather than sending a message that + # quietly lost its attachment; the reference adapter is where + # that should be loudest. + missing <- files[!file.exists(files)] + if (length(missing)) { + stop("chat_send(): no such file: ", + paste(missing, collapse = ", "), call. = FALSE) + } + attachments <- lapply(seq_along(files), function(i) { + chat_attachment( + id = sprintf("loopback-file-%d-%d", + length(client$env$log) + 1L, i), + name = basename(files[[i]]), + bytes = as.integer(file.size(files[[i]])), + path = files[[i]]) + }) + } id <- sprintf("loopback-%d", length(client$env$log) + 1L) msg <- chat_message(id = id, channel = channel, sender = if (is.null(identity$name)) "loopback" else identity$name, body = text, ts = Sys.time(), thread = thread, - markup = markup, kind = kind) + markup = markup, kind = kind, + attachments = attachments) client$env$log[[length(client$env$log) + 1L]] <- msg invisible(id) } @@ -63,7 +102,11 @@ chat_capabilities.chat_loopback <- function(client, ...) { members = FALSE, invites = FALSE, join = FALSE, whoami = TRUE, channels = TRUE, history = TRUE, pending = FALSE, mark_read = FALSE, set_identity = FALSE, relogin = FALSE, - files = FALSE, typing = FALSE, e2ee = FALSE, + channel_create = TRUE, leave = FALSE, + # files records the paths it was handed; attachments hands + # them back out of the poll. Both TRUE is what makes loopback + # the round-trip test double for media-carrying consumers. + files = TRUE, attachments = TRUE, typing = FALSE, e2ee = FALSE, identity_override = TRUE, rich_markup = character(), markup_dialects = c("plain", "markdown"), max_message_bytes = NA_integer_) @@ -79,7 +122,8 @@ chat_whoami.chat_loopback <- function(client, ...) { #' @export chat_channels.chat_loopback <- function(client, ...) { - unique(vapply(client$env$log, function(m) m$channel, character(1))) + unique(c(client$env$channels, + vapply(client$env$log, function(m) m$channel, character(1)))) } #' @export diff --git a/R/matrix.R b/R/matrix.R index e9d36e3..e1e6b1f 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -123,6 +123,10 @@ #' \code{mx.api::mx_room_members}. Leave NULL in production. #' @param .join Testing seam: replacement for #' \code{mx.api::mx_room_join}. Leave NULL in production. +#' @param .create Testing seam: replacement for +#' \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 .channels Testing seam: replacement for #' \code{mx.api::mx_rooms}. Leave NULL in production. #' @param .history Testing seam: replacement for @@ -154,6 +158,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .send = NULL, .media = NULL, .typing = NULL, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, + .create = NULL, .leave = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, .edit = NULL, .rich = NULL) { @@ -210,6 +215,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, save_fn = .save, typing_fn = .typing, react_fn = .react, info_fn = .info, members_fn = .members, join_fn = .join, + create_fn = .create, leave_fn = .leave, channels_fn = .channels, history_fn = .history, pending_fn = .pending, read_fn = .read, identity_fn = .identity, edit_fn = .edit, @@ -654,6 +660,26 @@ chat_join.chat_matrix <- function(client, channel, ...) { invisible(as.character(join_fn(sess, channel))) } +#' @export +chat_channel_create.chat_matrix <- function(client, name, ...) { + # Errors propagate, chat_join()'s reasoning: a creation that + # quietly failed leaves the caller sending into a room that does + # not exist. + sess <- mx.client::mx_client_session(client$env$mx) + create_fn <- client$create_fn %||% mx.api::mx_room_create + invisible(as.character(create_fn(sess, name = name, ...))) +} + +#' @export +chat_leave.chat_matrix <- function(client, channel, ...) { + # Errors propagate: a leave that quietly failed keeps delivering a + # room the caller believes it has left. + sess <- mx.client::mx_client_session(client$env$mx) + leave_fn <- client$leave_fn %||% mx.api::mx_room_leave + leave_fn(sess, channel) + invisible(channel) +} + #' @export chat_members.chat_matrix <- function(client, channel, ...) { # Errors propagate. An empty room and an unanswerable question are @@ -719,7 +745,17 @@ chat_capabilities.chat_matrix <- function(client, ...) { invites = matrix_invites_available(), join = TRUE, whoami = TRUE, channels = TRUE, history = TRUE, pending = matrix_invites_available(), mark_read = TRUE, - set_identity = TRUE, relogin = TRUE, files = !isTRUE(client$e2ee), + set_identity = TRUE, relogin = TRUE, + channel_create = TRUE, leave = 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, typing = TRUE, e2ee = isTRUE(client$e2ee), identity_override = FALSE, # Empty on an e2ee client: the Megolm path builds its own HTML diff --git a/R/slack.R b/R/slack.R index ff74c3f..2764f02 100644 --- a/R/slack.R +++ b/R/slack.R @@ -387,7 +387,10 @@ chat_capabilities.chat_slack <- function(client, ...) { invites = FALSE, join = TRUE, whoami = TRUE, channels = TRUE, history = TRUE, pending = FALSE, mark_read = TRUE, set_identity = TRUE, relogin = FALSE, - files = FALSE, typing = FALSE, e2ee = FALSE, + # conversations.create and conversations.leave exist in the + # Web API; this adapter has no verbs for them yet. + channel_create = FALSE, leave = FALSE, + files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, identity_override = TRUE, rich_markup = character(), markup_dialects = c("plain", "markdown"), max_message_bytes = 40000L) diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index db3e4ca..0d8d198 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -313,3 +313,99 @@ local({ expect_error(chat_edit(cl, "#lab", "1", "x"), "not supported by this adapter") }) + +# ---- Channel lifecycle on the reference adapter ---- +# A created channel exists before any traffic: chat_channels() must +# report it, or a consumer that creates and then lists sees its own +# channel missing and creates it again. +local({ + cl <- chat_loopback() + id <- chat_channel_create(cl, "warroom") + expect_identical(id, "warroom") + expect_true("warroom" %in% chat_channels(cl)) + chat_send(cl, id, "first") + expect_identical(chat_history(cl, id)$messages[[1L]]$body, "first") + # Creating twice is an error, not a no-op: the caller has lost + # track of its own state. + expect_error(chat_channel_create(cl, "warroom"), "already exists") +}) + +# Loopback has no membership, so leave refuses rather than pretending. +local({ + lo <- chat_loopback() + expect_true(chat_capabilities(lo)$channel_create) + expect_false(chat_capabilities(lo)$leave) + expect_error(chat_leave(lo, "general"), "not supported by this adapter") +}) + +# An adapter that cannot create says so. +local({ + nothing <- structure(list(), class = c("chat_nothing", "chat_client")) + expect_error(chat_channel_create(nothing, "x"), + "not supported by this adapter") + expect_error(chat_leave(nothing, "x"), "not supported by this adapter") +}) + +# ---- Every adapter answers the lifecycle and media flags ---- +# Same rule as the reaction flags: a flag one adapter reports and +# another omits gives a consumer NULL, and NULL is not FALSE. +local({ + for (adapter in c("chat_loopback", "chat_irc", "chat_slack", + "chat_matrix")) { + m <- getS3method("chat_capabilities", adapter) + caps <- m(structure(list(env = new.env()), class = adapter)) + for (flag in c("channel_create", "leave", "files", "attachments")) { + expect_true(flag %in% names(caps), info = paste(adapter, flag)) + expect_true(is.logical(caps[[flag]]) && length(caps[[flag]]) == 1L, + info = paste(adapter, flag)) + expect_false(is.na(caps[[flag]]), info = paste(adapter, flag)) + } + } +}) + +# ---- Attachment record ---- +att <- chat_attachment("mxc://ex/abc", name = "plot.png", + mime = "image/png", bytes = 1024L) +expect_inherits(att, "chat_attachment") +expect_identical(att$id, "mxc://ex/abc") +expect_true(is.na(chat_attachment("mxc://ex/abc")$sha256)) +expect_error(chat_attachment("")) +expect_error(chat_attachment(NULL)) + +# chat_message() validates attachments: a list of records or nothing. +# A character vector of paths here is a send-side files= that leaked +# through an adapter unconverted, which should fail at construction +# rather than at the first consumer that indexes into it. +local({ + m <- chat_message(id = "1", channel = "c", sender = "ann", body = "x", + ts = Sys.time(), attachments = list(att)) + expect_identical(m$attachments[[1L]]$name, "plot.png") + expect_error(chat_message(id = "1", channel = "c", sender = "ann", + body = "x", ts = Sys.time(), + attachments = "plot.png"), + "chat_attachment") + expect_error(chat_message(id = "1", channel = "c", sender = "ann", + body = "x", ts = Sys.time(), + attachments = list()), + "chat_attachment") +}) + +# Files round-trip on the reference adapter: sent paths come back out +# of the poll as attachment records, which is what makes loopback the +# test double for media-carrying consumers. +local({ + cl <- chat_loopback() + f <- tempfile(fileext = ".png") + writeBin(as.raw(1:16), f) + chat_send(cl, "general", "see plot", files = f) + msg <- chat_poll(cl)$messages[[1L]] + expect_identical(length(msg$attachments), 1L) + expect_identical(msg$attachments[[1L]]$name, basename(f)) + expect_identical(msg$attachments[[1L]]$bytes, 16L) + expect_identical(msg$attachments[[1L]]$path, f) + # A missing file errors rather than sending a message that quietly + # lost its attachment. + expect_error(chat_send(cl, "general", "x", + files = file.path(tempdir(), "not-there.png")), + "no such file") +}) diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 0b06d40..d29c6df 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1469,6 +1469,43 @@ expect_error(chat_join(structure(list(), class = c("chat_nothing", "chat_client")), "!a"), "not supported by this adapter") +# ---- Creating ---- +# The seam replaces mx.api::mx_room_create; adapter-specific options +# (topic, visibility, invitees) ride through ... untouched. +local({ + seen <- NULL + cl <- seam_client(.create = function(session, name, ...) { + seen <<- list(name = name, extra = list(...)) + "!new:ex" + }) + expect_identical(chat_channel_create(cl, "warroom", topic = "t"), + "!new:ex") + expect_identical(seen$name, "warroom") + expect_identical(seen$extra$topic, "t") +}) + +# A failed creation propagates: the caller must not walk away with a +# name it believes is a room. +expect_error( + chat_channel_create(seam_client(.create = function(...) stop("M_LIMIT")), + "warroom"), "M_LIMIT") + +# ---- Leaving ---- +local({ + seen <- NULL + cl <- seam_client(.leave = function(session, room_id) { + seen <<- room_id + NULL + }) + expect_identical(chat_leave(cl, "!a:ex"), "!a:ex") + expect_identical(seen, "!a:ex") +}) + +# A failed leave propagates: doing nothing quietly keeps delivering a +# room the caller believes it has left. +expect_error(chat_leave(seam_client(.leave = function(...) stop("M_UNKNOWN")), + "!a:ex"), "M_UNKNOWN") + # ---- The record ---- iv <- chat_invite(channel = "!a:ex", inviter = "@ann:ex") expect_inherits(iv, "chat_invite") diff --git a/man/chat_attachment.Rd b/man/chat_attachment.Rd new file mode 100644 index 0000000..3b3f3e7 --- /dev/null +++ b/man/chat_attachment.Rd @@ -0,0 +1,53 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_attachment} +\alias{chat_attachment} +\title{Construct a normalized attachment record} +\usage{ +chat_attachment( + id, + name = NA_character_, + mime = NA_character_, + bytes = NA_integer_, + url = NA_character_, + path = NA_character_, + sha256 = NA_character_, + raw = NULL +) +} +\arguments{ +\item{id}{Adapter-native identifier for the content (a Matrix mxc +URI, a Slack file id). The stable handle; everything else here is +description.} + +\item{name}{Filename as the sender labeled it, or NA.} + +\item{mime}{MIME type, or NA when the transport does not say.} + +\item{bytes}{Size in bytes, or NA.} + +\item{url}{A fetchable location, or NA. It may require this +client's credentials; a consumer must not assume it is public.} + +\item{path}{Local filesystem path when the content is already on +disk, or NA.} + +\item{sha256}{Content hash, or NA. Adapters fill it only when the +transport carries one (Matrix encrypted attachments do); they +must not compute it speculatively, because NA meaning +"unverified" is what tells a consumer that needs provenance to +hash at ingest and record the result.} + +\item{raw}{The adapter's platform-native payload.} +} +\value{ +A list with class \code{chat_attachment}. +} +\description{ +Inbound media on a \code{\link{chat_message}}: \code{chat_poll()} +and \code{chat_history()} put these in the message's +\code{attachments} on adapters whose +\code{chat_capabilities()$attachments} is TRUE. +} +\examples{ +chat_attachment("mxc://ex/abc", name = "plot.png", mime = "image/png") +} diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index 6cf661d..4a4e58d 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -21,7 +21,12 @@ A list with at least: \code{threads} (can post into back out of \code{\link{chat_poll}}), \code{join} (\code{\link{chat_join}} works), \code{whoami} (\code{\link{chat_whoami}} works, and with it the default - \code{\link{chat_addressed}}), \code{files}, \code{typing}, + \code{\link{chat_addressed}}), \code{channel_create} + (\code{\link{chat_channel_create}} works), \code{leave} + (\code{\link{chat_leave}} works), \code{files} (outbound: + \code{chat_send(files =)} works), \code{attachments} (inbound: + media comes back out of \code{\link{chat_poll}} as + \code{\link{chat_attachment}} records), \code{typing}, \code{e2ee}, \code{identity_override} (logicals), \code{markup_dialects} (character), \code{max_message_bytes} (integer or NA). diff --git a/man/chat_channel_create.Rd b/man/chat_channel_create.Rd new file mode 100644 index 0000000..8196ff5 --- /dev/null +++ b/man/chat_channel_create.Rd @@ -0,0 +1,30 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_channel_create} +\alias{chat_channel_create} +\title{Create a channel} +\usage{ +chat_channel_create(client, name, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{name}{Character. Human-readable name for the new channel.} + +\item{...}{Adapter-specific options (topic, visibility, invitees).} +} +\value{ +The new channel's identifier, invisibly. Everything else -- + sends, joins, membership -- takes the identifier, not the name, + so the return value is the point of the call. +} +\description{ +Capability-gated: check \code{chat_capabilities()$channel_create}. +Bots open rooms about as often as they are invited to them; a +contract without creation forces every such consumer below the +seam, into adapter-native calls. +} +\examples{ +cl <- chat_loopback() +id <- chat_channel_create(cl, "general") +chat_send(cl, id, "hello") +} diff --git a/man/chat_leave.Rd b/man/chat_leave.Rd new file mode 100644 index 0000000..94d727f --- /dev/null +++ b/man/chat_leave.Rd @@ -0,0 +1,23 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_leave} +\alias{chat_leave} +\title{Leave a channel} +\usage{ +chat_leave(client, channel, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{channel}{Channel/room identifier.} + +\item{...}{Adapter-specific options.} +} +\value{ +The left channel's identifier, invisibly. +} +\description{ +The inverse of \code{\link{chat_join}}: after it returns, the +client stops receiving the channel's traffic, on platforms where +membership is a thing at all. Capability-gated: check +\code{chat_capabilities()$leave}. +} diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 435eb48..1c97b39 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -22,6 +22,8 @@ chat_matrix( .info = NULL, .members = NULL, .join = NULL, + .create = NULL, + .leave = NULL, .channels = NULL, .history = NULL, .pending = NULL, @@ -164,6 +166,12 @@ replacing \code{mx.api::mx_room_name} and \item{.join}{Testing seam: replacement for \code{mx.api::mx_room_join}. Leave NULL in production.} +\item{.create}{Testing seam: replacement for +\code{mx.api::mx_room_create}. Leave NULL in production.} + +\item{.leave}{Testing seam: replacement for +\code{mx.api::mx_room_leave}. Leave NULL in production.} + \item{.channels}{Testing seam: replacement for \code{mx.api::mx_rooms}. Leave NULL in production.} diff --git a/man/chat_message.Rd b/man/chat_message.Rd index c810a47..895140f 100644 --- a/man/chat_message.Rd +++ b/man/chat_message.Rd @@ -16,7 +16,8 @@ chat_message( mentions = NULL, raw = NULL, encrypted = FALSE, - sender_verified = NULL + sender_verified = NULL, + attachments = NULL ) } \arguments{ @@ -71,6 +72,11 @@ verify. On an encrypted message FALSE is a real answer, not a missing one: the payload decrypted, but its claimed sender could not be tied to a verified device, so the identifier is the homeserver's word rather than cryptographic fact.} + +\item{attachments}{List of \code{\link{chat_attachment}} records, +or NULL. Inbound media: what a sent \code{files =} looks like +from the receiving side, on adapters whose +\code{chat_capabilities()$attachments} is TRUE.} } \value{ A list with class \code{chat_message}. From 4d80f96256fa5b81c3216dccc9a020b0170b4696 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 18 Aug 2026 12:44:30 -0500 Subject: [PATCH 2/2] Bump version to 0.0.1.21 --- DESCRIPTION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 30c871b..6a48f25 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.20 -Date: 2026-08-05 +Version: 0.0.1.21 +Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai",