diff --git a/.Rbuildignore b/.Rbuildignore
index 94520f2..5756486 100644
--- a/.Rbuildignore
+++ b/.Rbuildignore
@@ -1,4 +1,6 @@
^CLAUDE\.md$
+^AGENTS\.md$
+^cran-comments\.md$
^DESIGN\.md$
^tasks$
^\.git$
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 4adacd5..0db52f4 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -4,11 +4,12 @@ on:
pull_request:
env:
- _R_CHECK_FORCE_SUGGESTS_: "false"
+ _R_CHECK_FORCE_SUGGESTS_: "true"
jobs:
ci:
strategy:
+ fail-fast: false
matrix:
include:
- {os: macos-latest}
@@ -24,79 +25,58 @@ jobs:
with:
backend: RAPT
- - name: Dependencies
- run: ./run.sh install_deps
-
- # test_matrix_mxclient.R exit_file()s without mx.client, and that
- # file holds the direct tests for the crypto boundary functions --
- # the encryption-state lookup and the encrypted send, both of which
- # leak if they fail open. They sit behind chat_matrix()'s .crypto
- # seam, so nothing in test_matrix.R can reach them. install_deps
- # takes hard dependencies only, so without this they skip
- # themselves into a green check.
- #
- # curl and jsonlite come first and explicitly: chat.api has no
- # dependencies of its own, so install_deps brings in nothing, and
- # mx.api needs both.
- - name: Install Matrix stack
- run: |
- Rscript -e 'install.packages(c("curl", "jsonlite"))'
- for pkg in mx.api mx.client; do
- git clone --depth 1 "https://github.com/cornball-ai/$pkg.git" "$RUNNER_TEMP/$pkg"
- R CMD INSTALL "$RUNNER_TEMP/$pkg"
- done
-
- # The development crypto APIs come from drat after the upstream PR
- # lands. Bypass rapt's older r2u binary and build the vendored sources
- # with the Linux runner's Rust toolchain. Keep the existing platform
- # split: macOS exercises the Matrix adapter without optional crypto.
- - name: Install mx.crypto (Linux)
+ - name: Install Linux build prerequisites
if: runner.os == 'Linux'
+ run: sudo apt-get install -y --no-install-recommends libcurl4-openssl-dev
+
+ # Use CRAN sources for Matrix so binary rebuilds cannot select older
+ # versions. Include all adapter test packages from CRAN as well.
+ - name: Install CRAN dependencies
run: |
Rscript -e '
if (isTRUE(getOption("rapt.enabled"))) rapt::disable()
- utils::install.packages("mx.crypto",
- repos = "https://cornball-ai.github.io/drat",
- type = "source", dependencies = FALSE)
- if (!requireNamespace("mx.crypto", quietly = TRUE) ||
- utils::packageVersion("mx.crypto") < "0.2.1.1")
- stop("CI needs mx.crypto >= 0.2.1.1; publish it to drat first")'
+ remotes::install_cran(c("mx.api", "mx.crypto", "mx.client"),
+ repos = "https://cran.r-project.org",
+ type = "source", upgrade = "never")
+ remotes::install_deps(".", dependencies = TRUE,
+ repos = "https://cran.r-project.org",
+ upgrade = "never")'
- # Fail loudly rather than letting the suite skip. mx.crypto is
- # reported but not required, since the macOS leg does without it.
- #
- # The mx.client floor is enforced, not just declared: the adapter
- # calls straight into mx_send_encrypted() and cannot work around
- # what an older one gets wrong. Every step of reading that floor is
- # checked, because a parse that quietly finds nothing would wave
- # through exactly what this is here to catch -- which is what the
- # first version of corteza's equivalent did on macOS.
- - name: Verify Matrix stack
+ # A missing optional package would silently skip its adapter tests.
+ # Check every Suggests entry, including both Matrix version floors.
+ - name: Verify adapter dependencies
run: |
Rscript -e '
- for (p in c("mx.api", "mx.client")) {
- if (!requireNamespace(p, quietly = TRUE))
- stop("chat.api CI needs ", p, ": without it ",
- "test_matrix_mxclient.R skips itself", call. = FALSE)
- }
- sug <- read.dcf("DESCRIPTION")[1L, "Suggests"]
- if (is.na(sug)) stop("no Suggests field in DESCRIPTION")
- e <- grep("^mx[.]client",
- trimws(strsplit(sug, ",")[[1L]]), value = TRUE)
- if (!length(e)) stop("no mx.client entry in Suggests")
- m <- regmatches(e, regexpr("[0-9][0-9.-]*", e))
- if (!length(m)) stop("mx.client Suggests entry declares no floor")
- floor <- package_version(m)
- have <- utils::packageVersion("mx.client")
- message("mx.client ", have, " (>= ", floor, ")")
- if (have < floor)
- stop("mx.client ", have, " is below the ", floor,
- " chat.api declares", call. = FALSE)
- message("mx.api ", utils::packageVersion("mx.api"))
- message("mx.crypto ",
- if (requireNamespace("mx.crypto", quietly = TRUE))
- as.character(utils::packageVersion("mx.crypto"))
- else "absent (crypto-init test will not run)")'
+ suggests <- read.dcf("DESCRIPTION")[1L, "Suggests"]
+ if (is.na(suggests) || !nzchar(suggests))
+ stop("No Suggests field in DESCRIPTION")
+ entries <- trimws(strsplit(suggests, ",", fixed = TRUE)[[1L]])
+ for (entry in entries) {
+ parts <- regmatches(entry, regexec(
+ "^([[:alnum:].]+)( [(]>= ([0-9.]+)[)])?$", entry))[[1L]]
+ if (!length(parts)) stop("Unsupported Suggests entry: ", entry)
+ pkg <- parts[[2L]]
+ if (!requireNamespace(pkg, quietly = TRUE))
+ stop("Adapter tests require ", pkg)
+ have <- utils::packageVersion(pkg)
+ floor <- parts[[4L]]
+ if (nzchar(floor) && have < package_version(floor))
+ stop(pkg, " ", have, " is below the required ", floor)
+ message(pkg, " ", have, " at ", find.package(pkg))
+ }'
- name: Test
run: ./run.sh run_tests
+
+ - name: Fail on check warnings
+ run: |
+ log=chat.api.Rcheck/00check.log
+ if [ ! -f "$log" ]; then
+ echo "Missing $log: R CMD check did not finish" >&2
+ exit 1
+ fi
+ grep -E '^Status:' "$log"
+ if grep -qE '^Status:.*(ERROR|WARNING)' "$log"; then
+ cat "$log" >&2
+ exit 1
+ fi
diff --git a/DESCRIPTION b/DESCRIPTION
index b716269..4eda3f6 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.29
-Date: 2026-09-08
+Title: Transport-Agnostic Chat Contract
+Version: 0.1.0
+Date: 2026-09-11
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
@@ -10,12 +10,12 @@ Authors@R: c(
person("cornball.ai", role = "cph"))
Description: A transport-agnostic contract for chat-room connectivity:
connect, poll, and send against one interface, with adapters for
- 'Matrix', 'Slack', 'Telegram', Internet Relay Chat (IRC), and other
- platforms
- supplied by optional packages. The contract is the union of the
- parameters found across the R chat-client ecosystem, with per-adapter
- capability flags for threads, markup dialects, encryption, and
- per-message identity. Zero dependencies.
+ 'Matrix' , 'Slack' ,
+ 'Telegram' , and Internet Relay
+ Chat (IRC). An in-memory adapter supports local testing. Capability
+ flags describe support for threads, markup dialects, encryption, and
+ per-message identity. Platform clients are supplied by optional
+ packages; the core interface uses only base R.
License: Apache License (>= 2)
Depends: R (>= 4.0)
URL: https://github.com/cornball-ai/chat.api
@@ -23,8 +23,8 @@ BugReports: https://github.com/cornball-ai/chat.api/issues
Suggests:
httr,
mx.api,
- mx.client (>= 0.2.0.8),
- mx.crypto (>= 0.2.1.1),
+ mx.client (>= 0.2.1),
+ mx.crypto (>= 0.2.2),
slackr,
telegram,
tinytest
diff --git a/NEWS.md b/NEWS.md
index 0939bc9..ca957de 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,52 +1,16 @@
-# chat.api 0.0.1.29
-
-* chat_telegram() accepts a telegram::TGBot as `bot`. Requests then go
- through the class's public req(), so its proxy settings apply and the
- token can stay inside the object; attachments are fetched from the
- URL its getFile() returns. Only the transport is borrowed: the class's
- own verbs cannot long-poll, choose update kinds, edit, react, or
- leave, and its parser flattens updates into data frames. telegram
- joins Suggests. The .download testing seam now takes
- (file_id, file_path, dest).
-
-# chat.api 0.0.1.28
-
-* Slack gains user-token identity: chat_slack(user_token =) plus
- as_user = TRUE on chat_send() and chat_whoami() authenticate as an
- actual workspace member instead of the bot, so a send shows up under
- that member's real name and photo rather than the bot's profile.
- Distinct from identity/username, which only relabels the bot's own
- post. Capability flag user_identity reports whether a given Slack
- client was configured with a user token; every other adapter reports
- FALSE.
-
-# chat.api 0.0.1.27
-
-* New Telegram adapter, chat_telegram(), over the Bot API with HTTP
- delegated to the suggested httr package. getUpdates long polling is
- the poll, with a single update offset as the cursor. Sends render
- markdown to Telegram HTML and carry threads, replies, files, and
- silent delivery; edits, emoji reactions and reaction events, typing,
- chat info, leaving, identity, @username addressing, and attachment
- fetch through getFile are wired. Capabilities report what the Bot
- API lacks: history, member and chat lists, read markers, joining,
- and creating.
-
-# chat.api 0.0.1.26
-
-* Slack gains chat_channel_create() and chat_leave(), posting
- conversations.create and conversations.leave through the adapter's API
- seam. Adapter options such as is_private = TRUE pass through to the
- request body, and Slack's own refusals (name_taken, invalid_name,
- not_in_channel) propagate as errors. Both capability flags are now TRUE.
-
-# chat.api 0.0.1.25
-
-* Matrix E2EE saves ratchet state before room-key request transport, retries
- unsent requests with their stable ids, and treats transport failures as
- warnings so a decrypted sync batch is not lost or replayed.
-* Same-user forwarded-key recovery requires a cross-signing chain matching
- the master key in the local crypto store. Missing or unreadable local keys
- leave the user's devices untrusted for recovery.
-* Matrix E2EE now requires mx.client >= 0.2.0.8 and mx.crypto >= 0.2.1.1 for
- durable request handling and local cross-signing key access.
+# chat.api 0.1.0
+
+* First CRAN release.
+* A common interface for polling, sending, editing, reactions, attachments,
+ room membership, history, state, and identity, with capability flags for
+ adapter-specific support.
+* An in-memory loopback adapter for local development and testing, plus
+ adapters for Matrix, IRC, Slack, and Telegram.
+* Matrix supports encrypted messaging, durable room-key requests,
+ verification against local cross-signing keys, and credential persistence
+ through mx.client and mx.crypto.
+* Slack supports channel creation and leaving, bot identity customization,
+ and posting as a workspace member when configured with a user token.
+* Telegram supports long polling, threads, replies, files, edits, reactions,
+ and identity. Requests can use httr directly or a telegram::TGBot object
+ with its configured proxy.
diff --git a/R/chat.api-package.R b/R/chat.api-package.R
new file mode 100644
index 0000000..d449b89
--- /dev/null
+++ b/R/chat.api-package.R
@@ -0,0 +1,16 @@
+#' Transport-agnostic chat connectivity
+#'
+#' A common interface for chat messages, attachments, rooms, and identity.
+#' Use \code{\link{chat_loopback}} for local development, or connect through
+#' \code{\link{chat_matrix}}, \code{\link{chat_irc}},
+#' \code{\link{chat_slack}}, or \code{\link{chat_telegram}}.
+#' Inspect \code{\link{chat_capabilities}} before using optional operations.
+#'
+#' @name chat.api-package
+#' @aliases chat.api
+#' @keywords package
+#' @examples
+#' cl <- chat_loopback()
+#' chat_send(cl, "general", "hello")
+#' chat_poll(cl)$messages
+NULL
diff --git a/R/contract.R b/R/contract.R
index ac06bc3..d88db5e 100644
--- a/R/contract.R
+++ b/R/contract.R
@@ -16,6 +16,12 @@
#' @param ... Adapter-specific options.
#' @return A list with \code{messages} (list of \code{chat_message}) and
#' \code{cursor} (opaque, for the next \code{since}).
+#' @examples
+#' cl <- chat_loopback()
+#' chat_send(cl, "general", "hello")
+#' batch <- chat_poll(cl)
+#' batch$messages
+#' chat_poll(cl, since = batch$cursor)$messages
#' @export
chat_poll <- function(client, since = NULL, timeout = NULL, ...) {
UseMethod("chat_poll")
@@ -63,6 +69,11 @@ chat_poll <- function(client, since = NULL, timeout = NULL, ...) {
#' with files returns the attachment ids followed by the text id.
#' Callers that track their own traffic by id must handle every
#' element, or an unclaimed event reads as somebody else's message.
+#' @examples
+#' cl <- chat_loopback()
+#' id <- chat_send(cl, "general", "hello", markup = "plain")
+#' chat_send(cl, "general", "a reply", thread = id)
+#' chat_poll(cl)$messages
#' @export
chat_send <- function(client, channel, text, markup = c("plain", "markdown"),
thread = NULL, reply_to = NULL, identity = NULL,
@@ -81,6 +92,9 @@ chat_send <- function(client, channel, text, markup = c("plain", "markdown"),
#' @param on Logical.
#' @param ... Adapter-specific options.
#' @return TRUE if the signal was sent, FALSE otherwise, invisibly.
+#' @examples
+#' cl <- chat_loopback()
+#' chat_typing(cl, "general") # FALSE: loopback has no typing indicator
#' @export
chat_typing <- function(client, channel, on = TRUE, ...) {
UseMethod("chat_typing")
@@ -97,6 +111,8 @@ chat_typing.default <- function(client, channel, on = TRUE, ...) {
#' @param name Channel name, alias, or identifier.
#' @param ... Adapter-specific options.
#' @return The adapter-native channel identifier (character).
+#' @examples
+#' chat_resolve(chat_loopback(), "general")
#' @export
chat_resolve <- function(client, name, ...) {
UseMethod("chat_resolve")
@@ -140,6 +156,10 @@ chat_resolve <- function(client, name, ...) {
#' else's through the history endpoint this adapter polls, so a
#' consumer reading a single flag would wait forever for events that
#' never arrive.
+#' @examples
+#' caps <- chat_capabilities(chat_loopback())
+#' caps$threads
+#' caps$e2ee
#' @export
chat_capabilities <- function(client, ...) {
UseMethod("chat_capabilities")
@@ -170,6 +190,14 @@ chat_capabilities <- function(client, ...) {
#' @param ... Adapter-specific options.
#' @return The reaction's identifier where the platform gives it one
#' (Matrix), invisibly; \code{TRUE} where it does not (Slack).
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and a joined room.
+#' cl <- chat_matrix(app = "mybot")
+#' room <- chat_resolve(cl, "#general:example.org")
+#' id <- chat_send(cl, room, "hello")
+#' chat_react(cl, room, id, "+1")
+#' }
#' @export
chat_react <- function(client, channel, message_id, key, ...) {
UseMethod("chat_react")
@@ -198,6 +226,12 @@ chat_react.default <- function(client, channel, message_id, key, ...) {
#' \code{\link{chat_resolve}}.
#' @param ... Adapter-specific options.
#' @return The joined channel's identifier, invisibly.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and access to the room.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_join(cl, "#general:example.org")
+#' }
#' @export
chat_join <- function(client, channel, ...) {
UseMethod("chat_join")
@@ -250,6 +284,12 @@ chat_channel_create.default <- function(client, name, ...) {
#' @param channel Channel/room identifier.
#' @param ... Adapter-specific options.
#' @return The left channel's identifier, invisibly.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and a joined room.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_leave(cl, "#general:example.org")
+#' }
#' @export
chat_leave <- function(client, channel, ...) {
UseMethod("chat_leave")
@@ -283,6 +323,8 @@ chat_leave.default <- function(client, channel, ...) {
#' 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.
+#' @examples
+#' chat_invite("!room:example.org", inviter = "@alice:example.org")
#' @export
chat_invite <- function(channel, inviter = NA_character_, raw = NULL) {
stopifnot(is.character(channel))
@@ -324,6 +366,12 @@ print.chat_invite <- function(x, ...) {
#' @return A list with \code{id}, \code{name}, and \code{topic}.
#' \code{id} is the channel as the platform addresses it; \code{name}
#' and \code{topic} are character or NULL.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and a joined room.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_channel_info(cl, "#general:example.org")
+#' }
#' @export
chat_channel_info <- function(client, channel, ...) {
UseMethod("chat_channel_info")
@@ -349,6 +397,12 @@ chat_channel_info.default <- function(client, channel, ...) {
#' has none; an adapter that cannot answer throws, so an empty room is
#' never confused with an unanswerable question. Check
#' \code{chat_capabilities()$members} first.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and a joined room.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_members(cl, "#general:example.org")
+#' }
#' @export
chat_members <- function(client, channel, ...) {
UseMethod("chat_members")
@@ -388,6 +442,9 @@ chat_members.default <- function(client, channel, ...) {
#' acknowledgement. NULL when the adapter cannot tell.
#' @param raw The adapter's platform-native payload.
#' @return A list with class \code{chat_reaction}.
+#' @examples
+#' chat_reaction("r1", "general", "alice", target = "m1",
+#' key = "+1", ts = as.POSIXct("2026-01-01", tz = "UTC"))
#' @export
chat_reaction <- function(id, channel, sender, target, key, ts, self = NULL,
raw = NULL) {
@@ -414,6 +471,9 @@ print.chat_reaction <- function(x, ...) {
#' @param client A \code{chat_client}.
#' @param ... Adapter-specific options.
#' @return TRUE, invisibly.
+#' @examples
+#' cl <- chat_loopback()
+#' chat_disconnect(cl)
#' @export
chat_disconnect <- function(client, ...) {
UseMethod("chat_disconnect")
@@ -472,6 +532,9 @@ chat_disconnect.default <- function(client, ...) {
#' be tied to a verified device, so the identifier is the homeserver's
#' word rather than cryptographic fact.
#' @return A list with class \code{chat_message}.
+#' @examples
+#' chat_message("m1", "general", "alice", "hello",
+#' ts = as.POSIXct("2026-01-01", tz = "UTC"))
#' @export
chat_message <- function(id, channel, sender, body, ts, thread = NULL,
markup = "plain", kind = "message", self = NULL,
@@ -555,9 +618,19 @@ chat_attachment <- function(id, name = NA_character_, mime = NA_character_,
#' @param attachment A \code{\link{chat_attachment}} record, as carried
#' on a \code{\link{chat_message}}'s \code{attachments}.
#' @param dest Destination path. NULL picks a temporary file, keeping
-#' the attachment's extension where it has one.
+#' the attachment's extension where it has one. The caller should remove
+#' temporary downloads with \code{unlink()} when finished.
#' @param ... Adapter-specific options.
#' @return The destination path, invisibly.
+#' @examples
+#' cl <- chat_loopback()
+#' src <- tempfile(fileext = ".txt")
+#' writeLines("hello", src)
+#' chat_send(cl, "general", "a file", files = src)
+#' attachment <- chat_poll(cl)$messages[[1L]]$attachments[[1L]]
+#' dest <- chat_download(cl, attachment)
+#' readLines(dest)
+#' unlink(c(src, dest))
#' @export
chat_download <- function(client, attachment, dest = NULL, ...) {
UseMethod("chat_download")
@@ -840,6 +913,8 @@ chat_history.default <- function(client, channel, limit = 50L, cursor = NULL,
#' @return A list with \code{invites}, a list of \code{\link{chat_invite}}.
#' @examples
#' \dontrun{
+#' # Requires a saved Matrix configuration and a homeserver connection.
+#' client <- chat_matrix(app = "mybot")
#' pending <- chat_pending(client)
#' for (iv in pending$invites) chat_join(client, iv$channel)
#' }
@@ -872,6 +947,10 @@ chat_pending.default <- function(client, ...) {
#' @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.
+#' @examples
+#' cl <- chat_loopback()
+#' id <- chat_send(cl, "general", "hello")
+#' chat_mark_read(cl, "general", id) # FALSE: no read markers
#' @export
chat_mark_read <- function(client, channel, message_id, ...) {
UseMethod("chat_mark_read")
@@ -901,6 +980,12 @@ chat_mark_read.default <- function(client, channel, message_id, ...) {
#' @param display New display name.
#' @param ... Adapter-specific options.
#' @return TRUE if the identity was changed, invisibly.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration and account credentials.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_set_identity(cl, "Example Bot")
+#' }
#' @export
chat_set_identity <- function(client, display, ...) {
UseMethod("chat_set_identity")
@@ -937,6 +1022,10 @@ chat_set_identity.default <- function(client, display, ...) {
#' @param ... Adapter-specific options.
#' @return The state event's identifier where the platform gives one
#' (Matrix), invisibly; \code{TRUE} where it does not.
+#' @examples
+#' cl <- chat_loopback()
+#' chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning"))
+#' chat_get_state(cl, "general", "m.room.topic")
#' @export
chat_set_state <- function(client, channel, type, content, state_key = "",
...) {
@@ -972,6 +1061,11 @@ chat_set_state.default <- function(client, channel, type, content,
#' @param ... Adapter-specific options.
#' @return The stored content as a named list, or \code{NULL} when no
#' state is set for that \code{type}/\code{state_key} pair.
+#' @examples
+#' cl <- chat_loopback()
+#' chat_get_state(cl, "general", "m.room.topic") # NULL until written
+#' chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning"))
+#' chat_get_state(cl, "general", "m.room.topic")
#' @export
chat_get_state <- function(client, channel, type, state_key = "", ...) {
UseMethod("chat_get_state")
@@ -997,6 +1091,12 @@ chat_get_state.default <- function(client, channel, type, state_key = "", ...) {
#' @param client A \code{chat_client}.
#' @param ... Adapter-specific options.
#' @return TRUE, invisibly.
+#' @examples
+#' \dontrun{
+#' # Requires a saved Matrix configuration with login credentials.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_relogin(cl)
+#' }
#' @export
chat_relogin <- function(client, ...) {
UseMethod("chat_relogin")
@@ -1049,6 +1149,11 @@ chat_relogin.default <- function(client, ...) {
#' notifications almost always carry the text as first sent. So the
#' first version has to stand on its own -- "working on it" is a fine
#' thing to be paged with, a half-finished sentence is not.
+#' @examples
+#' cl <- chat_loopback()
+#' id <- chat_send(cl, "general", "Working on it")
+#' chat_edit(cl, "general", id, "Finished")
+#' chat_history(cl, "general")$messages
#' @export
chat_edit <- function(client, channel, message_id, text,
markup = c("plain", "markdown"), rich = NULL,
diff --git a/R/irc.R b/R/irc.R
index c4a2fce..2266baa 100644
--- a/R/irc.R
+++ b/R/irc.R
@@ -16,6 +16,14 @@
#' \code{"#rstats"}).
#' @param realname Real-name field for USER registration.
#' @return A \code{chat_client} of class \code{chat_irc}.
+#' @examples
+#' \dontrun{
+#' # Requires a reachable IRC server and permission to join the channel.
+#' cl <- chat_irc(host = "irc.example.org", nick = "example_bot",
+#' channels = "#example")
+#' chat_poll(cl, timeout = 1)
+#' chat_disconnect(cl)
+#' }
#' @export
chat_irc <- function(host, port = 6667L, nick, channels = character(),
realname = nick) {
diff --git a/R/matrix-config.R b/R/matrix-config.R
index 4715e6c..7b14e41 100644
--- a/R/matrix-config.R
+++ b/R/matrix-config.R
@@ -27,10 +27,14 @@
#' overrides both.
#' @return A list with class \code{chat_config}.
#' @examples
-#' \dontrun{
-#' cfg <- chat_matrix_config(app = "corteza",
-#' env_var = "CORTEZA_MATRIX_CONFIG")
-#' client <- chat_matrix(mx = cfg)
+#' if (requireNamespace("mx.client", quietly = TRUE)) {
+#' path <- tempfile(fileext = ".json")
+#' cfg <- chat_config(list(server = "https://matrix.example.org",
+#' token = "example-token",
+#' user_id = "@bot:example.org"))
+#' chat_config_save(cfg, path = path)
+#' chat_matrix_config(path = path)
+#' unlink(path)
#' }
#' @export
chat_matrix_config <- function(app = NULL, path = NULL, env_var = NULL) {
@@ -92,7 +96,9 @@ print.chat_config <- function(x, ...) {
#' an application that needs to migrate an older file.
#' @return The file path (character).
#' @examples
-#' chat_matrix_config_path("demo")
+#' if (requireNamespace("mx.client", quietly = TRUE)) {
+#' chat_matrix_config_path("demo")
+#' }
#' @export
chat_matrix_config_path <- function(app, env_var = NULL, legacy = FALSE) {
matrix_require_client("chat_matrix_config_path")
@@ -116,9 +122,14 @@ chat_matrix_config_path <- function(app, env_var = NULL, legacy = FALSE) {
#' @param path Override the file to write.
#' @return The config, invisibly.
#' @examples
-#' \dontrun{
-#' cfg$operators <- "@troy:example.org"
-#' chat_config_save(cfg)
+#' if (requireNamespace("mx.client", quietly = TRUE)) {
+#' path <- tempfile(fileext = ".json")
+#' cfg <- chat_config(list(server = "https://matrix.example.org",
+#' token = "example-token",
+#' user_id = "@bot:example.org"))
+#' chat_config_save(cfg, path = path)
+#' file.exists(path)
+#' unlink(path)
#' }
#' @export
chat_config_save <- function(config, app = NULL, path = NULL) {
@@ -168,9 +179,11 @@ unclass_config <- function(config) {
#' @return A \code{\link{chat_config}}, invisibly.
#' @examples
#' \dontrun{
+#' # Requires a real homeserver, account password, and access to the room.
+#' pw <- Sys.getenv("MATRIX_PASSWORD")
#' cfg <- chat_matrix_configure(server = "https://matrix.example.org",
#' user = "bot", password = pw,
-#' room = "#lab:example.org", app = "corteza")
+#' room = "#lab:example.org", app = "mybot")
#' }
#' @export
chat_matrix_configure <- function(server, user, password, room = NULL,
diff --git a/R/matrix.R b/R/matrix.R
index 18ec908..7cd1d7c 100644
--- a/R/matrix.R
+++ b/R/matrix.R
@@ -162,6 +162,13 @@
#' consumer needs whenever it drives mx.api directly (read receipts,
#' member lookups) because a relogin may have replaced the token this
#' poll cycle.
+#' @examples
+#' \dontrun{
+#' # Requires mx.client and saved Matrix credentials for this application.
+#' cl <- chat_matrix(app = "mybot")
+#' chat_capabilities(cl)
+#' chat_poll(cl, timeout = 0)
+#' }
#' @export
chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE,
mx = NULL, relogin = TRUE, e2ee = FALSE,
diff --git a/R/slack.R b/R/slack.R
index 3fe1e8d..47a38ef 100644
--- a/R/slack.R
+++ b/R/slack.R
@@ -66,6 +66,12 @@
#' (\code{chat_channel_info()}, \code{chat_members()}). Leave NULL in
#' production.
#' @return A \code{chat_client} of class \code{chat_slack}.
+#' @examples
+#' \dontrun{
+#' # Requires slackr, SLACK_TOKEN, and a channel the token can access.
+#' cl <- chat_slack(channels = "C0123456789")
+#' chat_send(cl, "C0123456789", "hello")
+#' }
#' @export
chat_slack <- function(channels = character(),
token = Sys.getenv("SLACK_TOKEN"),
diff --git a/R/telegram.R b/R/telegram.R
index e5bae23..2a7a501 100644
--- a/R/telegram.R
+++ b/R/telegram.R
@@ -57,6 +57,13 @@
#' a getFile answer to \code{dest}. Leave NULL in production; when
#' both seams are supplied neither httr nor a bot is required.
#' @return A \code{chat_client} of class \code{chat_telegram}.
+#' @examples
+#' \dontrun{
+#' # Requires httr, TELEGRAM_BOT_TOKEN, and access to the target chat.
+#' cl <- chat_telegram()
+#' chat_whoami(cl)
+#' chat_send(cl, "@example_channel", "hello")
+#' }
#' @export
chat_telegram <- function(token = Sys.getenv("TELEGRAM_BOT_TOKEN"),
timeout = 30L,
diff --git a/README.md b/README.md
index 6ddeb5e..6ae8eff 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Transport-agnostic chat contract for R agents. Zero dependencies.
-One interface — `chat_poll()`, `chat_send()`, `chat_typing()`,
-`chat_resolve()`, `chat_capabilities()`, `chat_disconnect()` — with
+One interface, including `chat_poll()`, `chat_send()`, `chat_typing()`,
+`chat_resolve()`, `chat_capabilities()`, and `chat_disconnect()`, with
adapters that wake up when their platform client is installed:
| Adapter | Constructor | Delegates to | Status |
@@ -12,7 +12,7 @@ adapters that wake up when their platform client is installed:
| Matrix | `chat_matrix()` | mx.client (Suggests) | working |
| IRC | `chat_irc()` | base R sockets | working |
| Slack | `chat_slack()` | slackr (Suggests) | signature-verified, review-hardened; live roundtrip pending a workspace token |
-| Telegram | `chat_telegram()` | httr, or a telegram::TGBot (Suggests) | every verb seam-tested against Bot API shapes; live roundtrip pending a bot token |
+| Telegram | `chat_telegram()` | httr, or a telegram::TGBot (Suggests) | working; live roundtrip verified |
```r
cl <- chat.api::chat_matrix(app = "mybot")
diff --git a/cran-comments.md b/cran-comments.md
new file mode 100644
index 0000000..0384221
--- /dev/null
+++ b/cran-comments.md
@@ -0,0 +1,67 @@
+## Submission
+
+This is the first submission of chat.api, version 0.1.0.
+
+The package provides a common chat interface with Matrix, IRC, Slack,
+Telegram, and in-memory adapters. It has no hard package dependencies.
+All suggested packages and required versions are available from CRAN.
+
+## Test environments
+
+* Ubuntu 24.04.5 LTS, x86_64, R 4.6.1.
+* Windows Server 2022 x64, R-release 4.6.1 (2026-06-24 ucrt), win-builder.
+* Windows Server 2022 x64, R-devel (2026-09-10 r90519 ucrt), win-builder.
+
+## R CMD check results
+
+Linux: 0 errors | 0 warnings | 1 note
+
+Windows R-release: 0 errors | 1 warning | 1 note
+
+Windows R-devel: 0 errors | 0 warnings | 1 note
+
+The note is "New submission".
+
+The corrected Windows R-devel check passed all 1,413 assertions,
+including the crypto pin-trust tests. Installation, examples, and PDF/HTML
+manual generation passed. One Unix file-permission assertion is skipped
+on Windows; the configuration roundtrip remains tested.
+
+The corrected Windows R-release run passed 1,403 assertions. Its warning
+reports a missing or unexported `mx.crypto::mxc_signing_key_public`.
+That function is exported by the CRAN source release mx.crypto 0.2.2;
+the Windows binary index had 0.2.1 when reviewed. The 10 crypto assertions
+guarded by a newer mx.crypto version did not run. The published source archives
+for mx.api 0.3.1, mx.crypto 0.2.2, and mx.client 0.2.1 were uploaded to
+win-builder in dependency order before re-uploading the unchanged chat.api
+archive on 2026-09-11. The repeat check still reported the same warning
+and passed 1,403 assertions. The dependency check results are needed to
+verify their installation outcomes before repeating the R-release check.
+
+The Linux check used `--as-cran --run-donttest`, including PDF and HTML
+manual generation. All 1,414 tinytest assertions passed with all suggested
+packages installed: mx.api 0.3.1, mx.client 0.2.1, mx.crypto 0.2.2,
+slackr 3.3.1, telegram 0.7.1, httr 1.4.9, and tinytest 1.4.3.
+With all optional platform packages absent, 1,039 assertions passed;
+tests requiring mx.client's session constructor are guarded explicitly.
+
+## Examples and tests
+
+Runnable examples use the in-memory adapter or temporary configuration
+files. The following examples use `\dontrun{}` because they require
+external services or account credentials:
+
+* chat_matrix, chat_react, chat_join, chat_leave, chat_channel_info,
+ chat_members, chat_pending, chat_set_identity, and chat_relogin require
+ saved Matrix credentials and a homeserver connection; room operations
+ also require access to the target room.
+* chat_matrix_configure requires a real homeserver and account password.
+* chat_slack requires a Slack token and access to a workspace channel.
+* chat_telegram requires a Telegram bot token and access to a target chat.
+* chat_irc requires a reachable IRC server and permission to join a channel.
+
+Automated checks use simulated transports. The optional live Telegram
+test is guarded by `tinytest::at_home()` and requires a bot token.
+Test cache, data, and configuration directories are redirected into the
+session temporary directory. The Linux check created no new files in R's
+user cache, data, or configuration directories.
diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R
index eb7ef30..b7c3cf2 100644
--- a/inst/tinytest/test_matrix.R
+++ b/inst/tinytest/test_matrix.R
@@ -1,9 +1,7 @@
-# Matrix adapter verification that needs nothing installed. Every client
-# here supplies mx plus all four seams, which is the configuration
-# chat_matrix() documents as running without mx.client, so these
-# assertions are the ones that must not disappear on a bare CI runner.
-# The half that pins the adapter to mx.client's real signatures lives in
-# test_matrix_mxclient.R, which announces its skip.
+# Matrix poll, send, and crypto routing tests use injected transports and
+# run without mx.client. Additional verbs validate a real mx.client session
+# before invoking their transport, so those cases require mx.client.
+# Signature checks live in test_matrix_mxclient.R, which announces its skip.
# device_id is here because an Olm account belongs to a device: an e2ee
# client refuses a config that cannot name one.
@@ -742,9 +740,10 @@ expect_error(spec("C:relative"), "drive-relative")
expect_identical(n("C:/relative"), "C:/relative")
# ~ expands, and a relative path resolves against the caller's directory
# rather than merging with an unrelated one of the same name.
-expect_true(startsWith(n("~/x"), "/"))
+expect_identical(n("~/x"), n(file.path(path.expand("~"), "x")))
expect_identical(n("rel/x"), n(file.path(getwd(), "rel/x")))
-expect_true(startsWith(n("rel/x"), "/"))
+# Absolute paths may have a POSIX/UNC root or a Windows drive root.
+expect_true(grepl("^(/|[A-Za-z]:/)", n("rel/x")))
# Directories that do exist still fold, which is the common case.
local({
d <- file.path(tempfile("specdir"), "s")
@@ -1322,6 +1321,7 @@ rx_ev <- function(event_id, target = "$msg", key = "y",
}
# Sending goes through mx.api::mx_react, seamed here.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.react = function(session, room_id, event_id, key) {
@@ -1339,6 +1339,7 @@ local({
# A failing react propagates. Unlike a typing indicator, a dropped
# acknowledgement is one the sender believes it made.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(chat_react(seam_client(.react = function(...) stop("403")),
"!room:ex", "$msg", "y"), "403")
@@ -1468,6 +1469,7 @@ if (requireNamespace("mx.client", quietly = TRUE) &&
}
# ---- Joining ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.join = function(session, room_id) {
@@ -1481,6 +1483,7 @@ local({
# 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.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(chat_join(seam_client(.join = function(...) stop("M_FORBIDDEN")),
"!a:ex"), "M_FORBIDDEN")
@@ -1492,6 +1495,7 @@ expect_error(chat_join(structure(list(), class = c("chat_nothing",
# ---- Creating ----
# The seam replaces mx.api::mx_room_create; adapter-specific options
# (topic, visibility, invitees) ride through ... untouched.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.create = function(session, name, ...) {
@@ -1506,11 +1510,13 @@ local({
# A failed creation propagates: the caller must not walk away with a
# name it believes is a room.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(
chat_channel_create(seam_client(.create = function(...) stop("M_LIMIT")),
"warroom"), "M_LIMIT")
# ---- Leaving ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.leave = function(session, room_id) {
@@ -1523,6 +1529,7 @@ local({
# A failed leave propagates: doing nothing quietly keeps delivering a
# room the caller believes it has left.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(chat_leave(seam_client(.leave = function(...) stop("M_UNKNOWN")),
"!a:ex"), "M_UNKNOWN")
@@ -1598,6 +1605,7 @@ local({
})
# ---- Fetching media ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.download = function(session, mxc_url, dest) {
@@ -1609,6 +1617,7 @@ local({
url = "mxc://ex/abc",
raw = list(encrypted = FALSE))
dest <- chat_download(cl, att)
+ on.exit(unlink(dest), add = TRUE)
expect_identical(seen$url, "mxc://ex/abc")
# The extension survives, so a consumer handing the file to
# something that sniffs by extension does not have to rename it.
@@ -1702,6 +1711,7 @@ local({
# A rich threaded reply is threaded too: that path bypasses
# mx_send_text, so it builds the same relation itself.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.rich = function(session, channel, text, ...) {
@@ -1730,6 +1740,7 @@ local({
# The seam replaces mx.api::mx_set_state. The default state_key is the
# empty string, where most Matrix state lives, and it is passed by name
# so a seam with the real signature receives it in the right slot.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.state = function(session, channel, type, content,
@@ -1751,6 +1762,7 @@ local({
# A failed write propagates: doing nothing quietly leaves a marker the
# caller believes is set and no reader will ever see.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(
chat_set_state(seam_client(.state = function(...) stop("M_FORBIDDEN")),
"!a:ex", "ai.example.marker", list()),
@@ -1758,6 +1770,7 @@ expect_error(
# Reading it back. mx_get_state() answers NULL for state that is not
# set, which is the generic's contract, so nothing is absorbed here.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.get_state = function(session, channel, type,
@@ -1773,10 +1786,12 @@ local({
chat_get_state(cl, "!a:ex", "ai.example.marker", state_key = "$root")
expect_identical(seen$state_key, "$root")
})
+if (requireNamespace("mx.client", quietly = TRUE))
expect_null(chat_get_state(seam_client(.get_state = function(...) NULL),
"!a:ex", "ai.example.marker"))
# An unreachable state store is a different fact from absent state, and
# still errors.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_error(
chat_get_state(seam_client(.get_state = function(...) stop("HTTP 502")),
"!a:ex", "ai.example.marker"), "HTTP 502")
@@ -1883,6 +1898,7 @@ local({
expect_true(chat_capabilities(seam_client())$whoami)
# ---- State: channels ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
cl <- seam_client(.channels = function(session) c("!a:ex", "!b:ex"))
expect_identical(chat_channels(cl), c("!a:ex", "!b:ex"))
@@ -1902,6 +1918,7 @@ hev <- function(id, body = "hi", msgtype = "m.text", ts = 1700000000000,
origin_server_ts = ts, content = content)
}
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.history = function(session, room_id, ...) {
@@ -1927,6 +1944,7 @@ local({
numeric(1))) > 0))
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# The cursor is /messages' own `from` token, and it comes back out as
# `end`. Not a message id: handing an event id to /messages does not
@@ -1947,6 +1965,7 @@ local({
# No `end` means no more history. The spec omits it at the start of a
# room, and that -- not an empty chunk -- is the stop signal: a window
# can be all state events and still have conversation behind it.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
cl <- seam_client(.history = function(...) {
list(chunk = list(list(type = "m.room.member", event_id = "$m")))
@@ -1955,6 +1974,7 @@ local({
expect_identical(res$messages, list())
expect_null(res$cursor)
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
cl <- seam_client(.history = function(...) {
list(chunk = list(), end = "t9")
@@ -1964,6 +1984,7 @@ local({
expect_identical(chat_history(cl, "!a:ex")$cursor, "t9")
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# A msgtype the contract has no word for is dropped, not renamed.
# matrix_kind() answers "message" for anything, so an m.image would
@@ -1980,6 +2001,7 @@ expect_identical(chat.api:::matrix_kind_strict("m.image"), NA_character_)
expect_identical(chat.api:::matrix_kind_strict("m.notice"), "notice")
expect_identical(chat.api:::matrix_kind_strict(NULL), NA_character_)
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# Non-message state events (joins, topic changes) are not history.
cl <- seam_client(.history = function(...) {
@@ -1989,6 +2011,7 @@ local({
expect_identical(length(chat_history(cl, "!a:ex")$messages), 1L)
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# self and mentions survive the trip, so a consumer can tell its own
# backfilled traffic from everyone else's.
@@ -2002,6 +2025,7 @@ local({
})
# ---- State: pending ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.pending = function(session, timeout = NULL, ...) {
@@ -2025,6 +2049,7 @@ local({
expect_identical(p$invites[[1L]]$inviter, "@ann:ex")
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# Nothing pending is an empty list, not NULL: a consumer looping
# over it should not have to test for both.
@@ -2033,6 +2058,7 @@ local({
})
# ---- State: mark read ----
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.read = function(session, room_id, event_id, ...) {
@@ -2047,6 +2073,7 @@ local({
# A failed receipt is FALSE, not a throw. Unlike a reaction, nobody is
# waiting on a read marker -- it costs a human a little context about
# what the bot has seen, and nothing more.
+if (requireNamespace("mx.client", quietly = TRUE))
expect_false(chat_mark_read(seam_client(.read = function(...) stop("boom")),
"!a:ex", "$1"))
# An adapter without one says nothing rather than failing, for the same
@@ -2085,6 +2112,7 @@ local({
# A progress message: post once, then keep replacing it. The alternative
# is narrating into the channel one message per tool call, which is how
# a room gets unreadable.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text",
@@ -2108,6 +2136,7 @@ local({
# Markdown renders into both copies. A formatted edit whose new_content
# carried only plain text would show the markup on old clients and lose
# it on new ones, which is exactly backwards.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text",
@@ -2122,6 +2151,7 @@ local({
expect_identical(seen$format, "org.matrix.custom.html")
expect_true(grepl("^\\* ", seen$formatted_body))
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# Plain markup sets no format at all, rather than an empty one.
seen <- NULL
@@ -2167,6 +2197,7 @@ expect_error(chat_edit(structure(list(), class = c("chat_nothing",
# cannot express. mx_markdown_to_html() escapes raw HTML -- correctly,
# it is a conservative subset -- so can only get into a room
# this way.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.rich = function(session, room_id, body, msgtype = "m.text",
@@ -2199,6 +2230,7 @@ local({
# An edit carries it too, and a supplied fragment beats one rendered
# from markdown -- the caller has markup the renderer cannot express,
# which is the only reason to pass one.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text",
@@ -2241,6 +2273,7 @@ expect_identical(chat_capabilities(seam_client())$rich_markup, "html")
# assumed m.text would turn an m.notice into an ordinary message the
# first time it fired -- and m.notice is what keeps a bot's own output
# from triggering other bots.
+if (requireNamespace("mx.client", quietly = TRUE))
local({
seen <- NULL
cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text",
@@ -2252,6 +2285,7 @@ local({
expect_identical(seen$msgtype, "m.notice")
expect_identical(seen$extra$`m.new_content`$msgtype, "m.notice")
})
+if (requireNamespace("mx.client", quietly = TRUE))
local({
# Default is unchanged.
seen <- NULL
diff --git a/inst/tinytest/test_matrix_mxclient.R b/inst/tinytest/test_matrix_mxclient.R
index 47b2b80..af67a97 100644
--- a/inst/tinytest/test_matrix_mxclient.R
+++ b/inst/tinytest/test_matrix_mxclient.R
@@ -17,6 +17,9 @@ if (requireNamespace("mx.crypto", quietly = TRUE) &&
store <- tempfile("pin-store-")
dir.create(store)
on.exit(unlink(store, recursive = TRUE), add = TRUE)
+ # Fixed test-only pickle key: this fixture checks save/load and pin trust,
+ # independently of mx.client's platform-specific random-byte source.
+ writeBin(as.raw(seq_len(32L)), file.path(store, "pickle.key"))
crypto <- list(store = store)
mx <- list(user_id = "@bot:example.org")
query <- function(client, user_ids, self_master_key) {
@@ -999,8 +1002,11 @@ local({
path = path)
chat_config_save(cfg)
expect_true(file.exists(path))
- # 0600. The file holds an access token.
- expect_identical(substr(as.character(file.mode(path)), 1L, 3L), "600")
+ # The token file must be 0600 on Unix. Windows file.mode() does not
+ # report Unix owner/group permissions or Windows access-control lists.
+ if (.Platform$OS.type == "unix") {
+ expect_identical(substr(as.character(file.mode(path)), 1L, 3L), "600")
+ }
back <- chat_matrix_config(path = path)
expect_inherits(back, "chat_config")
diff --git a/inst/tinytest/test_telegram.R b/inst/tinytest/test_telegram.R
index 2176870..5e87b0f 100644
--- a/inst/tinytest/test_telegram.R
+++ b/inst/tinytest/test_telegram.R
@@ -758,6 +758,7 @@ if (requireNamespace("httr", quietly = TRUE)) {
local({
f <- tempfile(fileext = ".png")
writeBin(as.raw(1:4), f)
+ on.exit(unlink(f), add = TRUE)
bot <- tg_fake_bot(tg_response('{"ok":true,"result":{"message_id":8}}'))
cl <- chat_telegram(token = "", bot = bot,
.download = function(...) NULL)
@@ -765,7 +766,9 @@ if (requireNamespace("httr", quietly = TRUE)) {
body <- bot$calls()[[1L]]$body
expect_identical(body$chat_id, "5")
expect_true(inherits(body$document, "form_file"))
- expect_identical(body$document$path, f)
+ # Upload helpers may resolve aliases such as /var -> /private/var.
+ expect_identical(normalizePath(body$document$path, mustWork = TRUE),
+ normalizePath(f, mustWork = TRUE))
})
# A refusal comes back through the response body with Telegram's
diff --git a/man/chat.api-package.Rd b/man/chat.api-package.Rd
new file mode 100644
index 0000000..f69a599
--- /dev/null
+++ b/man/chat.api-package.Rd
@@ -0,0 +1,18 @@
+% tinyrox says don't edit this manually, but it can't stop you!
+\name{chat.api-package}
+\alias{chat.api-package}
+\alias{chat.api}
+\title{Transport-agnostic chat connectivity}
+\description{
+A common interface for chat messages, attachments, rooms, and identity.
+Use \code{\link{chat_loopback}} for local development, or connect through
+\code{\link{chat_matrix}}, \code{\link{chat_irc}},
+\code{\link{chat_slack}}, or \code{\link{chat_telegram}}.
+Inspect \code{\link{chat_capabilities}} before using optional operations.
+}
+\examples{
+cl <- chat_loopback()
+chat_send(cl, "general", "hello")
+chat_poll(cl)$messages
+}
+\keyword{package}
diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd
index 9ffc396..ee0431a 100644
--- a/man/chat_capabilities.Rd
+++ b/man/chat_capabilities.Rd
@@ -49,3 +49,8 @@ A list with at least: \code{threads} (can post into
\description{
Describe what a chat client's platform supports
}
+\examples{
+caps <- chat_capabilities(chat_loopback())
+caps$threads
+caps$e2ee
+}
diff --git a/man/chat_channel_info.Rd b/man/chat_channel_info.Rd
index a3a8582..80be8dc 100644
--- a/man/chat_channel_info.Rd
+++ b/man/chat_channel_info.Rd
@@ -37,3 +37,10 @@ stay distinguishable. Check \code{chat_capabilities()$channel_info}
first.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and a joined room.
+cl <- chat_matrix(app = "mybot")
+chat_channel_info(cl, "#general:example.org")
+}
+}
diff --git a/man/chat_config_save.Rd b/man/chat_config_save.Rd
index a7aca75..366110e 100644
--- a/man/chat_config_save.Rd
+++ b/man/chat_config_save.Rd
@@ -20,8 +20,13 @@ The config, invisibly.
Writes to the file the config came from, at mode 0600.
}
\examples{
-\dontrun{
-cfg$operators <- "@troy:example.org"
-chat_config_save(cfg)
+if (requireNamespace("mx.client", quietly = TRUE)) {
+ path <- tempfile(fileext = ".json")
+ cfg <- chat_config(list(server = "https://matrix.example.org",
+ token = "example-token",
+ user_id = "@bot:example.org"))
+ chat_config_save(cfg, path = path)
+ file.exists(path)
+ unlink(path)
}
}
diff --git a/man/chat_disconnect.Rd b/man/chat_disconnect.Rd
index a9da605..e287c17 100644
--- a/man/chat_disconnect.Rd
+++ b/man/chat_disconnect.Rd
@@ -17,3 +17,7 @@ TRUE, invisibly.
The default method is a no-op: HTTP-poll transports have nothing to
close. Persistent-socket transports (IRC) override it.
}
+\examples{
+cl <- chat_loopback()
+chat_disconnect(cl)
+}
diff --git a/man/chat_download.Rd b/man/chat_download.Rd
index 8b6e703..3ee2ccc 100644
--- a/man/chat_download.Rd
+++ b/man/chat_download.Rd
@@ -12,7 +12,8 @@ chat_download(client, attachment, dest = NULL, ...)
on a \code{\link{chat_message}}'s \code{attachments}.}
\item{dest}{Destination path. NULL picks a temporary file, keeping
-the attachment's extension where it has one.}
+the attachment's extension where it has one. The caller should remove
+temporary downloads with \code{unlink()} when finished.}
\item{...}{Adapter-specific options.}
}
@@ -37,3 +38,13 @@ adapter records) is copied rather than fetched, so a consumer needs
one code path for both.
}
+\examples{
+cl <- chat_loopback()
+src <- tempfile(fileext = ".txt")
+writeLines("hello", src)
+chat_send(cl, "general", "a file", files = src)
+attachment <- chat_poll(cl)$messages[[1L]]$attachments[[1L]]
+dest <- chat_download(cl, attachment)
+readLines(dest)
+unlink(c(src, dest))
+}
diff --git a/man/chat_edit.Rd b/man/chat_edit.Rd
index 7566600..ad35657 100644
--- a/man/chat_edit.Rd
+++ b/man/chat_edit.Rd
@@ -67,3 +67,9 @@ notifications almost always carry the text as first sent. So the
first version has to stand on its own -- "working on it" is a fine
thing to be paged with, a half-finished sentence is not.
}
+\examples{
+cl <- chat_loopback()
+id <- chat_send(cl, "general", "Working on it")
+chat_edit(cl, "general", id, "Finished")
+chat_history(cl, "general")$messages
+}
diff --git a/man/chat_get_state.Rd b/man/chat_get_state.Rd
index 033078b..044046e 100644
--- a/man/chat_get_state.Rd
+++ b/man/chat_get_state.Rd
@@ -35,3 +35,9 @@ state store that cannot be reached at all still errors, because
that is a different fact.
}
+\examples{
+cl <- chat_loopback()
+chat_get_state(cl, "general", "m.room.topic") # NULL until written
+chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning"))
+chat_get_state(cl, "general", "m.room.topic")
+}
diff --git a/man/chat_invite.Rd b/man/chat_invite.Rd
index e343b80..76452a6 100644
--- a/man/chat_invite.Rd
+++ b/man/chat_invite.Rd
@@ -31,3 +31,6 @@ 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.
}
+\examples{
+chat_invite("!room:example.org", inviter = "@alice:example.org")
+}
diff --git a/man/chat_irc.Rd b/man/chat_irc.Rd
index 779a999..93cb8ff 100644
--- a/man/chat_irc.Rd
+++ b/man/chat_irc.Rd
@@ -26,3 +26,12 @@ persistent socket buffers into \code{\link{chat_poll}}: each poll
drains available lines, answers server PINGs, and returns PRIVMSGs
as normalized messages. The cursor is a message counter.
}
+\examples{
+\dontrun{
+# Requires a reachable IRC server and permission to join the channel.
+cl <- chat_irc(host = "irc.example.org", nick = "example_bot",
+ channels = "#example")
+chat_poll(cl, timeout = 1)
+chat_disconnect(cl)
+}
+}
diff --git a/man/chat_join.Rd b/man/chat_join.Rd
index 1284764..f4568de 100644
--- a/man/chat_join.Rd
+++ b/man/chat_join.Rd
@@ -28,3 +28,10 @@ caller believing it is in a room it will never hear from. Check
\code{chat_capabilities()$join}.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and access to the room.
+cl <- chat_matrix(app = "mybot")
+chat_join(cl, "#general:example.org")
+}
+}
diff --git a/man/chat_leave.Rd b/man/chat_leave.Rd
index 94d727f..7e60d0e 100644
--- a/man/chat_leave.Rd
+++ b/man/chat_leave.Rd
@@ -21,3 +21,10 @@ client stops receiving the channel's traffic, on platforms where
membership is a thing at all. Capability-gated: check
\code{chat_capabilities()$leave}.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and a joined room.
+cl <- chat_matrix(app = "mybot")
+chat_leave(cl, "#general:example.org")
+}
+}
diff --git a/man/chat_mark_read.Rd b/man/chat_mark_read.Rd
index dd7591d..e7d11b6 100644
--- a/man/chat_mark_read.Rd
+++ b/man/chat_mark_read.Rd
@@ -30,3 +30,8 @@ surface -- per-user, per-device, and absent entirely on some
platforms -- and no consumer needs it yet.
}
+\examples{
+cl <- chat_loopback()
+id <- chat_send(cl, "general", "hello")
+chat_mark_read(cl, "general", id) # FALSE: no read markers
+}
diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd
index c030a23..1391bb7 100644
--- a/man/chat_matrix.Rd
+++ b/man/chat_matrix.Rd
@@ -230,3 +230,11 @@ Wraps an \code{mx.client} client config (see
mx.client config; with \code{save_cursor = TRUE} every poll persists
it, so a restarted process resumes where it left off.
}
+\examples{
+\dontrun{
+# Requires mx.client and saved Matrix credentials for this application.
+cl <- chat_matrix(app = "mybot")
+chat_capabilities(cl)
+chat_poll(cl, timeout = 0)
+}
+}
diff --git a/man/chat_matrix_config.Rd b/man/chat_matrix_config.Rd
index 8a7eee5..ee4ad4a 100644
--- a/man/chat_matrix_config.Rd
+++ b/man/chat_matrix_config.Rd
@@ -33,9 +33,13 @@ owner. They pass through untouched and unvalidated.
}
\examples{
-\dontrun{
-cfg <- chat_matrix_config(app = "corteza",
- env_var = "CORTEZA_MATRIX_CONFIG")
-client <- chat_matrix(mx = cfg)
+if (requireNamespace("mx.client", quietly = TRUE)) {
+ path <- tempfile(fileext = ".json")
+ cfg <- chat_config(list(server = "https://matrix.example.org",
+ token = "example-token",
+ user_id = "@bot:example.org"))
+ chat_config_save(cfg, path = path)
+ chat_matrix_config(path = path)
+ unlink(path)
}
}
diff --git a/man/chat_matrix_config_path.Rd b/man/chat_matrix_config_path.Rd
index 9c81731..5e4ce04 100644
--- a/man/chat_matrix_config_path.Rd
+++ b/man/chat_matrix_config_path.Rd
@@ -21,5 +21,7 @@ The file path (character).
Where a Matrix configuration lives
}
\examples{
-chat_matrix_config_path("demo")
+if (requireNamespace("mx.client", quietly = TRUE)) {
+ chat_matrix_config_path("demo")
+}
}
diff --git a/man/chat_matrix_configure.Rd b/man/chat_matrix_configure.Rd
index f9f2392..73f5ee6 100644
--- a/man/chat_matrix_configure.Rd
+++ b/man/chat_matrix_configure.Rd
@@ -42,8 +42,10 @@ credentials.
}
\examples{
\dontrun{
+# Requires a real homeserver, account password, and access to the room.
+pw <- Sys.getenv("MATRIX_PASSWORD")
cfg <- chat_matrix_configure(server = "https://matrix.example.org",
user = "bot", password = pw,
- room = "#lab:example.org", app = "corteza")
+ room = "#lab:example.org", app = "mybot")
}
}
diff --git a/man/chat_members.Rd b/man/chat_members.Rd
index 9bd4761..0f9f0e5 100644
--- a/man/chat_members.Rd
+++ b/man/chat_members.Rd
@@ -23,3 +23,10 @@ Separate from \code{\link{chat_channel_info}} because it is the
expensive half: a member list is unbounded where a name and a topic
are two short strings, and it goes stale on a different schedule.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and a joined room.
+cl <- chat_matrix(app = "mybot")
+chat_members(cl, "#general:example.org")
+}
+}
diff --git a/man/chat_message.Rd b/man/chat_message.Rd
index 895140f..4c0d334 100644
--- a/man/chat_message.Rd
+++ b/man/chat_message.Rd
@@ -84,3 +84,7 @@ A list with class \code{chat_message}.
\description{
The record every adapter's \code{\link{chat_poll}} returns.
}
+\examples{
+chat_message("m1", "general", "alice", "hello",
+ ts = as.POSIXct("2026-01-01", tz = "UTC"))
+}
diff --git a/man/chat_pending.Rd b/man/chat_pending.Rd
index 4459fb5..54f62ee 100644
--- a/man/chat_pending.Rd
+++ b/man/chat_pending.Rd
@@ -29,6 +29,8 @@ replay every channel it is in.
}
\examples{
\dontrun{
+# Requires a saved Matrix configuration and a homeserver connection.
+client <- chat_matrix(app = "mybot")
pending <- chat_pending(client)
for (iv in pending$invites) chat_join(client, iv$channel)
}
diff --git a/man/chat_poll.Rd b/man/chat_poll.Rd
index 11b8686..f7029a2 100644
--- a/man/chat_poll.Rd
+++ b/man/chat_poll.Rd
@@ -25,3 +25,10 @@ getUpdates) map directly; persistent-socket transports (IRC) buffer
into the poll. The cursor is opaque and adapter-specific; pass the
returned cursor back as \code{since} on the next call.
}
+\examples{
+cl <- chat_loopback()
+chat_send(cl, "general", "hello")
+batch <- chat_poll(cl)
+batch$messages
+chat_poll(cl, since = batch$cursor)$messages
+}
diff --git a/man/chat_react.Rd b/man/chat_react.Rd
index 7f90ce0..6e8f873 100644
--- a/man/chat_react.Rd
+++ b/man/chat_react.Rd
@@ -39,3 +39,12 @@ can be the whole message. Check \code{chat_capabilities()$reactions}
before calling on an unknown adapter.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and a joined room.
+cl <- chat_matrix(app = "mybot")
+room <- chat_resolve(cl, "#general:example.org")
+id <- chat_send(cl, room, "hello")
+chat_react(cl, room, id, "+1")
+}
+}
diff --git a/man/chat_reaction.Rd b/man/chat_reaction.Rd
index 1fff242..11fdab3 100644
--- a/man/chat_reaction.Rd
+++ b/man/chat_reaction.Rd
@@ -44,3 +44,7 @@ folding one into the other would make every consumer disambiguate by
inspecting fields.
}
+\examples{
+chat_reaction("r1", "general", "alice", target = "m1",
+ key = "+1", ts = as.POSIXct("2026-01-01", tz = "UTC"))
+}
diff --git a/man/chat_relogin.Rd b/man/chat_relogin.Rd
index 9146a26..3c1397e 100644
--- a/man/chat_relogin.Rd
+++ b/man/chat_relogin.Rd
@@ -24,3 +24,10 @@ caller that gets FALSE, and the first means the next call will fail
with a stale token.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration with login credentials.
+cl <- chat_matrix(app = "mybot")
+chat_relogin(cl)
+}
+}
diff --git a/man/chat_resolve.Rd b/man/chat_resolve.Rd
index 94e2fac..7075630 100644
--- a/man/chat_resolve.Rd
+++ b/man/chat_resolve.Rd
@@ -18,3 +18,6 @@ The adapter-native channel identifier (character).
\description{
Resolve a human channel name to its identifier
}
+\examples{
+chat_resolve(chat_loopback(), "general")
+}
diff --git a/man/chat_send.Rd b/man/chat_send.Rd
index e68bb5a..39cf511 100644
--- a/man/chat_send.Rd
+++ b/man/chat_send.Rd
@@ -76,3 +76,9 @@ Character vector of the message ids this call created, in the
\description{
Send a message through a chat client
}
+\examples{
+cl <- chat_loopback()
+id <- chat_send(cl, "general", "hello", markup = "plain")
+chat_send(cl, "general", "a reply", thread = id)
+chat_poll(cl)$messages
+}
diff --git a/man/chat_set_identity.Rd b/man/chat_set_identity.Rd
index 7fa1041..3e8d7d5 100644
--- a/man/chat_set_identity.Rd
+++ b/man/chat_set_identity.Rd
@@ -31,3 +31,10 @@ rotation lands in the client that performed it, and nothing outside
has to know it happened.
}
+\examples{
+\dontrun{
+# Requires a saved Matrix configuration and account credentials.
+cl <- chat_matrix(app = "mybot")
+chat_set_identity(cl, "Example Bot")
+}
+}
diff --git a/man/chat_set_state.Rd b/man/chat_set_state.Rd
index 2641327..eb8de40 100644
--- a/man/chat_set_state.Rd
+++ b/man/chat_set_state.Rd
@@ -39,3 +39,8 @@ a state write that silently did nothing leaves the caller believing
a marker is set that no reader will ever see.
}
+\examples{
+cl <- chat_loopback()
+chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning"))
+chat_get_state(cl, "general", "m.room.topic")
+}
diff --git a/man/chat_slack.Rd b/man/chat_slack.Rd
index e0c51bb..016dee2 100644
--- a/man/chat_slack.Rd
+++ b/man/chat_slack.Rd
@@ -78,3 +78,10 @@ something the member typed, and a consumer that replies to the
member's traffic will reply to it.
}
+\examples{
+\dontrun{
+# Requires slackr, SLACK_TOKEN, and a channel the token can access.
+cl <- chat_slack(channels = "C0123456789")
+chat_send(cl, "C0123456789", "hello")
+}
+}
diff --git a/man/chat_telegram.Rd b/man/chat_telegram.Rd
index 2c622a4..145d5c5 100644
--- a/man/chat_telegram.Rd
+++ b/man/chat_telegram.Rd
@@ -65,3 +65,11 @@ comes out as ordinary traffic. Passing the returned cursor back as
\code{since} confirms it; Telegram re-sends anything unconfirmed.
}
+\examples{
+\dontrun{
+# Requires httr, TELEGRAM_BOT_TOKEN, and access to the target chat.
+cl <- chat_telegram()
+chat_whoami(cl)
+chat_send(cl, "@example_channel", "hello")
+}
+}
diff --git a/man/chat_typing.Rd b/man/chat_typing.Rd
index 90f26bb..f3c7968 100644
--- a/man/chat_typing.Rd
+++ b/man/chat_typing.Rd
@@ -31,3 +31,7 @@ TRUE if the signal was sent, FALSE otherwise, invisibly.
Capability-gated: the default method is a no-op so adapters without
typing indicators need not implement it.
}
+\examples{
+cl <- chat_loopback()
+chat_typing(cl, "general") # FALSE: loopback has no typing indicator
+}
diff --git a/tests/tinytest.R b/tests/tinytest.R
index a3932c6..5155488 100644
--- a/tests/tinytest.R
+++ b/tests/tinytest.R
@@ -1,5 +1,7 @@
if (requireNamespace("tinytest", quietly = TRUE)) {
+ Sys.setenv(R_USER_CACHE_DIR = tempfile("chat_api_cache_"),
+ R_USER_DATA_DIR = tempfile("chat_api_data_"),
+ R_USER_CONFIG_DIR = tempfile("chat_api_config_"))
tinytest::test_package("chat.api")
}
-