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
4 changes: 2 additions & 2 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.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",
Expand Down
9 changes: 9 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
124 changes: 121 additions & 3 deletions R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion R/irc.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
50 changes: 47 additions & 3 deletions R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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)
}
Expand Down Expand Up @@ -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_)
Expand All @@ -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
Expand Down
38 changes: 37 additions & 1 deletion R/matrix.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading