From ff5ef60163ed8f4574e9dbcc0e6d80f88c933e12 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 6 Oct 2026 11:36:11 -0500 Subject: [PATCH 01/11] Let a Matrix client take part in a room's call chat_call_join() announces the client as a MatrixRTC member, obtains the media server's address and token, and exchanges per-member media keys over Olm for as long as the client polls; chat_call_media() hands a media side what it needs to connect; chat_call_updates() reports the keys and membership that arrived since the last look; chat_call_leave() withdraws. The media itself is the caller's, typically in another process. mx.client does the protocol. The adapter owns what the protocol needs and would otherwise be copied out of it: each operation is handed the live credentials (a call outlives relogins) and the adapter's Olm sessions, and the sessions a key send opens come back into the adapter's crypto state, or its next save would write an older set over them. The call reads the to-device events the adapter already decrypted for the poll rather than decrypting the sync again. A call that cannot be advanced is a warning, not an error: the cursor has moved, and the next poll retries. Needs an e2ee client and mx.client 0.2.1.1 (the floor in Suggests). The capability flag is calls, FALSE on every other adapter. --- DESCRIPTION | 2 +- NAMESPACE | 9 ++ NEWS.md | 13 ++ R/contract.R | 125 +++++++++++++- R/irc.R | 2 +- R/loopback.R | 2 +- R/matrix-call.R | 168 +++++++++++++++++++ R/matrix-crypto.R | 5 + R/matrix.R | 20 ++- R/slack.R | 2 +- R/telegram.R | 2 +- inst/tinytest/test_matrix_call.R | 270 +++++++++++++++++++++++++++++++ man/chat_call_join.Rd | 53 ++++++ man/chat_call_leave.Rd | 22 +++ man/chat_call_media.Rd | 22 +++ man/chat_call_updates.Rd | 25 +++ man/chat_capabilities.Rd | 3 +- man/chat_matrix.Rd | 8 +- 18 files changed, 743 insertions(+), 10 deletions(-) create mode 100644 R/matrix-call.R create mode 100644 inst/tinytest/test_matrix_call.R create mode 100644 man/chat_call_join.Rd create mode 100644 man/chat_call_leave.Rd create mode 100644 man/chat_call_media.Rd create mode 100644 man/chat_call_updates.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 4eda3f6..01a83d3 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -23,7 +23,7 @@ BugReports: https://github.com/cornball-ai/chat.api/issues Suggests: httr, mx.api, - mx.client (>= 0.2.1), + mx.client (>= 0.2.1.1), mx.crypto (>= 0.2.2), slackr, telegram, diff --git a/NAMESPACE b/NAMESPACE index 815a393..8d05009 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,6 +2,10 @@ export(chat_addressed) export(chat_attachment) +export(chat_call_join) +export(chat_call_leave) +export(chat_call_media) +export(chat_call_updates) export(chat_capabilities) export(chat_channel_create) export(chat_channel_info) @@ -45,6 +49,10 @@ S3method(chat_addressed,chat_matrix) S3method(chat_addressed,chat_slack) S3method(chat_addressed,chat_telegram) S3method(chat_addressed,default) +S3method(chat_call_join,chat_matrix) +S3method(chat_call_join,default) +S3method(chat_call_leave,chat_matrix) +S3method(chat_call_leave,default) S3method(chat_capabilities,chat_irc) S3method(chat_capabilities,chat_loopback) S3method(chat_capabilities,chat_matrix) @@ -134,6 +142,7 @@ S3method(chat_whoami,chat_slack) S3method(chat_whoami,chat_telegram) S3method(chat_whoami,default) S3method(print,chat_attachment) +S3method(print,chat_call) S3method(print,chat_config) S3method(print,chat_identity) S3method(print,chat_invite) diff --git a/NEWS.md b/NEWS.md index ca957de..b3407f8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,16 @@ +# chat.api 0.1.0.1 + +* Matrix clients can take part in a room's call: `chat_call_join()` + announces the client as a MatrixRTC member, obtains the media server's + address and token, and exchanges per-member media keys over Olm for as + long as the client polls; `chat_call_media()` hands a media side what it + needs to connect, `chat_call_updates()` reports keys and membership that + arrived since the last look, `chat_call_leave()` withdraws. The media + itself is the caller's. Each call operation is handed the client's live + credentials and Olm sessions, and the sessions it opens come back into + the client's crypto state. Needs an `e2ee` client and mx.client 0.2.1.1; + the new capability flag is `calls`, FALSE on every other adapter. + # chat.api 0.1.0 * First CRAN release. diff --git a/R/contract.R b/R/contract.R index d88db5e..dc4d62c 100644 --- a/R/contract.R +++ b/R/contract.R @@ -141,7 +141,8 @@ chat_resolve <- function(client, name, ...) { #' \code{chat_send(files =)} works), \code{attachments} (inbound: #' media comes back out of \code{\link{chat_poll}} as #' \code{\link{chat_attachment}} records), \code{typing}, -#' \code{e2ee}, \code{identity_override} (logicals), +#' \code{e2ee}, \code{calls} (\code{\link{chat_call_join}} works), +#' \code{identity_override} (logicals), #' \code{user_identity} (a send can authenticate as a real member of #' the platform rather than as the bot -- Slack's #' \code{chat_send(as_user = TRUE)} with a user token; a property of @@ -1169,3 +1170,125 @@ chat_edit.default <- function(client, channel, message_id, text, paste(class(client), collapse = "/"), "). Check chat_capabilities()$edits.", call. = FALSE) } + +#' Join a channel's call +#' +#' Takes part in the channel's live call as this client's identity: the +#' signaling side only. The client announces itself as a call member, +#' obtains the media server's address and a token for it, and exchanges +#' media keys with the other members as they come and go, for as long +#' as the client keeps polling. The media itself (joining the media +#' server, audio in and out) is the caller's, typically in another +#' process: \code{\link{chat_call_media}} gives it what it needs, and +#' \code{\link{chat_call_updates}} the keys that arrive later. Every +#' \code{\link{chat_poll}} on the client advances its calls. +#' Capability-gated: check \code{chat_capabilities()$calls}. +#' +#' On Matrix this is a MatrixRTC call (what Element Call and FluffyChat +#' 2.10 join), over LiveKit, encrypted end to end with per-member keys +#' sent over Olm; it needs an \code{e2ee} client. +#' +#' @param client A \code{chat_client}. +#' @param channel Channel/room identifier. +#' @param intent \code{"voice"} or \code{"video"}, what the membership +#' announces. +#' @param ... Adapter-specific options. Matrix: \code{service_url}, the +#' media token service to use instead of discovering one. +#' @return A \code{chat_call}: an environment the adapter keeps +#' advancing on every poll until \code{\link{chat_call_leave}}. Its +#' \code{channel} and \code{identity} are plain fields. +#' @examples +#' \dontrun{ +#' cl <- chat_matrix(app = "mybot", e2ee = TRUE) +#' call <- chat_call_join(cl, "!room:example.org") +#' chat_call_media(call)$url +#' repeat { +#' chat_poll(cl, timeout = 1) +#' upd <- chat_call_updates(call) +#' for (k in upd$keys) message("key from ", k$identity) +#' } +#' } +#' @export +chat_call_join <- function(client, channel, intent = "voice", ...) { + UseMethod("chat_call_join") +} + +#' @export +chat_call_join.default <- function(client, channel, intent = "voice", ...) { + stop("chat_call_join() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$calls.", call. = FALSE) +} + +#' Leave a call +#' +#' Withdraws the membership \code{\link{chat_call_join}} announced and +#' stops advancing the call. The media side, if the caller opened one, +#' is the caller's to close. +#' +#' @param client The \code{chat_client} the call was joined on. +#' @param call A \code{chat_call} from \code{\link{chat_call_join}}. +#' @param ... Adapter-specific options. +#' @return The call, invisibly. +#' @export +chat_call_leave <- function(client, call, ...) { + UseMethod("chat_call_leave") +} + +#' @export +chat_call_leave.default <- function(client, call, ...) { + stop("chat_call_leave() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$calls.", call. = FALSE) +} + +#' What a media client needs to join a call +#' +#' The media server's address, the token that admits this identity, the +#' identity itself, and this client's current media key, as the adapter +#' holds them now. Taken once when the media side starts; a key that +#' rotates later arrives through \code{\link{chat_call_updates}}. +#' +#' @param call A \code{chat_call}. +#' @return A list: \code{url}, \code{jwt}, \code{identity}, +#' \code{key} (a list with \code{key}, raw bytes, and \code{index}), +#' and \code{peers}, a list of the other members' keys known so far, +#' each a list with \code{identity}, \code{key} and \code{index}. +#' @export +chat_call_media <- function(call) { + stopifnot(inherits(call, "chat_call")) + media <- call$media() + media$peers <- call$peers() + media +} + +#' What changed in a call since the last look +#' +#' Keys and membership that arrived through the polls since this was +#' last called, cleared on return. A media side in another process +#' forwards them: a peer's key before that peer's audio can be heard, +#' and this client's own key when it rotated (which it does when a +#' member leaves), or what it publishes stops being decodable. +#' +#' @param call A \code{chat_call}. +#' @return A list: \code{keys}, a list of the peers' keys received +#' (each \code{identity}, \code{key}, \code{index}); \code{own}, this +#' client's key when it changed (\code{key}, \code{index}), else +#' \code{NULL}; \code{members}, the members' identities when the +#' membership changed, else \code{NULL}; \code{ended}, \code{TRUE} +#' once the call has been left. +#' @export +chat_call_updates <- function(call) { + stopifnot(inherits(call, "chat_call")) + out <- call$changes + call$changes <- list(keys = list(), own = NULL, members = NULL, + ended = isTRUE(out$ended)) + out +} + +#' @export +print.chat_call <- function(x, ...) { + cat(" ", x$channel, " as ", x$identity, + if (isTRUE(x$changes$ended)) " (left)" else "", "\n", sep = "") + invisible(x) +} diff --git a/R/irc.R b/R/irc.R index 2266baa..ad35db4 100644 --- a/R/irc.R +++ b/R/irc.R @@ -141,7 +141,7 @@ chat_capabilities.chat_irc <- function(client, ...) { # has no join verb yet, so neither flag can be TRUE honestly. channel_create = FALSE, leave = FALSE, set_state = FALSE, files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = FALSE, user_identity = FALSE, + calls = FALSE, identity_override = FALSE, user_identity = FALSE, rich_markup = character(), markup_dialects = "plain", max_message_bytes = 400L) } diff --git a/R/loopback.R b/R/loopback.R index 33fc55f..8344f4f 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -151,7 +151,7 @@ chat_capabilities.chat_loopback <- function(client, ...) { # them back out of the poll. Both TRUE is what makes loopback # the round-trip test double for media-carrying consumers. files = TRUE, attachments = TRUE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, user_identity = FALSE, + calls = FALSE, identity_override = TRUE, user_identity = FALSE, rich_markup = character(), markup_dialects = c("plain", "markdown"), max_message_bytes = NA_integer_) diff --git a/R/matrix-call.R b/R/matrix-call.R new file mode 100644 index 0000000..afd7571 --- /dev/null +++ b/R/matrix-call.R @@ -0,0 +1,168 @@ +# MatrixRTC calls on the Matrix adapter: the signaling side of a call, +# advanced from chat_poll(). +# +# mx.client does the protocol (mx_call_join / mx_call_handle / +# mx_call_leave: membership state, the token service, per-member media +# keys over Olm). What this file adds is the adapter's ownership of +# the pieces those need and would otherwise be copied out of it: +# +# - the live credentials. A call lives across relogins, so the mx_call +# is handed client$env$mx again before every operation, never a copy +# taken at join time (the derive-at-use rule that applies to every +# credential in this adapter). +# - the Olm sessions. Sending a key opens Olm sessions with the other +# members' devices; those sessions have to come back into the +# adapter's crypto state, or the adapter's next save writes an older +# set over them and the peers' later to-device traffic cannot be +# decrypted. The call is given the adapter's sessions before each +# operation and the adapter adopts them after. +# - the decrypted to-device events. The adapter decrypts the sync +# once, in matrix_crypto_decrypt(); the call reads the result rather +# than decrypting the same sync again (which would advance the Olm +# ratchets twice). +# +# The media itself never comes here. A caller opens the media server +# leg where it wants (corteza: in a worker process) from what +# chat_call_media() hands it, and feeds it what chat_call_updates() +# reports after each poll. + +.MATRIX_CALLS_MIN <- "0.2.1.1" + +matrix_calls_available <- function(ops = NULL) { + if (!is.null(ops)) { + return(TRUE) + } + requireNamespace("mx.client", quietly = TRUE) && + utils::packageVersion("mx.client") >= .MATRIX_CALLS_MIN +} + +# The three mx.client verbs, resolved at the point of use so the +# package is only loaded when a call is made; a test supplies fakes. +matrix_call_ops <- function(override = NULL) { + ops <- list(join = function(...) mx.client::mx_call_join(...), + handle = function(...) mx.client::mx_call_handle(...), + leave = function(...) mx.client::mx_call_leave(...)) + for (nm in names(override)) { + ops[[nm]] <- override[[nm]] + } + ops +} + +#' @export +chat_call_join.chat_matrix <- function(client, channel, intent = "voice", + service_url = NULL, ...) { + if (!isTRUE(client$e2ee)) { + stop("chat_call_join() needs a client built with e2ee = TRUE: a ", + "call's media keys travel over Olm", call. = FALSE) + } + if (!matrix_calls_available(client$call_override)) { + stop("chat_call_join() needs mx.client >= ", .MATRIX_CALLS_MIN, + call. = FALSE) + } + if (!is.null(client$env$calls[[channel]]) && + !isTRUE(client$env$calls[[channel]]$changes$ended)) { + stop("already in the call in ", channel, "; chat_call_leave() it first", + call. = FALSE) + } + crypto <- matrix_crypto_require(client) + mx_call <- client$call_ops$join(client$env$mx, crypto$account, + crypto$sessions, channel, intent = intent, + store_dir = crypto$store, connect = FALSE, + service_url = service_url) + matrix_call_adopt_sessions(crypto, mx_call) + call <- new.env(parent = emptyenv()) + call$channel <- channel + call$identity <- mx_call$identity + call$mx <- mx_call + call$changes <- list(keys = list(), own = NULL, members = NULL, + ended = FALSE) + call$media <- function() { + list(url = mx_call$token$url, jwt = mx_call$token$jwt, + identity = mx_call$identity, + key = list(key = mx_call$keys$key, index = mx_call$keys$index)) + } + call$peers <- function() { + lapply(names(mx_call$keys$peers), function(id) { + p <- mx_call$keys$peers[[id]] + list(identity = id, key = p$key, index = p$index) + }) + } + class(call) <- "chat_call" + if (is.null(client$env$calls)) { + client$env$calls <- list() + } + client$env$calls[[channel]] <- call + call +} + +#' @export +chat_call_leave.chat_matrix <- function(client, call, ...) { + stopifnot(inherits(call, "chat_call")) + if (isTRUE(call$changes$ended)) { + return(invisible(call)) + } + call$mx$client <- client$env$mx + client$call_ops$leave(call$mx) + call$changes$ended <- TRUE + client$env$calls[[call$channel]] <- NULL + invisible(call) +} + +# Advance every live call with this poll's sync. Runs after the sync +# is decrypted and consumed; a failure here is reported, not thrown, +# since throwing would lose the poll's messages for a sync the cursor +# has already moved past, and the next poll retries what a call needs +# (membership and keys are re-read from room state and resent). +matrix_calls_sync <- function(client, sync, crypto) { + calls <- client$env$calls + if (!length(calls) || is.null(crypto)) { + return(invisible(NULL)) + } + processed <- list(to_device = crypto$to_device %||% list()) + crypto$to_device <- NULL + for (channel in names(calls)) { + call <- calls[[channel]] + if (isTRUE(call$changes$ended)) { + next + } + mx_call <- call$mx + mx_call$client <- client$env$mx + mx_call$sessions <- crypto$sessions + own_before <- list(key = mx_call$keys$key, index = mx_call$keys$index) + res <- tryCatch(client$call_ops$handle(mx_call, sync, + processed = processed), + error = function(e) { + warning("chat.api: the call in ", channel, " could not be advanced: ", + conditionMessage(e), call. = FALSE) + NULL + }) + matrix_call_adopt_sessions(crypto, mx_call) + if (is.null(res)) { + next + } + for (id in res$keys) { + p <- mx_call$keys$peers[[id]] + if (!is.null(p)) { + call$changes$keys[[length(call$changes$keys) + 1L]] <- + list(identity = id, key = p$key, index = p$index) + } + } + if (!identical(own_before$key, mx_call$keys$key) || + !identical(own_before$index, mx_call$keys$index)) { + call$changes$own <- list(key = mx_call$keys$key, + index = mx_call$keys$index) + } + if (!is.null(res$members)) { + call$changes$members <- vapply(res$members, function(m) m$identity, "") + } + } + invisible(NULL) +} + +# The Olm sessions a call operation left behind are the adapter's now. +matrix_call_adopt_sessions <- function(crypto, mx_call) { + if (!is.null(mx_call$sessions)) { + crypto$sessions <- mx_call$sessions + } + invisible(NULL) +} diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R index 4f3ae4d..ddd33de 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -558,6 +558,11 @@ matrix_crypto_decrypt <- function(crypto, sync, mx) { self_id = mx$user_id, devices = devices, self_device_id = mx$device_id) matrix_crypto_commit_key_requests(crypto, res, mx) + # The decrypted to-device events, for the calls this client is in + # (matrix_calls_sync): their peers' media keys arrive this way. Held + # on the context for this poll only; decrypting the sync a second + # time for them would advance the Olm ratchets twice. + crypto$to_device <- res$to_device res$events } diff --git a/R/matrix.R b/R/matrix.R index 7cd1d7c..bf4efe4 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -153,6 +153,10 @@ #' the rich-send path. Leave NULL in production. #' @param .edit Testing seam: replacement for \code{mx.api::mx_send} on #' the edit path. Leave NULL in production. +#' @param .call Testing seam: a named list overriding any of the three +#' call operations -- \code{join}, \code{handle}, \code{leave} +#' (\code{mx.client::mx_call_join}, \code{mx_call_handle}, +#' \code{mx_call_leave}). Leave NULL in production. #' @return A \code{chat_client} of class \code{chat_matrix}. #' \code{\link{chat_poll}} on this class returns \code{first_run} and #' \code{client} alongside \code{messages}, \code{cursor}, and @@ -180,7 +184,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .get_state = NULL, .extract_media = NULL, .download = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, - .edit = NULL, .rich = NULL) { + .edit = NULL, .rich = NULL, .call = NULL) { seams <- list(.sync, .extract, .send, .media) if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) && !requireNamespace("mx.client", quietly = TRUE)) { @@ -242,7 +246,8 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, pending_fn = .pending, read_fn = .read, identity_fn = .identity, edit_fn = .edit, rich_fn = .rich, - crypto_ops = matrix_crypto_ops(.crypto)), + crypto_ops = matrix_crypto_ops(.crypto), + call_ops = matrix_call_ops(.call), call_override = .call), class = c("chat_matrix", "chat_client")) } @@ -533,6 +538,12 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { # Nothing above threw, so the sync is consumed and its cursor # becomes the live one. client$env$mx <- res$client + # The calls this client is in read the decrypted to-device + # events of this sync (their peers' media keys) and its + # membership changes. After the cursor: a call that cannot be + # advanced is reported and retried on the next poll, and must + # not cost the poll its messages. + matrix_calls_sync(client, res$sync, crypto) } event_ts <- matrix_event_times(res$sync) @@ -979,6 +990,11 @@ chat_capabilities.chat_matrix <- function(client, ...) { # unmentioned. attachments = matrix_media_capable(client), typing = TRUE, e2ee = isTRUE(client$e2ee), + # A call's media keys travel over Olm, so only an e2ee client + # can take part, and only with an mx.client that speaks + # MatrixRTC. + calls = isTRUE(client$e2ee) && + matrix_calls_available(client$call_override), identity_override = FALSE, user_identity = FALSE, # Empty on an e2ee client: the Megolm path builds its own HTML # from markdown and has nowhere to put a supplied fragment. diff --git a/R/slack.R b/R/slack.R index 47a38ef..fae5a3b 100644 --- a/R/slack.R +++ b/R/slack.R @@ -466,7 +466,7 @@ chat_capabilities.chat_slack <- function(client, ...) { mark_read = TRUE, set_identity = TRUE, relogin = FALSE, channel_create = TRUE, leave = TRUE, set_state = FALSE, files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, + calls = FALSE, identity_override = TRUE, # Unlike every other flag here, this one is a property of the # instance, not the adapter: whether as_user = TRUE will work # depends on whether this client was built with a user_token, diff --git a/R/telegram.R b/R/telegram.R index 2a7a501..edf8052 100644 --- a/R/telegram.R +++ b/R/telegram.R @@ -654,7 +654,7 @@ chat_capabilities.chat_telegram <- function(client, ...) { mark_read = FALSE, set_identity = TRUE, relogin = FALSE, channel_create = FALSE, leave = TRUE, set_state = FALSE, files = TRUE, attachments = TRUE, typing = TRUE, e2ee = FALSE, - identity_override = FALSE, user_identity = FALSE, + calls = FALSE, identity_override = FALSE, user_identity = FALSE, rich_markup = "html", markup_dialects = c("plain", "markdown"), # 4096 characters after entities parsing. Characters, not diff --git a/inst/tinytest/test_matrix_call.R b/inst/tinytest/test_matrix_call.R new file mode 100644 index 0000000..e4f0e29 --- /dev/null +++ b/inst/tinytest/test_matrix_call.R @@ -0,0 +1,270 @@ +# MatrixRTC calls on the Matrix adapter (R/matrix-call.R), with the +# protocol (mx.client's mx_call_*) and the crypto faked through the +# constructor's seams. What is under test is the adapter's part: the +# live credentials and Olm sessions handed to each call operation, the +# decrypted to-device events of a poll reaching the call, and what a +# consumer is told afterwards. + +library(tinytest) + +fake_mx <- function(token = "tok", user_id = "@bot:ex", device_id = "DEV1") { + list(user_id = user_id, server = "https://ex.invalid", token = token, + device_id = device_id, sync_token = NULL) +} + +wrap_sync <- function(events = list(), room = "!room:ex", to_device = list()) { + join <- list(list(timeline = list(events = events))) + names(join) <- room + list(rooms = list(join = join), to_device = list(events = to_device)) +} + +key_event <- function(identity, key_b64, index) { + list(type = "io.element.call.encryption_keys", sender = sub(":[^:]+$", "", identity), + content = list(keys = list(list(index = index, key = key_b64)), + member = list(id = sub(":[^:]+$", "", identity), + claimed_device_id = sub("^.*:", "", identity)), + room_id = "!room:ex")) +} + +# A crypto context the fake decrypt fills with this poll's to-device +# events, the way the real one does. +fake_crypto <- function() { + chat.api:::matrix_crypto_forget() + ctx <- new.env(parent = emptyenv()) + ctx$account <- "ACCOUNT" + ctx$sessions <- list(olm = list(), generation = 0L) + ctx$store <- "/tmp/fake-store" + ctx$encrypted <- "!room:ex" + ctx$next_to_device <- list() + ops <- list(init = function(mx, store = NULL, app = NULL) ctx, + encrypted = function(crypto, mx, room_id) room_id %in% crypto$encrypted, + send = function(crypto, mx, room_id, text, ...) "$sent", + decrypt = function(crypto, sync, mx) { + crypto$to_device <- sync$to_device$events %||% list() + list() + }) + list(ops = ops, ctx = ctx) +} + +# The protocol, faked: a join mints a token and a key; a handle applies +# the key events it is given, rotates the key on a membership change, +# and records what it was handed; each operation touches the sessions +# so the adapter's adoption of them can be seen. +fake_calls <- function(handle_error = FALSE) { + log <- new.env(parent = emptyenv()) + log$join <- list() + log$handle <- list() + log$leave <- list() + ops <- list( + join = function(client, account, sessions, room_id, intent = "voice", + store_dir = NULL, connect = TRUE, service_url = NULL) { + log$join[[length(log$join) + 1L]] <- list( + client = client, account = account, sessions = sessions, + room_id = room_id, intent = intent, store_dir = store_dir, + connect = connect, service_url = service_url) + call <- new.env(parent = emptyenv()) + call$client <- client + call$account <- account + call$sessions <- modifyList(sessions, list(generation = sessions$generation + 1L)) + call$room_id <- room_id + call$identity <- paste0(client$user_id, ":", client$device_id) + call$token <- list(url = "wss://sfu.ex", jwt = "jwt-1") + call$keys <- list(key = as.raw(1:16), index = 0L, peers = list()) + call + }, + handle = function(call, sync, processed = NULL) { + log$handle[[length(log$handle) + 1L]] <- list( + client = call$client, sessions = call$sessions, processed = processed) + if (isTRUE(handle_error)) { + stop("room state unreachable") + } + call$sessions <- modifyList(call$sessions, + list(generation = call$sessions$generation + 1L)) + received <- character() + for (ev in processed$to_device %||% list()) { + if (identical(ev$type, "io.element.call.encryption_keys")) { + id <- paste0(ev$content$member$id, ":", + ev$content$member$claimed_device_id) + k <- ev$content$keys[[1L]] + call$keys$peers[[id]] <- list(key = jsonlite::base64_dec(k$key), + index = k$index) + received <- c(received, id) + } + } + members <- NULL + room <- sync$rooms$join[[call$room_id]] + if (any(vapply(room$timeline$events %||% list(), function(e) { + identical(e$type, "org.matrix.msc3401.call.member") + }, logical(1)))) { + members <- list(list(identity = call$identity), + list(identity = "@ann:ex:PHONE")) + call$keys$key <- as.raw(17:32) + call$keys$index <- call$keys$index + 1L + } + list(keys = received, members = members) + }, + leave = function(call) { + log$leave[[length(log$leave) + 1L]] <- list(client = call$client) + invisible(NULL) + }) + list(ops = ops, log = log) +} + +seam_client <- function(syncs, crypto, calls, e2ee = TRUE) { + i <- 0L + chat_matrix(mx = fake_mx(), e2ee = e2ee, .crypto = crypto$ops, .call = calls$ops, + .save = function(client, ...) client, + .sync = function(client, ...) { + i <<- i + 1L + s <- if (i <= length(syncs)) syncs[[i]] else wrap_sync() + # Each poll rotates the token, as a relogin would. + list(sync = s, client = fake_mx(sprintf("tok-%d", i)), + first_run = FALSE) + }, + .extract = function(sync, self_id) list(), + .send = function(...) "$id", .media = function(...) NULL) +} + +# ---- capabilities ---- +local({ + cr <- fake_crypto() + expect_true(chat_capabilities(seam_client(list(), cr, fake_calls()))$calls) + expect_false(chat_capabilities(seam_client(list(), cr, fake_calls(), e2ee = FALSE))$calls) + expect_false(chat_capabilities(chat_loopback())$calls) + for (adapter in c("chat_loopback", "chat_irc", "chat_slack", "chat_matrix", + "chat_telegram")) { + m <- getS3method("chat_capabilities", adapter) + caps <- m(structure(list(env = new.env()), class = adapter)) + expect_true(is.logical(caps$calls) && length(caps$calls) == 1L, + info = adapter) + } +}) + +# Other adapters refuse by name. +expect_error(chat_call_join(chat_loopback(), "general"), "not supported") +expect_error(chat_call_leave(chat_loopback(), structure(new.env(), class = "chat_call")), + "not supported") + +# ---- joining ---- +local({ + cr <- fake_crypto() + fc <- fake_calls() + # Not without e2ee: the keys travel over Olm. + expect_error(chat_call_join(seam_client(list(), cr, fc, e2ee = FALSE), "!room:ex"), + "e2ee = TRUE") + cl <- seam_client(list(), cr, fc) + call <- chat_call_join(cl, "!room:ex", service_url = "https://jwt.ex") + expect_true(inherits(call, "chat_call")) + expect_identical(call$channel, "!room:ex") + expect_identical(call$identity, "@bot:ex:DEV1") + # The protocol got the live credentials, the adapter's crypto + # state, its store, and no media connection of its own. + j <- fc$log$join[[1L]] + expect_identical(j$client$token, "tok") + expect_identical(j$account, "ACCOUNT") + expect_identical(j$store_dir, "/tmp/fake-store") + expect_false(j$connect) + expect_identical(j$intent, "voice") + expect_identical(j$service_url, "https://jwt.ex") + # The sessions the join touched are the adapter's now. + expect_identical(cr$ctx$sessions$generation, 1L) + # What a media side needs. + media <- chat_call_media(call) + expect_identical(media$url, "wss://sfu.ex") + expect_identical(media$jwt, "jwt-1") + expect_identical(media$identity, "@bot:ex:DEV1") + expect_identical(media$key, list(key = as.raw(1:16), index = 0L)) + expect_identical(media$peers, list()) + # Nothing has changed yet. + upd <- chat_call_updates(call) + expect_identical(upd$keys, list()) + expect_null(upd$own) + expect_null(upd$members) + expect_false(upd$ended) + # One call per room at a time. + expect_error(chat_call_join(cl, "!room:ex"), "already in the call") + expect_stdout(print(call), " !room:ex as @bot:ex:DEV1") +}) + +# ---- polling advances the call ---- +local({ + cr <- fake_crypto() + fc <- fake_calls() + peer_key <- jsonlite::base64_enc(as.raw(101:116)) + syncs <- list( + wrap_sync(to_device = list(key_event("@ann:ex:PHONE", peer_key, 4L))), + wrap_sync(events = list(list(type = "org.matrix.msc3401.call.member", + sender = "@ann:ex", content = list()))), + wrap_sync()) + cl <- seam_client(syncs, cr, fc) + call <- chat_call_join(cl, "!room:ex") + # Poll 1: a peer's key arrives in the to-device traffic the adapter + # decrypted; the call is handed it, not the raw sync to decrypt again. + chat_poll(cl) + h <- fc$log$handle[[1L]] + expect_identical(length(h$processed$to_device), 1L) + expect_identical(h$processed$to_device[[1L]]$type, "io.element.call.encryption_keys") + # With the credentials this poll rotated to, and the adapter's sessions. + expect_identical(h$client$token, "tok-1") + expect_identical(h$sessions$generation, 1L) + expect_identical(cr$ctx$sessions$generation, 2L) + upd <- chat_call_updates(call) + expect_identical(upd$keys, list(list(identity = "@ann:ex:PHONE", key = as.raw(101:116), + index = 4L))) + expect_null(upd$own) + expect_null(upd$members) + # The peer's key is also in what a media side starting late gets. + expect_identical(chat_call_media(call)$peers[[1L]]$identity, "@ann:ex:PHONE") + # Poll 2: a membership change rotates our key; the consumer is told + # the new key and the members. + chat_poll(cl) + expect_identical(fc$log$handle[[2L]]$client$token, "tok-2") + upd <- chat_call_updates(call) + expect_identical(upd$keys, list()) + expect_identical(upd$own, list(key = as.raw(17:32), index = 1L)) + expect_identical(upd$members, c("@bot:ex:DEV1", "@ann:ex:PHONE")) + expect_identical(chat_call_media(call)$key$index, 1L) + # Poll 3: nothing for the call; updates are empty and were cleared. + chat_poll(cl) + upd <- chat_call_updates(call) + expect_identical(upd$keys, list()) + expect_null(upd$own) + expect_null(upd$members) + expect_identical(length(fc$log$handle), 3L) + # The to-device events were not left on the context for a later poll. + expect_null(cr$ctx$to_device) +}) + +# ---- a call that cannot be advanced costs the poll nothing ---- +local({ + cr <- fake_crypto() + fc <- fake_calls(handle_error = TRUE) + cl <- seam_client(list(wrap_sync()), cr, fc) + call <- chat_call_join(cl, "!room:ex") + res <- NULL + expect_warning(res <- chat_poll(cl), "could not be advanced: room state unreachable") + expect_true(is.list(res) && !is.null(res$messages)) + expect_false(chat_call_updates(call)$ended) + expect_identical(length(fc$log$handle), 1L) +}) + +# ---- leaving ---- +local({ + cr <- fake_crypto() + fc <- fake_calls() + cl <- seam_client(list(wrap_sync(), wrap_sync()), cr, fc) + call <- chat_call_join(cl, "!room:ex") + chat_poll(cl) + chat_call_leave(cl, call) + expect_identical(fc$log$leave[[1L]]$client$token, "tok-1") + expect_true(chat_call_updates(call)$ended) + expect_stdout(print(call), "(left)") + # No longer advanced by polls, and the room can be joined again. + chat_poll(cl) + expect_identical(length(fc$log$handle), 1L) + again <- chat_call_join(cl, "!room:ex") + expect_false(chat_call_updates(again)$ended) + # Leaving twice is idempotent. + chat_call_leave(cl, call) + expect_identical(length(fc$log$leave), 1L) +}) diff --git a/man/chat_call_join.Rd b/man/chat_call_join.Rd new file mode 100644 index 0000000..0359683 --- /dev/null +++ b/man/chat_call_join.Rd @@ -0,0 +1,53 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_call_join} +\alias{chat_call_join} +\title{Join a channel's call} +\usage{ +chat_call_join(client, channel, intent = "voice", ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{channel}{Channel/room identifier.} + +\item{intent}{\code{"voice"} or \code{"video"}, what the membership +announces.} + +\item{...}{Adapter-specific options. Matrix: \code{service_url}, the +media token service to use instead of discovering one.} +} +\value{ +A \code{chat_call}: an environment the adapter keeps + advancing on every poll until \code{\link{chat_call_leave}}. Its + \code{channel} and \code{identity} are plain fields. +} +\description{ +Takes part in the channel's live call as this client's identity: the +signaling side only. The client announces itself as a call member, +obtains the media server's address and a token for it, and exchanges +media keys with the other members as they come and go, for as long +as the client keeps polling. The media itself (joining the media +server, audio in and out) is the caller's, typically in another +process: \code{\link{chat_call_media}} gives it what it needs, and +\code{\link{chat_call_updates}} the keys that arrive later. Every +\code{\link{chat_poll}} on the client advances its calls. +Capability-gated: check \code{chat_capabilities()$calls}. +} +\details{ +On Matrix this is a MatrixRTC call (what Element Call and FluffyChat +2.10 join), over LiveKit, encrypted end to end with per-member keys +sent over Olm; it needs an \code{e2ee} client. + +} +\examples{ +\dontrun{ +cl <- chat_matrix(app = "mybot", e2ee = TRUE) +call <- chat_call_join(cl, "!room:example.org") +chat_call_media(call)$url +repeat { + chat_poll(cl, timeout = 1) + upd <- chat_call_updates(call) + for (k in upd$keys) message("key from ", k$identity) +} +} +} diff --git a/man/chat_call_leave.Rd b/man/chat_call_leave.Rd new file mode 100644 index 0000000..0965320 --- /dev/null +++ b/man/chat_call_leave.Rd @@ -0,0 +1,22 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_call_leave} +\alias{chat_call_leave} +\title{Leave a call} +\usage{ +chat_call_leave(client, call, ...) +} +\arguments{ +\item{client}{The \code{chat_client} the call was joined on.} + +\item{call}{A \code{chat_call} from \code{\link{chat_call_join}}.} + +\item{...}{Adapter-specific options.} +} +\value{ +The call, invisibly. +} +\description{ +Withdraws the membership \code{\link{chat_call_join}} announced and +stops advancing the call. The media side, if the caller opened one, +is the caller's to close. +} diff --git a/man/chat_call_media.Rd b/man/chat_call_media.Rd new file mode 100644 index 0000000..8042d5c --- /dev/null +++ b/man/chat_call_media.Rd @@ -0,0 +1,22 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_call_media} +\alias{chat_call_media} +\title{What a media client needs to join a call} +\usage{ +chat_call_media(call) +} +\arguments{ +\item{call}{A \code{chat_call}.} +} +\value{ +A list: \code{url}, \code{jwt}, \code{identity}, + \code{key} (a list with \code{key}, raw bytes, and \code{index}), + and \code{peers}, a list of the other members' keys known so far, + each a list with \code{identity}, \code{key} and \code{index}. +} +\description{ +The media server's address, the token that admits this identity, the +identity itself, and this client's current media key, as the adapter +holds them now. Taken once when the media side starts; a key that +rotates later arrives through \code{\link{chat_call_updates}}. +} diff --git a/man/chat_call_updates.Rd b/man/chat_call_updates.Rd new file mode 100644 index 0000000..9cc3f83 --- /dev/null +++ b/man/chat_call_updates.Rd @@ -0,0 +1,25 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_call_updates} +\alias{chat_call_updates} +\title{What changed in a call since the last look} +\usage{ +chat_call_updates(call) +} +\arguments{ +\item{call}{A \code{chat_call}.} +} +\value{ +A list: \code{keys}, a list of the peers' keys received + (each \code{identity}, \code{key}, \code{index}); \code{own}, this + client's key when it changed (\code{key}, \code{index}), else + \code{NULL}; \code{members}, the members' identities when the + membership changed, else \code{NULL}; \code{ended}, \code{TRUE} + once the call has been left. +} +\description{ +Keys and membership that arrived through the polls since this was +last called, cleared on return. A media side in another process +forwards them: a peer's key before that peer's audio can be heard, +and this client's own key when it rotated (which it does when a +member leaves), or what it publishes stops being decodable. +} diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index ee0431a..9b8d90e 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -30,7 +30,8 @@ A list with at least: \code{threads} (can post into \code{chat_send(files =)} works), \code{attachments} (inbound: media comes back out of \code{\link{chat_poll}} as \code{\link{chat_attachment}} records), \code{typing}, - \code{e2ee}, \code{identity_override} (logicals), + \code{e2ee}, \code{calls} (\code{\link{chat_call_join}} works), + \code{identity_override} (logicals), \code{user_identity} (a send can authenticate as a real member of the platform rather than as the bot -- Slack's \code{chat_send(as_user = TRUE)} with a user token; a property of diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 1391bb7..9bd92e6 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -34,7 +34,8 @@ chat_matrix( .read = NULL, .identity = NULL, .edit = NULL, - .rich = NULL + .rich = NULL, + .call = NULL ) } \arguments{ @@ -212,6 +213,11 @@ the edit path. Leave NULL in production.} \item{.rich}{Testing seam: replacement for \code{mx.api::mx_send} on the rich-send path. Leave NULL in production.} + +\item{.call}{Testing seam: a named list overriding any of the three +call operations -- \code{join}, \code{handle}, \code{leave} +(\code{mx.client::mx_call_join}, \code{mx_call_handle}, +\code{mx_call_leave}). Leave NULL in production.} } \value{ A \code{chat_client} of class \code{chat_matrix}. From 5e624ed3540d5285c40e64d0dd7aef1ec25a974f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 6 Oct 2026 11:36:29 -0500 Subject: [PATCH 02/11] Bump version to 0.1.0.1 --- DESCRIPTION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 01a83d3..cbd75fb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract -Version: 0.1.0 -Date: 2026-09-11 +Version: 0.1.0.1 +Date: 2026-10-06 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", From 5e27dd0714f17762aca1bcaaacfe79e873033ebf Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 6 Oct 2026 11:38:05 -0500 Subject: [PATCH 03/11] Keep a client's calls on its crypto context, not the client object A consumer that builds a chat client per poll (corteza does) lost its calls with each client. The crypto context is interned per identity and store and holds the Olm sessions the call's keys live in, so the calls live there too: any client for the identity advances them. --- R/matrix-call.R | 30 +++++++++++++++++++++--------- inst/tinytest/test_matrix_call.R | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/R/matrix-call.R b/R/matrix-call.R index afd7571..c185310 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -59,12 +59,17 @@ chat_call_join.chat_matrix <- function(client, channel, intent = "voice", stop("chat_call_join() needs mx.client >= ", .MATRIX_CALLS_MIN, call. = FALSE) } - if (!is.null(client$env$calls[[channel]]) && - !isTRUE(client$env$calls[[channel]]$changes$ended)) { + # The calls live on the identity's crypto context, not on this + # client object: a consumer that builds a client per poll (corteza + # does) keeps its calls, since the context is interned per identity + # and store. The keys a call holds are in that context's Olm + # sessions anyway. + crypto <- matrix_crypto_require(client) + if (!is.null(crypto$calls[[channel]]) && + !isTRUE(crypto$calls[[channel]]$changes$ended)) { stop("already in the call in ", channel, "; chat_call_leave() it first", call. = FALSE) } - crypto <- matrix_crypto_require(client) mx_call <- client$call_ops$join(client$env$mx, crypto$account, crypto$sessions, channel, intent = intent, store_dir = crypto$store, connect = FALSE, @@ -88,10 +93,10 @@ chat_call_join.chat_matrix <- function(client, channel, intent = "voice", }) } class(call) <- "chat_call" - if (is.null(client$env$calls)) { - client$env$calls <- list() + if (is.null(crypto$calls)) { + crypto$calls <- list() } - client$env$calls[[channel]] <- call + crypto$calls[[channel]] <- call call } @@ -104,7 +109,10 @@ chat_call_leave.chat_matrix <- function(client, call, ...) { call$mx$client <- client$env$mx client$call_ops$leave(call$mx) call$changes$ended <- TRUE - client$env$calls[[call$channel]] <- NULL + crypto <- matrix_crypto_require(client) + if (!is.null(crypto)) { + crypto$calls[[call$channel]] <- NULL + } invisible(call) } @@ -114,8 +122,12 @@ chat_call_leave.chat_matrix <- function(client, call, ...) { # has already moved past, and the next poll retries what a call needs # (membership and keys are re-read from room state and resent). matrix_calls_sync <- function(client, sync, crypto) { - calls <- client$env$calls - if (!length(calls) || is.null(crypto)) { + if (is.null(crypto)) { + return(invisible(NULL)) + } + calls <- crypto$calls + if (!length(calls)) { + crypto$to_device <- NULL return(invisible(NULL)) } processed <- list(to_device = crypto$to_device %||% list()) diff --git a/inst/tinytest/test_matrix_call.R b/inst/tinytest/test_matrix_call.R index e4f0e29..90b738c 100644 --- a/inst/tinytest/test_matrix_call.R +++ b/inst/tinytest/test_matrix_call.R @@ -248,6 +248,28 @@ local({ expect_identical(length(fc$log$handle), 1L) }) +# ---- the call outlives the client object ---- +# corteza builds a chat client per poll. The calls live on the identity's +# interned crypto context, so a new client for the same identity polls +# them on. +local({ + cr <- fake_crypto() + fc <- fake_calls() + peer_key <- jsonlite::base64_enc(as.raw(1:16)) + first <- seam_client(list(), cr, fc) + call <- chat_call_join(first, "!room:ex") + second <- seam_client(list(wrap_sync(to_device = list( + key_event("@bob:ex:LAPTOP", peer_key, 2L)))), cr, fc) + chat_poll(second) + expect_identical(length(fc$log$handle), 1L) + expect_identical(chat_call_updates(call)$keys[[1L]]$identity, "@bob:ex:LAPTOP") + # And a third client sees it as already joined. + expect_error(chat_call_join(seam_client(list(), cr, fc), "!room:ex"), + "already in the call") + chat_call_leave(seam_client(list(), cr, fc), call) + expect_true(chat_call_updates(call)$ended) +}) + # ---- leaving ---- local({ cr <- fake_crypto() From ebd3a75c99615ec1ca35ae07b6b8df65818d652b Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 10:01:51 -0500 Subject: [PATCH 04/11] CI: take mx.api and mx.client from the drat until CRAN carries 0.2.1.1 The call surface's mx.client floor is 0.2.1.1, released on the drat and not yet on CRAN (0.2.1); mx.client 0.2.1.1 in turn imports mx.api 0.3.1.1, also drat-only for now. With the drat listed first, available.packages() takes its builds for both. The drat entry can go once CRAN carries these versions. --- .github/workflows/ci.yaml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0db52f4..d36f9cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,14 +29,20 @@ jobs: 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. + # Matrix from source so binary rebuilds cannot select older + # versions. The drat comes first: the call surface needs mx.client + # 0.2.1.1 (and its mx.api 0.3.1.1), which are released there and + # not yet on CRAN. available.packages() keeps the first repository + # that lists a package, so the drat's build wins while it is newer; + # once CRAN carries these versions the drat entry can go. 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", + repos = c("https://cornball-ai.github.io/drat", + "https://cran.r-project.org"), type = "source", upgrade = "never") remotes::install_deps(".", dependencies = TRUE, repos = "https://cran.r-project.org", From f54f54fdf45e31d42e61a2457b7a3308586eb774 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 13:52:26 -0500 Subject: [PATCH 05/11] Report the rooms where a call membership changed from the poll chat_poll() on an adapter with the calls capability returns `calls`: one notice per channel where someone other than the client announced or withdrew a call membership in this poll, with the identities now in the call (mx.client's reading of the events) and those who left. A bot that joins calls on demand acts on these; the call button in a Matrix client posts a membership event and nothing else, so this is the only signal a bot gets that a call started. --- NAMESPACE | 1 + R/contract.R | 18 ++++++++++- R/matrix-call.R | 39 +++++++++++++++++++++++ R/matrix.R | 1 + inst/tinytest/test_matrix_call.R | 54 ++++++++++++++++++++++++++++++++ man/chat_poll.Rd | 9 +++++- 6 files changed, 120 insertions(+), 2 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 8d05009..7a1dc61 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -143,6 +143,7 @@ S3method(chat_whoami,chat_telegram) S3method(chat_whoami,default) S3method(print,chat_attachment) S3method(print,chat_call) +S3method(print,chat_call_notice) S3method(print,chat_config) S3method(print,chat_identity) S3method(print,chat_invite) diff --git a/R/contract.R b/R/contract.R index dc4d62c..1708dd1 100644 --- a/R/contract.R +++ b/R/contract.R @@ -15,7 +15,14 @@ #' default. #' @param ... Adapter-specific options. #' @return A list with \code{messages} (list of \code{chat_message}) and -#' \code{cursor} (opaque, for the next \code{since}). +#' \code{cursor} (opaque, for the next \code{since}). An adapter with +#' the \code{calls} capability also returns \code{calls}: one notice +#' per channel where someone other than this client announced or +#' withdrew a call membership in this poll, each +#' \code{list(channel, members, left)} with \code{members} the +#' identities now in the call and \code{left} those who withdrew. A +#' consumer that joins calls on demand acts on these (see +#' \code{\link{chat_call_join}}). #' @examples #' cl <- chat_loopback() #' chat_send(cl, "general", "hello") @@ -1292,3 +1299,12 @@ print.chat_call <- function(x, ...) { if (isTRUE(x$changes$ended)) " (left)" else "", "\n", sep = "") invisible(x) } + +#' @export +print.chat_call_notice <- function(x, ...) { + cat(" ", x$channel, ": in ", + if (length(x$members)) paste(x$members, collapse = ", ") else "nobody", + if (length(x$left)) paste0("; left ", paste(x$left, collapse = ", ")), + "\n", sep = "") + invisible(x) +} diff --git a/R/matrix-call.R b/R/matrix-call.R index c185310..5ce0eb3 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -116,6 +116,45 @@ chat_call_leave.chat_matrix <- function(client, call, ...) { invisible(call) } +# The call-membership events this sync carries, as notices: for each +# room where someone other than this client announced or withdrew a +# call membership, who is in (by mx.client's reading of the events: +# non-empty content, a LiveKit focus, not expired) and who left (an +# empty membership). A notice says the room's call changed; the room's +# full membership is the call's own business once joined +# (chat_call_updates()). Nothing without mx.client's call API. +matrix_call_notices <- function(client, sync) { + if (!matrix_calls_available(client$call_override)) { + return(list()) + } + self <- client$env$mx$user_id + out <- list() + for (room_id in names(sync$rooms$join)) { + room <- sync$rooms$join[[room_id]] + evs <- c(room$state$events %||% list(), room$timeline$events %||% list()) + evs <- Filter(function(ev) { + identical(ev$type, .MATRIX_CALL_MEMBER) && + is.character(ev$sender) && !identical(ev$sender, self) + }, evs) + if (!length(evs)) { + next + } + members <- vapply(mx.client::mx_call_members(evs), function(m) m$identity, "") + left <- unique(unlist(lapply(evs, function(ev) { + if (!length(ev$content)) ev$sender + }))) + out[[length(out) + 1L]] <- structure( + list(channel = room_id, members = unique(members), + left = as.character(left %||% character())), + class = "chat_call_notice") + } + out +} + +# The membership state event's type (MSC3401; the per-device form both +# Element Call and FluffyChat write in 2026). +.MATRIX_CALL_MEMBER <- "org.matrix.msc3401.call.member" + # Advance every live call with this poll's sync. Runs after the sync # is decrypted and consumed; a failure here is reported, not thrown, # since throwing would lose the poll's messages for a sync the cursor diff --git a/R/matrix.R b/R/matrix.R index bf4efe4..5243fe8 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -670,6 +670,7 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { first_run = isTRUE(res$first_run), reactions = matrix_reactions(client, res$sync, event_pos), invites = matrix_invites(client, res$sync), + calls = matrix_call_notices(client, res$sync), raw = res$sync) } diff --git a/inst/tinytest/test_matrix_call.R b/inst/tinytest/test_matrix_call.R index 90b738c..445bb8d 100644 --- a/inst/tinytest/test_matrix_call.R +++ b/inst/tinytest/test_matrix_call.R @@ -248,6 +248,60 @@ local({ expect_identical(length(fc$log$handle), 1L) }) +# ---- a poll reports the rooms where someone's call membership changed ---- +local({ + cr <- fake_crypto() + fc <- fake_calls() + member <- function(sender, device, content = NULL) { + list(type = "org.matrix.msc3401.call.member", sender = sender, + state_key = sprintf("_%s_%s_m.call", sender, device), + origin_server_ts = as.numeric(Sys.time()) * 1000, + content = content %||% list(application = "m.call", call_id = "", + device_id = device, + focus_active = list(type = "livekit"), + foci_preferred = list(list( + type = "livekit", + livekit_service_url = "https://jwt.ex")), + expires = 14400000)) + } + syncs <- list( + # Ann joins in one room; our own membership in another is not news. + { + s <- wrap_sync(events = list(member("@ann:ex", "PHONE"))) + s$rooms$join[["!other:ex"]] <- list(timeline = list(events = list( + member("@bot:ex", "DEV1")))) + s + }, + # Ann leaves (empty content), Bob joins in room state. + { + s <- wrap_sync(events = list(member("@ann:ex", "PHONE", content = list()))) + s$rooms$join[["!room:ex"]]$state <- list(events = list(member("@bob:ex", "LAPTOP"))) + s + }, + # Nothing about calls. + wrap_sync(events = list(list(type = "m.room.message", sender = "@ann:ex", + content = list(body = "hi", msgtype = "m.text"))))) + cl <- seam_client(syncs, cr, fc) + if (requireNamespace("mx.client", quietly = TRUE) && + utils::packageVersion("mx.client") >= "0.2.1.1") { + res <- chat_poll(cl) + expect_identical(length(res$calls), 1L) + expect_identical(res$calls[[1L]]$channel, "!room:ex") + expect_identical(res$calls[[1L]]$members, "@ann:ex:PHONE") + expect_identical(res$calls[[1L]]$left, character()) + expect_stdout(print(res$calls[[1L]]), " !room:ex: in @ann:ex:PHONE") + res <- chat_poll(cl) + expect_identical(length(res$calls), 1L) + expect_identical(res$calls[[1L]]$members, "@bob:ex:LAPTOP") + expect_identical(res$calls[[1L]]$left, "@ann:ex") + res <- chat_poll(cl) + expect_identical(res$calls, list()) + } else { + # Without mx.client's call API there are no notices, and no error. + expect_identical(chat_poll(cl)$calls, list()) + } +}) + # ---- the call outlives the client object ---- # corteza builds a chat client per poll. The calls live on the identity's # interned crypto context, so a new client for the same identity polls diff --git a/man/chat_poll.Rd b/man/chat_poll.Rd index f7029a2..1d828c6 100644 --- a/man/chat_poll.Rd +++ b/man/chat_poll.Rd @@ -17,7 +17,14 @@ default.} } \value{ A list with \code{messages} (list of \code{chat_message}) and - \code{cursor} (opaque, for the next \code{since}). + \code{cursor} (opaque, for the next \code{since}). An adapter with + the \code{calls} capability also returns \code{calls}: one notice + per channel where someone other than this client announced or + withdrew a call membership in this poll, each + \code{list(channel, members, left)} with \code{members} the + identities now in the call and \code{left} those who withdrew. A + consumer that joins calls on demand acts on these (see + \code{\link{chat_call_join}}). } \description{ Poll-shaped everywhere: long-poll transports (Matrix /sync, Telegram From c6f3f6ba7018b8a4bb047f0ddcff6a02141e4930 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 15:45:41 -0500 Subject: [PATCH 06/11] Hand the call the decrypted room events, and log the call traffic FluffyChat sends its call key as a room event rather than to-device, so the call needs the poll's decrypted room events beside its to-device events; they go to mx_call_handle() as processed$events. While a call is on, each poll logs what arrived by type (raw and decrypted to-device events, call keys in a room timeline), which is what told the first live call's missing key apart from a parse failure. --- R/matrix-call.R | 16 +++++++++++++++- R/matrix-crypto.R | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/R/matrix-call.R b/R/matrix-call.R index 5ce0eb3..a7c4708 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -169,8 +169,22 @@ matrix_calls_sync <- function(client, sync, crypto) { crypto$to_device <- NULL return(invisible(NULL)) } - processed <- list(to_device = crypto$to_device %||% list()) + processed <- list(to_device = crypto$to_device %||% list(), + events = crypto$decrypted %||% list()) crypto$to_device <- NULL + crypto$decrypted <- NULL + traffic <- crypto$call_traffic + crypto$call_traffic <- NULL + if (!is.null(traffic) && + (length(traffic$raw) || length(traffic$decrypted) || traffic$room_keys > 0L)) { + message("chat.api call: to-device raw [", + paste(traffic$raw, collapse = ", "), "] decrypted [", + paste(traffic$decrypted, collapse = ", "), "]", + if (traffic$room_keys > 0L) { + paste0("; ", traffic$room_keys, " call key event(s) in a room ", + "timeline, which the call does not read") + }) + } for (channel in names(calls)) { call <- calls[[channel]] if (isTRUE(call$changes$ended)) { diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R index ddd33de..4915bb9 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -563,6 +563,21 @@ matrix_crypto_decrypt <- function(crypto, sync, mx) { # on the context for this poll only; decrypting the sync a second # time for them would advance the Olm ratchets twice. crypto$to_device <- res$to_device + # The decrypted room events too: FluffyChat sends its call key as a + # room event rather than to-device, and the call reads those from + # here once mx.client keeps a decrypted event's type and content. + crypto$decrypted <- res$events + # What arrived, by type, for the call diagnostics (matrix_calls_sync + # logs it while a call is on): the raw to-device events, the + # decrypted ones, and any call key sent as a room event instead. + crypto$call_traffic <- list( + raw = vapply(sync$to_device$events %||% list(), + function(ev) as.character(ev$type %||% "?"), ""), + decrypted = vapply(res$to_device %||% list(), + function(ev) as.character(ev$type %||% "?"), ""), + room_keys = sum(vapply(res$events %||% list(), function(ev) { + identical(ev$type, "io.element.call.encryption_keys") + }, logical(1)))) res$events } From 7fec5709fde848a7f5f142b706a12369497adb32 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 16:03:05 -0500 Subject: [PATCH 07/11] Name the decrypted non-message room events in the call traffic line --- R/matrix-call.R | 9 +++------ R/matrix-crypto.R | 9 ++++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/R/matrix-call.R b/R/matrix-call.R index a7c4708..530f972 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -176,14 +176,11 @@ matrix_calls_sync <- function(client, sync, crypto) { traffic <- crypto$call_traffic crypto$call_traffic <- NULL if (!is.null(traffic) && - (length(traffic$raw) || length(traffic$decrypted) || traffic$room_keys > 0L)) { + (length(traffic$raw) || length(traffic$decrypted) || length(traffic$room))) { message("chat.api call: to-device raw [", paste(traffic$raw, collapse = ", "), "] decrypted [", - paste(traffic$decrypted, collapse = ", "), "]", - if (traffic$room_keys > 0L) { - paste0("; ", traffic$room_keys, " call key event(s) in a room ", - "timeline, which the call does not read") - }) + paste(traffic$decrypted, collapse = ", "), "] room events [", + paste(traffic$room, collapse = ", "), "]") } for (channel in names(calls)) { call <- calls[[channel]] diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R index 4915bb9..84006a6 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -575,9 +575,12 @@ matrix_crypto_decrypt <- function(crypto, sync, mx) { function(ev) as.character(ev$type %||% "?"), ""), decrypted = vapply(res$to_device %||% list(), function(ev) as.character(ev$type %||% "?"), ""), - room_keys = sum(vapply(res$events %||% list(), function(ev) { - identical(ev$type, "io.element.call.encryption_keys") - }, logical(1)))) + # Decrypted room events that are not messages, as sender:type. + room = unlist(lapply(res$events %||% list(), function(ev) { + if (is.null(ev$msgtype) && is.character(ev$type)) { + paste0(ev$sender %||% "?", ":", ev$type) + } + }))) res$events } From f0434506ad30ebe3642b5a280240291f8e504bee Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 16:16:13 -0500 Subject: [PATCH 08/11] Report the identities this client's call key has reached --- R/contract.R | 6 ++++-- R/matrix-call.R | 6 ++++++ man/chat_call_updates.Rd | 6 ++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/R/contract.R b/R/contract.R index 1708dd1..7a2158a 100644 --- a/R/contract.R +++ b/R/contract.R @@ -1282,8 +1282,10 @@ chat_call_media <- function(call) { #' (each \code{identity}, \code{key}, \code{index}); \code{own}, this #' client's key when it changed (\code{key}, \code{index}), else #' \code{NULL}; \code{members}, the members' identities when the -#' membership changed, else \code{NULL}; \code{ended}, \code{TRUE} -#' once the call has been left. +#' membership changed, else \code{NULL}; \code{shared_with}, the +#' identities this client's key has reached, when that set changed, +#' else \code{NULL}; \code{ended}, \code{TRUE} once the call has been +#' left. #' @export chat_call_updates <- function(call) { stopifnot(inherits(call, "chat_call")) diff --git a/R/matrix-call.R b/R/matrix-call.R index 530f972..f3ac2b0 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -191,6 +191,7 @@ matrix_calls_sync <- function(client, sync, crypto) { mx_call$client <- client$env$mx mx_call$sessions <- crypto$sessions own_before <- list(key = mx_call$keys$key, index = mx_call$keys$index) + shared_before <- mx_call$keys$shared_with res <- tryCatch(client$call_ops$handle(mx_call, sync, processed = processed), error = function(e) { @@ -209,6 +210,11 @@ matrix_calls_sync <- function(client, sync, crypto) { list(identity = id, key = p$key, index = p$index) } } + # Who our key has reached (the to-device sends that succeeded), + # whenever that set changes: the other half of "can they hear us". + if (!identical(shared_before, mx_call$keys$shared_with)) { + call$changes$shared_with <- as.character(mx_call$keys$shared_with) + } if (!identical(own_before$key, mx_call$keys$key) || !identical(own_before$index, mx_call$keys$index)) { call$changes$own <- list(key = mx_call$keys$key, diff --git a/man/chat_call_updates.Rd b/man/chat_call_updates.Rd index 9cc3f83..e7561ee 100644 --- a/man/chat_call_updates.Rd +++ b/man/chat_call_updates.Rd @@ -13,8 +13,10 @@ A list: \code{keys}, a list of the peers' keys received (each \code{identity}, \code{key}, \code{index}); \code{own}, this client's key when it changed (\code{key}, \code{index}), else \code{NULL}; \code{members}, the members' identities when the - membership changed, else \code{NULL}; \code{ended}, \code{TRUE} - once the call has been left. + membership changed, else \code{NULL}; \code{shared_with}, the + identities this client's key has reached, when that set changed, + else \code{NULL}; \code{ended}, \code{TRUE} once the call has been + left. } \description{ Keys and membership that arrived through the polls since this was From b541485e2e57f23b9924300190b6edfb51f7a406 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 16:52:56 -0500 Subject: [PATCH 09/11] Only messages become messages; the key's recipients at join mx.client now hands back every decrypted room event, and a call's key or a ring notification has no body; one of those became a chat_message with an empty body, which a consumer's message loop tripped over ("argument is of length zero" in corteza's bot, once per call). Also chat_call_media() reports the identities the client's key reached at join, since the first send happens inside the join, before any poll. --- R/matrix-call.R | 3 ++- R/matrix.R | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/R/matrix-call.R b/R/matrix-call.R index f3ac2b0..e627a9c 100644 --- a/R/matrix-call.R +++ b/R/matrix-call.R @@ -84,7 +84,8 @@ chat_call_join.chat_matrix <- function(client, channel, intent = "voice", call$media <- function() { list(url = mx_call$token$url, jwt = mx_call$token$jwt, identity = mx_call$identity, - key = list(key = mx_call$keys$key, index = mx_call$keys$index)) + key = list(key = mx_call$keys$key, index = mx_call$keys$index), + shared_with = as.character(mx_call$keys$shared_with)) } call$peers <- function() { lapply(names(mx_call$keys$peers), function(id) { diff --git a/R/matrix.R b/R/matrix.R index 5243fe8..a1c028d 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -577,6 +577,13 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { # owning crypto here: corteza used to run its own decrypt off $raw and # concatenate the results itself. for (d in dec) { + # Only messages. mx.client hands back every decrypted room event + # (a call's key, a ring notification, a reaction), and those have + # no body; a chat_message with an empty body is what a consumer + # trips over. + if (!is.character(d$body) || length(d$body) != 1L) { + next + } ms <- d$ts %||% event_ts[[as.character(d$event_id)]] messages[[length(messages) + 1L]] <- chat_message( id = as.character(d$event_id), From 4865109e0af900f9863b5344349a01861392bafa Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 20:03:10 -0500 Subject: [PATCH 10/11] Report mx.client's dropped to-device events while in a call A bot's top level never returns, so a deferred warning about an Olm message that could not be decrypted is never printed; a peer's media key dying that way left no trace. During a call those warnings are messages. --- R/matrix-crypto.R | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R index 84006a6..6640a53 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -553,10 +553,21 @@ matrix_crypto_decrypt <- function(crypto, sync, mx) { NULL } - res <- mx.client::mx_crypto_process_sync( - crypto$account, crypto$sessions, sync, crypto$self_curve, - self_id = mx$user_id, devices = devices, - self_device_id = mx$device_id) + # While in a call, mx.client's warnings about dropped to-device + # events are reported as messages: a peer's media key that fails to + # decrypt is otherwise silent in a long-running bot, whose top level + # never returns to print deferred warnings. + res <- withCallingHandlers( + mx.client::mx_crypto_process_sync( + crypto$account, crypto$sessions, sync, crypto$self_curve, + self_id = mx$user_id, devices = devices, + self_device_id = mx$device_id), + warning = function(w) { + if (length(crypto$calls)) { + message("chat.api call: ", conditionMessage(w)) + invokeRestart("muffleWarning") + } + }) matrix_crypto_commit_key_requests(crypto, res, mx) # The decrypted to-device events, for the calls this client is in # (matrix_calls_sync): their peers' media keys arrive this way. Held From b54d5016701a7e10c6a05562453033a91bbf5687 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 7 Oct 2026 22:14:37 -0500 Subject: [PATCH 11/11] Let chat_edit work in an encrypted room A call trims an interrupted reply by editing the message it posted to the room, and calls run in encrypted rooms, where chat_edit refused: an edit carries its replacement text in m.new_content, and the plain path would put that on the homeserver in the clear. But mx_send_encrypted takes the whole event content, so the relation and m.new_content can ride inside the Megolm ciphertext like any other field. The crypto send path gains an extra-content argument, chat_edit routes through it when the room is encrypted, and chat_capabilities()$edits is now TRUE on an e2ee client. --- NEWS.md | 6 +++++ R/matrix-crypto.R | 9 ++++++- R/matrix.R | 49 +++++++++++++++++-------------------- inst/tinytest/test_matrix.R | 49 ++++++++++++++++++++++++++++++------- 4 files changed, 77 insertions(+), 36 deletions(-) diff --git a/NEWS.md b/NEWS.md index b3407f8..ff38ed3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,11 @@ # chat.api 0.1.0.1 +* Matrix `chat_edit()` works in an encrypted room. The edit's "* " + fallback body, its `m.new_content` and the `m.replace` relation now + ride inside the Megolm ciphertext, so the replacement text stays off + the homeserver; `chat_capabilities()$edits` is TRUE on an e2ee client. + (A call trims an interrupted reply posted to the room by editing it, + and calls run in encrypted rooms.) * Matrix clients can take part in a room's call: `chat_call_join()` announces the client as a MatrixRTC member, obtains the media server's address and token, and exchanges per-member media keys over Olm for as diff --git a/R/matrix-crypto.R b/R/matrix-crypto.R index 6640a53..b674d2f 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -687,7 +687,8 @@ matrix_crypto_content <- function(text, msgtype = "m.text", markdown = FALSE, # individual devices that cannot be verified is mx.client's decision to # make, and it is a different one. matrix_crypto_send <- function(crypto, mx, room_id, text, msgtype = "m.text", - markdown = FALSE, mentions = NULL) { + markdown = FALSE, mentions = NULL, + extra = NULL) { members <- mx.api::mx_room_members(mx.client::mx_client_session(mx), room_id) if (!length(members)) { @@ -697,6 +698,12 @@ matrix_crypto_send <- function(crypto, mx, room_id, text, msgtype = "m.text", } content <- matrix_crypto_content(text, msgtype = msgtype, markdown = markdown, mentions = mentions) + # Extra content fields (an edit's m.new_content and m.relates_to) ride + # inside the ciphertext like any other, so an edit in an encrypted + # room keeps its replacement text off the homeserver. + for (nm in names(extra)) { + content[[nm]] <- extra[[nm]] + } res <- mx.client::mx_send_encrypted(mx, crypto$account, crypto$sessions, room_id, content, crypto$store, member_ids = members) diff --git a/R/matrix.R b/R/matrix.R index a1c028d..acffe12 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -972,10 +972,10 @@ chat_capabilities.chat_matrix <- function(client, ...) { # that fails in exactly the rooms such a client exists for. list(threads = matrix_threads_available() && !isTRUE(client$e2ee), thread_replies = TRUE, - # Refused in encrypted rooms: an edit carries its replacement - # text in an ordinary event, and there is no Megolm path that - # can carry a relation. Same bargain as files. - edits = !isTRUE(client$e2ee), + # An edit works encrypted too: the Megolm path carries the + # relation and m.new_content inside the ciphertext, so the + # replacement text stays off the homeserver. + edits = TRUE, reactions = TRUE, reaction_events = matrix_reactions_available(), channel_info = TRUE, members = TRUE, invites = matrix_invites_available(), join = TRUE, whoami = TRUE, @@ -1192,39 +1192,19 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, markup = c("plain", "markdown"), rich = NULL, kind = "message", ...) { markup <- match.arg(markup) - # Refused in encrypted rooms, and chat_capabilities() reports - # edits = FALSE on an e2ee client to match -- the same bargain - # attachments get. - # - # An edit is an ordinary m.room.message carrying m.new_content, so - # sending one the plain way puts the replacement text on the - # homeserver in the clear, in a room whose whole point is that it is - # not. The Megolm path cannot carry it either: crypto_ops$send() - # takes text, msgtype, markdown and mentions, and there is nowhere in - # that shape to put a relation. - crypto <- matrix_crypto_require(client) - if (!is.null(crypto) && - client$crypto_ops$encrypted(crypto, client$env$mx, channel)) { - stop("chat.api: cannot edit a message in the encrypted room ", - channel, ". An edit carries the replacement text in an ", - "ordinary event, and posting one would put it on the ", - "homeserver in the clear.", call. = FALSE) - } - sess <- mx.client::mx_client_session(client$env$mx) - fn <- client$edit_fn %||% mx.api::mx_send + msgtype <- matrix_msgtype(kind) html <- if (identical(markup, "markdown")) { mx.client::mx_markdown_to_html(text) } else { NULL } - msgtype <- matrix_msgtype(kind) - new_content <- list(msgtype = msgtype, body = text) # A supplied fragment wins over one rendered from markdown: the # caller has markup the renderer cannot express, which is the only # reason to pass one. if (!is.null(rich)) { html <- rich } + new_content <- list(msgtype = msgtype, body = text) if (!is.null(html)) { new_content$format <- "org.matrix.custom.html" new_content$formatted_body <- html @@ -1239,6 +1219,23 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, # it is a correction rather than the bot repeating itself. extra$formatted_body <- paste0("* ", html) } + # In an encrypted room the whole event -- the "* " fallback body, the + # m.new_content and the relation -- is encrypted with Megolm, so the + # replacement text never reaches the homeserver in the clear. The + # crypto send path carries the relation as extra content. + crypto <- matrix_crypto_require(client) + if (!is.null(crypto) && + client$crypto_ops$encrypted(crypto, client$env$mx, channel)) { + # markdown = FALSE: the fallback's formatted_body is already in + # `extra` ("* " + html), so letting the content builder render + # "* text" again would turn the asterisk into a bullet list. + event <- client$crypto_ops$send(crypto, client$env$mx, channel, + paste0("* ", text), msgtype = msgtype, markdown = FALSE, + extra = extra) + return(invisible(as.character(event))) + } + sess <- mx.client::mx_client_session(client$env$mx) + fn <- client$edit_fn %||% mx.api::mx_send invisible(as.character(fn(sess, channel, paste0("* ", text), msgtype = msgtype, extra = extra))) } diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index b7c3cf2..934bd06 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -2164,23 +2164,54 @@ local({ expect_null(seen$`m.new_content`$format) }) -# An encrypted room refuses. The edit carries its replacement text in an -# ordinary event, so sending one would put it on the homeserver in the -# clear -- in the room whose whole point is that it is not. +# An encrypted room edits through the Megolm path: the "* " fallback +# body, the m.new_content and the relation all ride inside the +# ciphertext, so the replacement text never reaches the homeserver in +# the clear, and the plaintext edit transport is never touched. local({ ctx <- new.env(parent = emptyenv()) + seen <- NULL ops <- list(init = function(...) ctx, encrypted = function(crypto, mx, room_id) TRUE, - send = function(...) "$enc", + send = function(crypto, mx, room_id, text, ...) { + seen <<- c(list(room_id = room_id, text = text), list(...)) + "$enc" + }, decrypt = function(...) list()) cl <- seam_client(mx = fake_mx(user_id = "@e2ee-edit:ex"), .crypto = ops, e2ee = TRUE, .edit = function(...) stop("must not be reached")) - expect_error(chat_edit(cl, "!secret:ex", "$orig", "shh"), - "in the clear") - # And the capability says so up front, rather than letting a - # consumer find out by trying. - expect_false(chat_capabilities(cl)$edits) + expect_identical(chat_edit(cl, "!secret:ex", "$orig", "shh"), "$enc") + expect_identical(seen$room_id, "!secret:ex") + expect_identical(seen$text, "* shh") # the fallback body + expect_identical(seen$extra[["m.new_content"]]$body, "shh") + expect_identical(seen$extra[["m.relates_to"]], + list(rel_type = "m.replace", event_id = "$orig")) + # And the capability says so, so a consumer need not find out by trying. + expect_true(chat_capabilities(cl)$edits) +}) +# A markdown edit in an encrypted room renders the fallback HTML once, +# into extra, and sends the crypto body plain -- so the content builder +# never re-renders "* text" and turns the asterisk into a bullet list. +local({ + ctx <- new.env(parent = emptyenv()) + seen <- NULL + ops <- list(init = function(...) ctx, + encrypted = function(crypto, mx, room_id) TRUE, + send = function(crypto, mx, room_id, text, markdown = FALSE, ...) { + seen <<- c(list(text = text, markdown = markdown), list(...)) + "$enc" + }, + decrypt = function(...) list()) + cl <- seam_client(mx = fake_mx(user_id = "@e2ee-md:ex"), + .crypto = ops, e2ee = TRUE) + chat_edit(cl, "!secret:ex", "$orig", "**done**", markup = "markdown") + expect_false(seen$markdown) # not re-rendered + expect_identical(seen$text, "* **done**") + expect_true(grepl("done", seen$extra$formatted_body)) + expect_true(startsWith(seen$extra$formatted_body, "* ")) + expect_true(grepl("done", + seen$extra[["m.new_content"]]$formatted_body)) }) expect_true(chat_capabilities(seam_client())$edits)