From dc825980bd0bb375a4021e054fe963d52ae85574 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 12:12:33 -0500 Subject: [PATCH 01/10] Prepare chat.api for CRAN checks --- .Rbuildignore | 2 + .github/workflows/ci.yaml | 109 +++++++++++++-------------------- DESCRIPTION | 18 +++--- R/chat.api-package.R | 16 +++++ R/contract.R | 107 +++++++++++++++++++++++++++++++- R/irc.R | 8 +++ R/matrix-config.R | 31 +++++++--- R/matrix.R | 7 +++ R/slack.R | 6 ++ R/telegram.R | 7 +++ README.md | 6 +- cran-comments.md | 46 ++++++++++++++ inst/tinytest/test_matrix.R | 45 ++++++++++++-- man/chat.api-package.Rd | 18 ++++++ man/chat_capabilities.Rd | 5 ++ man/chat_channel_info.Rd | 7 +++ man/chat_config_save.Rd | 11 +++- man/chat_disconnect.Rd | 4 ++ man/chat_download.Rd | 13 +++- man/chat_edit.Rd | 6 ++ man/chat_get_state.Rd | 6 ++ man/chat_invite.Rd | 3 + man/chat_irc.Rd | 9 +++ man/chat_join.Rd | 7 +++ man/chat_leave.Rd | 7 +++ man/chat_mark_read.Rd | 5 ++ man/chat_matrix.Rd | 8 +++ man/chat_matrix_config.Rd | 12 ++-- man/chat_matrix_config_path.Rd | 4 +- man/chat_matrix_configure.Rd | 4 +- man/chat_members.Rd | 7 +++ man/chat_message.Rd | 4 ++ man/chat_pending.Rd | 2 + man/chat_poll.Rd | 7 +++ man/chat_react.Rd | 9 +++ man/chat_reaction.Rd | 4 ++ man/chat_relogin.Rd | 7 +++ man/chat_resolve.Rd | 3 + man/chat_send.Rd | 6 ++ man/chat_set_identity.Rd | 7 +++ man/chat_set_state.Rd | 5 ++ man/chat_slack.Rd | 7 +++ man/chat_telegram.Rd | 8 +++ man/chat_typing.Rd | 4 ++ tests/tinytest.R | 4 +- 45 files changed, 514 insertions(+), 107 deletions(-) create mode 100644 R/chat.api-package.R create mode 100644 cran-comments.md create mode 100644 man/chat.api-package.Rd 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..85a4356 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,51 @@ 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) - if: runner.os == 'Linux' + # Use CRAN directly so recently published Matrix versions do not + # depend on r2u's rebuild schedule. Include all adapter test packages. + - 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_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..af9fffa 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: chat.api Type: Package -Title: Transport-Agnostic Chat Contract for R Agents +Title: Transport-Agnostic Chat Contract Version: 0.0.1.29 Date: 2026-09-08 Authors@R: c( @@ -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/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..7693a51 --- /dev/null +++ b/cran-comments.md @@ -0,0 +1,46 @@ +## 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 R-release and R-devel: pending win-builder checks. + +## R CMD check results + +0 errors | 0 warnings | 1 note + +The note is "New submission". + +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..4608c25 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. @@ -1322,6 +1320,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 +1338,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 +1468,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 +1482,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 +1494,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 +1509,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 +1528,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 +1604,7 @@ local({ }) # ---- Fetching media ---- +if (requireNamespace("mx.client", quietly = TRUE)) local({ seen <- NULL cl <- seam_client(.download = function(session, mxc_url, dest) { @@ -1609,6 +1616,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 +1710,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 +1739,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 +1761,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 +1769,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 +1785,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 +1897,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 +1917,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 +1943,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 +1964,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 +1973,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 +1983,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 +2000,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 +2010,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 +2024,7 @@ local({ }) # ---- State: pending ---- +if (requireNamespace("mx.client", quietly = TRUE)) local({ seen <- NULL cl <- seam_client(.pending = function(session, timeout = NULL, ...) { @@ -2025,6 +2048,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 +2057,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 +2072,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 +2111,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 +2135,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 +2150,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 +2196,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 +2229,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 +2272,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 +2284,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/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") } - From 021265bbb244df55c393547f9f780a101cafbf43 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 12:12:36 -0500 Subject: [PATCH 02/10] Bump version to 0.1.0 --- DESCRIPTION | 4 ++-- NEWS.md | 68 +++++++++++++---------------------------------------- 2 files changed, 18 insertions(+), 54 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index af9fffa..4eda3f6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract -Version: 0.0.1.29 -Date: 2026-09-08 +Version: 0.1.0 +Date: 2026-09-11 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", 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. From d32f5a7ca2c8cff0d0c3f8960636c863c0ba7bf7 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 12:14:27 -0500 Subject: [PATCH 03/10] Record win-builder uploads --- cran-comments.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cran-comments.md b/cran-comments.md index 7693a51..d1f52ad 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -9,7 +9,8 @@ All suggested packages and required versions are available from CRAN. ## Test environments * Ubuntu 24.04.5 LTS, x86_64, R 4.6.1. -* Windows R-release and R-devel: pending win-builder checks. +* Windows R-release and R-devel: uploaded to win-builder on 2026-09-11; + results pending. ## R CMD check results From 21711b2ad9444e2ab3b1077e09bf4776bd49f1dd Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 13:19:05 -0500 Subject: [PATCH 04/10] Fix Windows assumptions in Matrix tests --- cran-comments.md | 9 ++++++--- inst/tinytest/test_matrix.R | 5 +++-- inst/tinytest/test_matrix_mxclient.R | 10 ++++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index d1f52ad..3f24774 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -9,12 +9,15 @@ All suggested packages and required versions are available from CRAN. ## Test environments * Ubuntu 24.04.5 LTS, x86_64, R 4.6.1. -* Windows R-release and R-devel: uploaded to win-builder on 2026-09-11; - results pending. +* Windows R-release: uploaded to win-builder on 2026-09-11; results pending. +* Windows Server 2022, R-devel (2026-09-10 r90519 ucrt): the first + win-builder check found 3 Unix-specific test assertions. The tests now + accept Windows absolute paths and check Unix file modes only on Unix. + A corrected Windows check is pending. ## R CMD check results -0 errors | 0 warnings | 1 note +Linux: 0 errors | 0 warnings | 1 note The note is "New submission". diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 4608c25..b7c3cf2 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -740,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") 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") From 1518eaaf4837358a867fb2ec06e294a119980db3 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 13:21:43 -0500 Subject: [PATCH 05/10] Record corrected win-builder uploads --- cran-comments.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index 3f24774..cd8ae71 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -9,11 +9,12 @@ All suggested packages and required versions are available from CRAN. ## Test environments * Ubuntu 24.04.5 LTS, x86_64, R 4.6.1. -* Windows R-release: uploaded to win-builder on 2026-09-11; results pending. +* Windows R-release: corrected source uploaded to win-builder on + 2026-09-11; results pending. * Windows Server 2022, R-devel (2026-09-10 r90519 ucrt): the first win-builder check found 3 Unix-specific test assertions. The tests now accept Windows absolute paths and check Unix file modes only on Unix. - A corrected Windows check is pending. + Corrected source was uploaded on 2026-09-11; results pending. ## R CMD check results From bc1f81d96f2879ceb6f002b289aedc50ac444e14 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 13:54:41 -0500 Subject: [PATCH 06/10] Record R-release dependency warning and retry --- cran-comments.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index cd8ae71..514f672 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -9,8 +9,7 @@ All suggested packages and required versions are available from CRAN. ## Test environments * Ubuntu 24.04.5 LTS, x86_64, R 4.6.1. -* Windows R-release: corrected source uploaded to win-builder on - 2026-09-11; results pending. +* Windows Server 2022 x64, R-release 4.6.1 (2026-06-24 ucrt), win-builder. * Windows Server 2022, R-devel (2026-09-10 r90519 ucrt): the first win-builder check found 3 Unix-specific test assertions. The tests now accept Windows absolute paths and check Unix file modes only on Unix. @@ -20,8 +19,19 @@ All suggested packages and required versions are available from CRAN. Linux: 0 errors | 0 warnings | 1 note +Windows R-release: 0 errors | 1 warning | 1 note + The note is "New submission". +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 still has 0.2.1. 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. That repeat check is pending. + 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, From c911eac9690381ea8d75785d4c15a1e9849b904f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 14:31:34 -0500 Subject: [PATCH 07/10] Record clean R-devel check --- cran-comments.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index 514f672..f725e85 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -10,10 +10,7 @@ All suggested packages and required versions are available from CRAN. * 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, R-devel (2026-09-10 r90519 ucrt): the first - win-builder check found 3 Unix-specific test assertions. The tests now - accept Windows absolute paths and check Unix file modes only on Unix. - Corrected source was uploaded on 2026-09-11; results pending. +* Windows Server 2022 x64, R-devel (2026-09-10 r90519 ucrt), win-builder. ## R CMD check results @@ -21,13 +18,20 @@ 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 still has 0.2.1. The 10 crypto assertions guarded -by a newer mx.crypto version did not run. The published source archives +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. That repeat check is pending. From 31b16a953b9db548df7d217aac08de7d8110b6ba Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 14:37:32 -0500 Subject: [PATCH 08/10] Record persistent R-release dependency warning --- cran-comments.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cran-comments.md b/cran-comments.md index f725e85..0384221 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -34,7 +34,9 @@ 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. That repeat check is pending. +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 From 93ca93909f65d289fc8425faaf7991995750f453 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 15:29:01 -0500 Subject: [PATCH 09/10] Install Matrix sources and fix portable CI version checks --- .github/workflows/ci.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 85a4356..881d99a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,12 +25,15 @@ jobs: with: backend: RAPT - # Use CRAN directly so recently published Matrix versions do not - # depend on r2u's rebuild schedule. Include all adapter test packages. + # 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() + 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")' @@ -46,7 +49,7 @@ jobs: entries <- trimws(strsplit(suggests, ",", fixed = TRUE)[[1L]]) for (entry in entries) { parts <- regmatches(entry, regexec( - "^([[:alnum:].]+)( \\(>= ([0-9.]+)\\))?$", entry))[[1L]] + "^([[:alnum:].]+)( [(]>= ([0-9.]+)[)])?$", entry))[[1L]] if (!length(parts)) stop("Unsupported Suggests entry: ", entry) pkg <- parts[[2L]] if (!requireNamespace(pkg, quietly = TRUE)) From 9fbc020a4ec7ebc30df6d0782d1bb120db5efec7 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 11 Sep 2026 15:34:03 -0500 Subject: [PATCH 10/10] Fix CI build prerequisites and normalized upload paths --- .github/workflows/ci.yaml | 4 ++++ inst/tinytest/test_telegram.R | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 881d99a..0db52f4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,6 +25,10 @@ jobs: with: backend: RAPT + - 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 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