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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Package: chat.api
Type: Package
Title: Transport-Agnostic Chat Contract for R Agents
Version: 0.0.1.15
Version: 0.0.1.17
Date: 2026-08-05
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
Expand Down
31 changes: 31 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,31 @@
export(chat_addressed)
export(chat_capabilities)
export(chat_channel_info)
export(chat_channels)
export(chat_config)
export(chat_config_save)
export(chat_disconnect)
export(chat_history)
export(chat_identity)
export(chat_invite)
export(chat_irc)
export(chat_join)
export(chat_loopback)
export(chat_mark_read)
export(chat_matrix)
export(chat_matrix_config)
export(chat_matrix_config_path)
export(chat_matrix_configure)
export(chat_members)
export(chat_message)
export(chat_pending)
export(chat_poll)
export(chat_react)
export(chat_reaction)
export(chat_relogin)
export(chat_resolve)
export(chat_send)
export(chat_set_identity)
export(chat_slack)
export(chat_typing)
export(chat_whoami)
Expand All @@ -32,21 +43,36 @@ S3method(chat_capabilities,chat_slack)
S3method(chat_channel_info,chat_matrix)
S3method(chat_channel_info,chat_slack)
S3method(chat_channel_info,default)
S3method(chat_channels,chat_loopback)
S3method(chat_channels,chat_matrix)
S3method(chat_channels,chat_slack)
S3method(chat_channels,default)
S3method(chat_disconnect,chat_irc)
S3method(chat_disconnect,default)
S3method(chat_history,chat_loopback)
S3method(chat_history,chat_matrix)
S3method(chat_history,chat_slack)
S3method(chat_history,default)
S3method(chat_join,chat_matrix)
S3method(chat_join,chat_slack)
S3method(chat_join,default)
S3method(chat_mark_read,chat_matrix)
S3method(chat_mark_read,chat_slack)
S3method(chat_mark_read,default)
S3method(chat_members,chat_matrix)
S3method(chat_members,chat_slack)
S3method(chat_members,default)
S3method(chat_pending,chat_matrix)
S3method(chat_pending,default)
S3method(chat_poll,chat_irc)
S3method(chat_poll,chat_loopback)
S3method(chat_poll,chat_matrix)
S3method(chat_poll,chat_slack)
S3method(chat_react,chat_matrix)
S3method(chat_react,chat_slack)
S3method(chat_react,default)
S3method(chat_relogin,chat_matrix)
S3method(chat_relogin,default)
S3method(chat_resolve,chat_irc)
S3method(chat_resolve,chat_loopback)
S3method(chat_resolve,chat_matrix)
Expand All @@ -55,13 +81,18 @@ S3method(chat_send,chat_irc)
S3method(chat_send,chat_loopback)
S3method(chat_send,chat_matrix)
S3method(chat_send,chat_slack)
S3method(chat_set_identity,chat_irc)
S3method(chat_set_identity,chat_matrix)
S3method(chat_set_identity,chat_slack)
S3method(chat_set_identity,default)
S3method(chat_typing,chat_matrix)
S3method(chat_typing,default)
S3method(chat_whoami,chat_irc)
S3method(chat_whoami,chat_loopback)
S3method(chat_whoami,chat_matrix)
S3method(chat_whoami,chat_slack)
S3method(chat_whoami,default)
S3method(print,chat_config)
S3method(print,chat_identity)
S3method(print,chat_invite)
S3method(print,chat_message)
Expand Down
200 changes: 200 additions & 0 deletions R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -537,3 +537,203 @@ identity_mentioned <- function(id, message) {
escape_rx <- function(x) {
gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", x)
}

#' List the channels this client is in
#'
#' The state half of the contract. \code{\link{chat_poll}} answers "what
#' changed since my cursor"; this and its siblings answer "what is true
#' now", which is the question a process asks when it starts up with no
#' useful cursor at all.
#'
#' @param client A \code{chat_client}.
#' @param ... Adapter-specific options.
#' @return Character vector of channel identifiers.
#' @examples
#' chat_channels(chat_loopback())
#' @export
chat_channels <- function(client, ...) {
UseMethod("chat_channels")
}

#' @export
chat_channels.default <- function(client, ...) {
stop("chat_channels() is not supported by this adapter (",
paste(class(client), collapse = "/"),
"). Check chat_capabilities()$channels.", call. = FALSE)
}

#' Read a channel's recent messages
#'
#' Independent of the poll cursor: a restarted process uses this to
#' recover the context it lost, and asking for it must not move the
#' cursor or consume anything.
#'
#' @param client A \code{chat_client}.
#' @param channel Channel/room identifier.
#' @param limit Maximum messages to return.
#' @param cursor Opaque continuation token from a previous call's
#' \code{cursor}, to read the page before it; NULL starts from the most
#' recent.
#' @param ... Adapter-specific options.
#' @return A list with \code{messages} (list of \code{\link{chat_message}},
#' oldest first) and \code{cursor} (opaque; pass it back to read
#' further into the past, NULL when the channel has no more history).
#'
#' @section The cursor is opaque, like chat_poll's:
#' Not a message id. This started out taking one and it was wrong on the
#' reference transport: Matrix's \code{/messages} takes a pagination
#' token from a previous response, and handing it an event id does not
#' page from that event -- it fails, or worse, silently returns the wrong
#' window. Slack pages by its own \code{next_cursor}. There is no id that
#' means the same thing on both, so the contract does what it already
#' does for \code{\link{chat_poll}}: the token is the adapter's, and a
#' consumer only ever passes back what it was given.
#'
#' @section Order:
#' Chronological, oldest first, whatever the platform's native direction
#' is. Matrix \code{dir = "b"} and Slack \code{conversations.history}
#' both hand back newest-first and every consumer replaying history into
#' a transcript has to flip it. One flip in the adapter beats one per
#' consumer, and a consumer that gets it wrong produces a transcript
#' that reads backwards without erroring.
#'
#' Note that pages run backwards while each page runs forwards: call it
#' twice and the second page's messages all precede the first page's.
#' A consumer assembling a full transcript prepends.
#'
#' @section Overlap with chat_poll:
#' The same message can arrive from both, and adapters must return the
#' same \code{id} for it either way. That id is the only thing a consumer
#' has to deduplicate on -- a startup backfill and the first poll after
#' it routinely cover the same events.
#' @examples
#' cl <- chat_loopback()
#' chat_send(cl, "general", "hello")
#' chat_history(cl, "general")$messages
#' @export
chat_history <- function(client, channel, limit = 50L, cursor = NULL, ...) {
UseMethod("chat_history")
}

#' @export
chat_history.default <- function(client, channel, limit = 50L, cursor = NULL,
...) {
stop("chat_history() is not supported by this adapter (",
paste(class(client), collapse = "/"),
"). Check chat_capabilities()$history.", call. = FALSE)
}

#' Read standing state that is not tied to a cursor
#'
#' Today: pending invitations. \code{\link{chat_poll}} reports an
#' invitation when it arrives, which is no help to a client that was not
#' running at the time -- and some homeservers only report invites newer
#' than the \code{since} token, so the poll loop never sees them again.
#'
#' This is a separate verb rather than a mode of \code{\link{chat_poll}}
#' deliberately. Overloading the cursor would make "start from nothing"
#' and "tell me what is standing" the same call, and a client that asked
#' for pending invitations and thereby reset its read position would
#' replay every channel it is in.
#'
#' @param client A \code{chat_client}.
#' @param ... Adapter-specific options.
#' @return A list with \code{invites}, a list of \code{\link{chat_invite}}.
#' @examples
#' \dontrun{
#' pending <- chat_pending(client)
#' for (iv in pending$invites) chat_join(client, iv$channel)
#' }
#' @export
chat_pending <- function(client, ...) {
UseMethod("chat_pending")
}

#' @export
chat_pending.default <- function(client, ...) {
stop("chat_pending() is not supported by this adapter (",
paste(class(client), collapse = "/"),
"). Check chat_capabilities()$pending.", call. = FALSE)
}

#' Mark a message as read
#'
#' The default is a quiet FALSE, on \code{\link{chat_typing}}'s
#' reasoning rather than \code{\link{chat_react}}'s: a read marker that
#' does not appear costs a human a little context about what the bot has
#' seen, and nothing more. Nobody is waiting on it the way they wait on
#' an acknowledgement.
#'
#' Write-only. Reading other participants' read state is a much larger
#' surface -- per-user, per-device, and absent entirely on some
#' platforms -- and no consumer needs it yet.
#'
#' @param client A \code{chat_client}.
#' @param channel Channel/room identifier.
#' @param message_id The message to mark read, and everything before it.
#' @param ... Adapter-specific options.
#' @return TRUE if the marker was sent, FALSE otherwise, invisibly.
#' @export
chat_mark_read <- function(client, channel, message_id, ...) {
UseMethod("chat_mark_read")
}

#' @export
chat_mark_read.default <- function(client, channel, message_id, ...) {
invisible(FALSE)
}

#' Set this client's persistent identity
#'
#' The account's own display name, as everyone in every channel sees it
#' until it is changed again. Distinct from \code{\link{chat_send}}'s
#' \code{identity} argument, which decorates a single message on
#' platforms that allow it.
#'
#' Owning this matters beyond tidiness. On Matrix the rename is an
#' authenticated call that can rotate the access token underneath the
#' caller, and a consumer that made that call itself had to notice the
#' rotation and get the new token back into its client -- usually via
#' whatever file both of them happened to share. Behind the contract the
#' rotation lands in the client that performed it, and nothing outside
#' has to know it happened.
#'
#' @param client A \code{chat_client}.
#' @param display New display name.
#' @param ... Adapter-specific options.
#' @return TRUE if the identity was changed, invisibly.
#' @export
chat_set_identity <- function(client, display, ...) {
UseMethod("chat_set_identity")
}

#' @export
chat_set_identity.default <- function(client, display, ...) {
stop("chat_set_identity() is not supported by this adapter (",
paste(class(client), collapse = "/"),
"). Check chat_capabilities()$set_identity.", call. = FALSE)
}

#' Refresh this client's credentials
#'
#' Forces the re-authentication that adapters otherwise perform on
#' demand. The refreshed credentials stay inside the client.
#'
#' The default throws rather than returning quietly. "I could not
#' refresh" and "there was nothing to refresh" look identical to a
#' caller that gets FALSE, and the first means the next call will fail
#' with a stale token.
#'
#' @param client A \code{chat_client}.
#' @param ... Adapter-specific options.
#' @return TRUE, invisibly.
#' @export
chat_relogin <- function(client, ...) {
UseMethod("chat_relogin")
}

#' @export
chat_relogin.default <- function(client, ...) {
stop("chat_relogin() is not supported by this adapter (",
paste(class(client), collapse = "/"), ").", call. = FALSE)
}
29 changes: 24 additions & 5 deletions R/irc.R
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,25 @@ chat_capabilities.chat_irc <- function(client, ...) {
list(threads = FALSE, thread_replies = FALSE, edits = FALSE,
reactions = FALSE, reaction_events = FALSE, channel_info = FALSE,
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,
identity_override = FALSE, markup_dialects = "plain",
max_message_bytes = 400L)
}

#' @export
chat_whoami.chat_irc <- function(client, ...) {
# The nick this client sent in its NICK line. A server that refused
# it and assigned another (collision, or a nick longer than the
# server allows) has not been read back here -- the 001 welcome
# carries the real one, and this adapter does not parse it yet.
chat_identity(client$nick)
# env first: a chat_set_identity() NICK change during this session
# writes there, and the list field is only what this client was
# constructed with. Reading the constructor's field would keep
# reporting the old nick for the life of the process, so the bot
# would stop recognising its own name.
#
# A server that refused the nick and assigned another (collision, or
# one longer than it allows) is invisible either way: the 001
# welcome carries the real one and this adapter does not parse it.
chat_identity(client$env$nick %||% client$nick)
}

# What may sit next to a nick without being part of it. IRC nicks are
Expand Down Expand Up @@ -170,3 +177,15 @@ chat_disconnect.chat_irc <- function(client, ...) {
tryCatch(close(client$env$con), error = function(e) NULL)
invisible(TRUE)
}

#' @export
chat_set_identity.chat_irc <- function(client, display, ...) {
# IRC has no display name distinct from the nick, so this is a NICK
# change. The server can refuse it (collision, length, restricted
# characters) and says so asynchronously in a numeric this adapter
# does not parse, so the local nick is updated optimistically and
# chat_whoami() can be wrong until the next reconnect.
irc_write(client$env$con, sprintf("NICK %s", display))
client$env$nick <- display
invisible(TRUE)
}
41 changes: 41 additions & 0 deletions R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ chat_capabilities.chat_loopback <- function(client, ...) {
list(threads = TRUE, thread_replies = TRUE, edits = FALSE,
reactions = FALSE, reaction_events = FALSE, channel_info = FALSE,
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,
identity_override = TRUE, markup_dialects = c("plain", "markdown"),
max_message_bytes = NA_integer_)
Expand All @@ -72,3 +74,42 @@ chat_whoami.chat_loopback <- function(client, ...) {
# something stable to compare against across a test.
chat_identity("loopback")
}

#' @export
chat_channels.chat_loopback <- function(client, ...) {
unique(vapply(client$env$log, function(m) m$channel, character(1)))
}

#' @export
chat_history.chat_loopback <- function(client, channel, limit = 50L,
cursor = NULL, ...) {
log <- client$env$log
keep <- vapply(log, function(m) identical(m$channel, channel), logical(1))
log <- log[keep]
# The cursor is a count of how many of this channel's messages the
# caller has already seen from the end. An integer, deliberately
# opaque: the reference adapter is what a new adapter is read as an
# example, and one that paged by message id would teach the wrong
# thing -- Matrix cannot do that at all.
seen <- if (is.null(cursor)) {
0L
} else {
as.integer(cursor)
}
if (seen > 0L) {
log <- if (seen >= length(log)) {
list()
} else {
log[seq_len(length(log) - seen)]
}
}
# The tail, still oldest-first. limit trims the far end, not the near
# one: "the last 20 messages" means the 20 most recent.
if (length(log) > limit) {
log <- log[seq.int(length(log) - limit + 1L, length(log))]
nxt <- seen + limit
} else {
nxt <- NULL
}
list(messages = log, cursor = nxt)
}
Loading
Loading