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,7 +1,7 @@
Package: chat.api
Type: Package
Title: Transport-Agnostic Chat Contract for R Agents
Version: 0.0.1.13
Version: 0.0.1.14
Date: 2026-08-05
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
Expand All @@ -22,7 +22,7 @@ BugReports: https://github.com/cornball-ai/chat.api/issues
Suggests:
httr,
mx.api,
mx.client (>= 0.2.0.3),
mx.client (>= 0.2.0.4),
mx.crypto,
slackr,
tinytest
Expand Down
6 changes: 6 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
export(chat_capabilities)
export(chat_channel_info)
export(chat_disconnect)
export(chat_invite)
export(chat_irc)
export(chat_join)
export(chat_loopback)
export(chat_matrix)
export(chat_members)
Expand All @@ -25,6 +27,9 @@ S3method(chat_channel_info,chat_slack)
S3method(chat_channel_info,default)
S3method(chat_disconnect,chat_irc)
S3method(chat_disconnect,default)
S3method(chat_join,chat_matrix)
S3method(chat_join,chat_slack)
S3method(chat_join,default)
S3method(chat_members,chat_matrix)
S3method(chat_members,chat_slack)
S3method(chat_members,default)
Expand All @@ -45,5 +50,6 @@ S3method(chat_send,chat_matrix)
S3method(chat_send,chat_slack)
S3method(chat_typing,chat_matrix)
S3method(chat_typing,default)
S3method(print,chat_invite)
S3method(print,chat_message)
S3method(print,chat_reaction)
69 changes: 68 additions & 1 deletion R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ chat_resolve <- function(client, name, ...) {
#' (\code{\link{chat_react}} works), \code{reaction_events} (reactions
#' come back out of \code{\link{chat_poll}}), \code{channel_info}
#' (\code{\link{chat_channel_info}} works), \code{members}
#' (\code{\link{chat_members}} works), \code{files}, \code{typing},
#' (\code{\link{chat_members}} works), \code{invites} (invitations come
#' back out of \code{\link{chat_poll}}), \code{join}
#' (\code{\link{chat_join}} works), \code{files}, \code{typing},
#' \code{e2ee}, \code{identity_override} (logicals),
#' \code{markup_dialects} (character), \code{max_message_bytes}
#' (integer or NA).
Expand Down Expand Up @@ -149,6 +151,71 @@ chat_react.default <- function(client, channel, message_id, key, ...) {
"). Check chat_capabilities()$reactions.", call. = FALSE)
}

#' Join a channel
#'
#' Accepts a pending invitation, or joins an open channel where the
#' platform allows it.
#'
#' The default method throws, on the same reasoning as
#' \code{\link{chat_react}}: a join that silently does nothing leaves the
#' caller believing it is in a room it will never hear from. Check
#' \code{chat_capabilities()$join}.
#'
#' @param client A \code{chat_client}.
#' @param channel Channel/room identifier, as carried on a
#' \code{\link{chat_invite}}'s \code{channel} or resolved by
#' \code{\link{chat_resolve}}.
#' @param ... Adapter-specific options.
#' @return The joined channel's identifier, invisibly.
#' @export
chat_join <- function(client, channel, ...) {
UseMethod("chat_join")
}

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

#' Construct a normalized invitation record
#'
#' The record \code{\link{chat_poll}} returns in \code{$invites} on
#' adapters whose \code{chat_capabilities()$invites} is TRUE.
#'
#' @param channel Channel/room identifier the client has been invited to
#' (character). Pass it to \code{\link{chat_join}} to accept.
#' @param inviter Who issued the invitation, or \code{NA} when the
#' transport does not say. NA rather than NULL, and deliberately: a
#' consumer deciding whether to accept has to tell "someone I do not
#' trust" from "I could not tell who", and both refuse for different
#' reasons. NULL would collapse the second into the absence of a field.
#' @param raw The adapter's platform-native payload.
#' @return A list with class \code{chat_invite}.
#'
#' @section No timestamp:
#' Unlike \code{\link{chat_message}} and \code{\link{chat_reaction}},
#' there is no \code{ts}. An invitation is a standing state rather than
#' an event at a moment, and Matrix's stripped invite state carries no
#' reliable \code{origin_server_ts} to report. A field that could only
#' ever be NA is worse than no field.
#' @export
chat_invite <- function(channel, inviter = NA_character_, raw = NULL) {
stopifnot(is.character(channel))
structure(list(channel = channel,
inviter = if (is.null(inviter)) NA_character_ else inviter,
raw = raw),
class = "chat_invite")
}

#' @export
print.chat_invite <- function(x, ...) {
cat(sprintf("invite to %s from %s\n", x$channel,
if (is.na(x$inviter)) "someone unknown" else x$inviter))
invisible(x)
}

#' Describe a channel
#'
#' Returns the channel's descriptive metadata: what it is called and what
Expand Down
6 changes: 3 additions & 3 deletions R/irc.R
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ chat_resolve.chat_irc <- function(client, name, ...) {
chat_capabilities.chat_irc <- function(client, ...) {
list(threads = FALSE, thread_replies = FALSE, edits = FALSE,
reactions = FALSE, reaction_events = FALSE, channel_info = FALSE,
members = FALSE, files = FALSE, typing = FALSE, e2ee = FALSE,
identity_override = FALSE, markup_dialects = "plain",
max_message_bytes = 400L)
members = FALSE, invites = FALSE, join = FALSE, files = FALSE,
typing = FALSE, e2ee = FALSE, identity_override = FALSE,
markup_dialects = "plain", max_message_bytes = 400L)
}

#' @export
Expand Down
5 changes: 3 additions & 2 deletions R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ chat_resolve.chat_loopback <- function(client, name, ...) {
chat_capabilities.chat_loopback <- function(client, ...) {
list(threads = TRUE, thread_replies = TRUE, edits = FALSE,
reactions = FALSE, reaction_events = FALSE, channel_info = FALSE,
members = FALSE, files = FALSE, typing = FALSE, e2ee = FALSE,
identity_override = TRUE, markup_dialects = c("plain", "markdown"),
members = FALSE, invites = FALSE, join = FALSE, files = FALSE,
typing = FALSE, e2ee = FALSE, identity_override = TRUE,
markup_dialects = c("plain", "markdown"),
max_message_bytes = NA_integer_)
}
65 changes: 52 additions & 13 deletions R/matrix.R
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@
#' \code{mx.api::mx_room_topic}. Leave NULL in production.
#' @param .members Testing seam: replacement for
#' \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.
#' @return A \code{chat_client} of class \code{chat_matrix}.
#' \code{\link{chat_poll}} on this class returns \code{first_run} and
#' \code{client} alongside \code{messages}, \code{cursor}, and
Expand All @@ -136,7 +138,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
crypto_store = NULL, .sync = NULL, .extract = NULL,
.send = NULL, .media = NULL, .typing = NULL,
.crypto = NULL, .save = NULL, .react = NULL,
.info = NULL, .members = NULL) {
.info = NULL, .members = NULL, .join = NULL) {
seams <- list(.sync, .extract, .send, .media)
if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) &&
!requireNamespace("mx.client", quietly = TRUE)) {
Expand Down Expand Up @@ -189,7 +191,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
# on the deferred-save path, which only e2ee reaches.
save_fn = .save,
typing_fn = .typing, react_fn = .react,
info_fn = .info, members_fn = .members,
info_fn = .info, members_fn = .members, join_fn = .join,
crypto_ops = matrix_crypto_ops(.crypto)),
class = c("chat_matrix", "chat_client"))
}
Expand Down Expand Up @@ -269,6 +271,29 @@ matrix_reactions_available <- function() {
"mx_extract_reactions" %in% getNamespaceExports("mx.client")
}

# The sync's pending invites, as contract records.
#
# mx_extract_invite_records() carries the inviter, which is the whole of
# whether an invite should be accepted -- and NA when the stripped state
# does not say, which a consumer has to be able to tell from a sender it
# simply does not trust. Passed through unchanged for that reason.
matrix_invites <- function(client, sync) {
if (!matrix_invites_available()) {
return(list())
}
recs <- mx.client::mx_extract_invite_records(sync,
self_id = client$env$mx$user_id)
lapply(recs, function(r) {
chat_invite(channel = as.character(r$room_id), inviter = r$inviter,
raw = r)
})
}

matrix_invites_available <- function() {
requireNamespace("mx.client", quietly = TRUE) &&
"mx_extract_invite_records" %in% getNamespaceExports("mx.client")
}

# Sort messages or reactions into sync order, by their own event id.
# Unpositioned events keep their arrival order at the end rather than
# being dropped or interleaved by guesswork, and the tie-break on index
Expand Down Expand Up @@ -433,18 +458,20 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) {
# own pre-poll copy would spend the rest of the cycle authenticating
# with the token the homeserver just rejected.
#
# raw carries the full sync response for Matrix-specific consumers
# (invites) that the generic contract does not model yet. E2EE and
# reactions no longer need it.
# raw is the untouched sync response, kept as an escape hatch for
# whatever the contract still does not model. Nothing this adapter
# reports needs it any more: messages, reactions and invites are all
# records.
#
# reactions follows messages exactly: returned on a first run too,
# with first_run left to say what they are. A consumer that acts on
# reactions has the same backfill problem it has with messages and
# solves it the same way -- what it must not have to learn is that
# one of the two lists plays by a different rule.
# reactions and invites follow messages exactly: returned on a first
# run too, with first_run left to say what they are. A consumer that
# acts on either has the same backfill problem it has with messages
# and solves it the same way -- what it must not have to learn is
# that one of the three lists plays by a different rule.
list(messages = messages, cursor = res$client$sync_token,
first_run = isTRUE(res$first_run), client = res$client,
reactions = matrix_reactions(client, res$sync, event_pos),
invites = matrix_invites(client, res$sync),
raw = res$sync)
}

Expand Down Expand Up @@ -568,6 +595,16 @@ chat_channel_info.chat_matrix <- function(client, channel, ...) {
list(id = channel, name = absent(info$name), topic = absent(info$topic))
}

#' @export
chat_join.chat_matrix <- function(client, channel, ...) {
# Errors propagate. A join that quietly failed leaves the caller
# believing it is in a room it will never hear a word from, which is
# indistinguishable from an idle room.
join_fn <- client$join_fn %||% mx.api::mx_room_join
invisible(as.character(
join_fn(mx.client::mx_client_session(client$env$mx), channel)))
}

#' @export
chat_members.chat_matrix <- function(client, channel, ...) {
# Errors propagate. An empty room and an unanswerable question are
Expand Down Expand Up @@ -625,9 +662,11 @@ chat_capabilities.chat_matrix <- function(client, ...) {
# that fails in exactly the rooms such a client exists for.
list(threads = FALSE, thread_replies = FALSE, edits = FALSE,
reactions = TRUE, reaction_events = matrix_reactions_available(),
channel_info = TRUE, members = TRUE, files = !isTRUE(client$e2ee),
typing = TRUE, e2ee = isTRUE(client$e2ee),
identity_override = FALSE, markup_dialects = c("plain", "markdown"),
channel_info = TRUE, members = TRUE,
invites = matrix_invites_available(), join = TRUE,
files = !isTRUE(client$e2ee), typing = TRUE,
e2ee = isTRUE(client$e2ee), identity_override = FALSE,
markup_dialects = c("plain", "markdown"),
max_message_bytes = NA_integer_)
}

Expand Down
22 changes: 21 additions & 1 deletion R/slack.R
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,20 @@ chat_channel_info.chat_slack <- function(client, channel, ...) {
topic = blank(ch$topic$value) %||% blank(ch$purpose$value))
}

#' @export
chat_join.chat_slack <- function(client, channel, ...) {
# conversations.join, which works for a public channel the bot is not
# in yet. It cannot join a private one: Slack requires a member to add
# the bot, and that arrives as no event this adapter can see -- which
# is why `invites` is FALSE here while `join` is TRUE.
api <- client$api_fn %||% slackr::call_slack_api
body <- slack_body(api("/api/conversations.join", .method = "POST",
token = client$token,
body = list(channel = sub("^#", "", channel))))
slack_stop_for_error(body, "conversations.join")
invisible(sub("^#", "", channel))
}

#' @export
chat_members.chat_slack <- function(client, channel, ...) {
api <- client$api_fn %||% slackr::call_slack_api
Expand Down Expand Up @@ -364,7 +378,13 @@ chat_capabilities.chat_slack <- function(client, ...) {
# a different transport than this one.
list(threads = TRUE, thread_replies = FALSE, edits = FALSE,
reactions = TRUE, reaction_events = FALSE, channel_info = TRUE,
members = TRUE, files = FALSE, typing = FALSE, e2ee = FALSE,
members = TRUE,
# A Slack bot is added to a channel rather than invited, and
# conversations.history says nothing about it -- there is no
# invitation to hand a consumer. Joining an open channel is a
# different thing, and does work.
invites = FALSE, join = TRUE,
files = FALSE, typing = FALSE, e2ee = FALSE,
identity_override = TRUE, markup_dialects = c("plain", "markdown"),
max_message_bytes = 40000L)
}
31 changes: 31 additions & 0 deletions inst/tinytest/test_contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,34 @@ local({
expect_true(caps$channel_info && caps$members)
expect_false("members" %in% names(formals(chat_channel_info)))
})

# ---- Every adapter answers the invite pair ----
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("invites", "join")) {
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))
}
}
})

# Receiving invitations and being able to join are separate: Slack does
# the second and not the first. A single flag would have a consumer
# either wait for invitations that never arrive or refuse to join a
# channel it could.
local({
slack <- getS3method("chat_capabilities", "chat_slack")(NULL)
expect_false(slack$invites)
expect_true(slack$join)
})

local({
lo <- chat_loopback()
expect_false(chat_capabilities(lo)$join)
expect_error(chat_join(lo, "c"), "not supported by this adapter")
})
Loading
Loading