diff --git a/DESCRIPTION b/DESCRIPTION index 6a8af64..1288335 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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"), @@ -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 diff --git a/NAMESPACE b/NAMESPACE index 3baa5af..20a12e9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) @@ -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) @@ -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) diff --git a/R/contract.R b/R/contract.R index a702d04..6148792 100644 --- a/R/contract.R +++ b/R/contract.R @@ -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). @@ -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 diff --git a/R/irc.R b/R/irc.R index c4f5a1c..a03c3ee 100644 --- a/R/irc.R +++ b/R/irc.R @@ -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 diff --git a/R/loopback.R b/R/loopback.R index a69e4ad..8f4a0ec 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -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_) } diff --git a/R/matrix.R b/R/matrix.R index a4bc72b..fe70a5c 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -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 @@ -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)) { @@ -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")) } @@ -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 @@ -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) } @@ -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 @@ -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_) } diff --git a/R/slack.R b/R/slack.R index c2e2ce1..66370f8 100644 --- a/R/slack.R +++ b/R/slack.R @@ -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 @@ -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) } diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index 780a42e..21aa48c 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -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") +}) diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 1bf6ae4..30c138a 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1377,3 +1377,100 @@ expect_error(chat_reaction(id = "$r", channel = "!c", sender = "@a", target = NULL, key = "y", ts = as.POSIXct(NA))) expect_error(chat_reaction(id = "$r", channel = "!c", sender = "@a", target = "$m", key = NULL, ts = as.POSIXct(NA))) + +# ---- Invites ---- +# The last thing corteza read off chat_poll()$raw. It gates on who sent +# an invitation, so the record has to carry that or the reach-through +# does not go away. + +inv_sync <- function(...) { + rooms <- list(...) + list(rooms = list(invite = rooms)) +} +inv_state <- function(sender, self = "@bot:ex") { + list(invite_state = list(events = list( + list(type = "m.room.member", state_key = self, sender = sender, + content = list(membership = "invite"))))) +} + +if (requireNamespace("mx.client", quietly = TRUE) && + "mx_extract_invite_records" %in% getNamespaceExports("mx.client")) { + + res <- chat_poll(seam_client(sync = inv_sync(`!a:ex` = inv_state("@ann:ex")))) + expect_identical(length(res$invites), 1L) + inv <- res$invites[[1L]] + expect_inherits(inv, "chat_invite") + expect_identical(inv$channel, "!a:ex") + expect_identical(inv$inviter, "@ann:ex") + + # An invite whose sender cannot be read reports NA, not a guess and + # not a missing field. A consumer gating on the sender has to tell + # "someone I do not trust" from "I could not tell" -- both refuse, + # for different reasons. + res <- chat_poll(seam_client(sync = inv_sync(`!b:ex` = list()))) + expect_true(is.na(res$invites[[1L]]$inviter)) + expect_identical(res$invites[[1L]]$channel, "!b:ex") + + # Several at once, in the order the homeserver listed them. + res <- chat_poll(seam_client(sync = inv_sync( + `!a:ex` = inv_state("@ann:ex"), `!b:ex` = inv_state("@bob:ex")))) + expect_identical(vapply(res$invites, function(i) i$channel, character(1)), + c("!a:ex", "!b:ex")) + + # No invites is an empty list, not NULL: a consumer looping over it + # should not have to test for both. + res <- chat_poll(seam_client(sync = wrap_sync(list(ev("$1"))))) + expect_identical(res$invites, list()) + expect_false(is.null(res$invites)) + + # An invitation is not a message, and does not appear as one. + res <- chat_poll(seam_client(recs = list(rec("$m1")), + sync = inv_sync(`!a:ex` = inv_state("@ann:ex")))) + expect_identical(length(res$messages), 1L) + expect_identical(length(res$invites), 1L) + expect_identical(res$messages[[1L]]$id, "$m1") +} + +# ---- Joining ---- +local({ + seen <- NULL + cl <- seam_client(.join = function(session, room_id) { + seen <<- room_id + "!a:ex" + }) + expect_identical(chat_join(cl, "!a:ex"), "!a:ex") + expect_identical(seen, "!a:ex") +}) + +# A failed join propagates. Silently doing nothing would leave the caller +# believing it is in a room it will never hear a word from, which looks +# exactly like an idle room. +expect_error(chat_join(seam_client(.join = function(...) stop("M_FORBIDDEN")), + "!a:ex"), "M_FORBIDDEN") + +# An adapter that cannot join says so. +expect_error(chat_join(structure(list(), class = c("chat_nothing", + "chat_client")), "!a"), + "not supported by this adapter") + +# ---- The record ---- +iv <- chat_invite(channel = "!a:ex", inviter = "@ann:ex") +expect_inherits(iv, "chat_invite") +expect_identical(iv$channel, "!a:ex") +# The inviter defaults to NA, never NULL: a record that reached a +# consumer with no inviter field would read as "no gate needed". +expect_true(is.na(chat_invite(channel = "!a:ex")$inviter)) +expect_true(is.na(chat_invite(channel = "!a:ex", inviter = NULL)$inviter)) +# There is no ts. An invitation is a standing state, and Matrix's +# stripped invite state carries no reliable origin_server_ts -- a field +# that could only ever be NA is worse than no field. +expect_false("ts" %in% names(iv)) +expect_error(chat_invite(channel = NULL)) + +# ---- Capabilities ---- +local({ + caps <- chat_capabilities(seam_client()) + expect_true(is.logical(caps$invites)) + expect_true(caps$join) + expect_identical(caps$invites, chat.api:::matrix_invites_available()) +}) diff --git a/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index dfdf02f..08b6780 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -372,3 +372,36 @@ local({ expect_true(caps$channel_info) expect_true(caps$members) }) + +# ---- Joining ---- +# Slack splits what Matrix bundles: a bot is added to a private channel +# by a member, which arrives as no event this adapter can see, but it can +# join an open one itself. So `invites` is FALSE and `join` is TRUE -- +# the asymmetry is the point of having two flags. + +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., body = NULL, .method, token) { + seen <<- list(path = path, body = body, .method = .method) + list(ok = TRUE, channel = list(id = "C1")) + }) + expect_identical(chat_join(cl, "#lab"), "lab") + expect_identical(seen$path, "/api/conversations.join") + expect_identical(seen$.method, "POST") + # In the body, not the dots: call_slack_api() drops dots on POST. + expect_identical(seen$body$channel, "lab") +}) + +# A refusal is an error, not a silent non-join. "already_in_channel" is +# not what this returns for -- Slack answers ok = TRUE for that. +expect_error(chat_join(slack_api_client(function(...) { + list(ok = FALSE, error = "channel_not_found") +}), "lab"), "Slack refused conversations.join: channel_not_found") + +local({ + caps <- chat_capabilities(chat_slack(channels = "lab", token = "t", + .history = function(...) NULL, + .post = function(...) "1")) + expect_false(caps$invites) + expect_true(caps$join) +}) diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index abf5052..589ece0 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -17,7 +17,9 @@ A list with at least: \code{threads} (can post into (\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). diff --git a/man/chat_invite.Rd b/man/chat_invite.Rd new file mode 100644 index 0000000..e343b80 --- /dev/null +++ b/man/chat_invite.Rd @@ -0,0 +1,33 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_invite} +\alias{chat_invite} +\title{Construct a normalized invitation record} +\usage{ +chat_invite(channel, inviter = NA_character_, raw = NULL) +} +\arguments{ +\item{channel}{Channel/room identifier the client has been invited to +(character). Pass it to \code{\link{chat_join}} to accept.} + +\item{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.} + +\item{raw}{The adapter's platform-native payload.} +} +\value{ +A list with class \code{chat_invite}. +} +\description{ +The record \code{\link{chat_poll}} returns in \code{$invites} on +adapters whose \code{chat_capabilities()$invites} is TRUE. +} +\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. +} diff --git a/man/chat_join.Rd b/man/chat_join.Rd new file mode 100644 index 0000000..1284764 --- /dev/null +++ b/man/chat_join.Rd @@ -0,0 +1,30 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_join} +\alias{chat_join} +\title{Join a channel} +\usage{ +chat_join(client, channel, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{channel}{Channel/room identifier, as carried on a +\code{\link{chat_invite}}'s \code{channel} or resolved by +\code{\link{chat_resolve}}.} + +\item{...}{Adapter-specific options.} +} +\value{ +The joined channel's identifier, invisibly. +} +\description{ +Accepts a pending invitation, or joins an open channel where the +platform allows it. +} +\details{ +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}. + +} diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 8c65e31..05fcae8 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -20,7 +20,8 @@ chat_matrix( .save = NULL, .react = NULL, .info = NULL, - .members = NULL + .members = NULL, + .join = NULL ) } \arguments{ @@ -152,6 +153,9 @@ replacing \code{mx.api::mx_room_name} and \item{.members}{Testing seam: replacement for \code{mx.api::mx_room_members}. Leave NULL in production.} + +\item{.join}{Testing seam: replacement for +\code{mx.api::mx_room_join}. Leave NULL in production.} } \value{ A \code{chat_client} of class \code{chat_matrix}.