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", diff --git a/DESCRIPTION b/DESCRIPTION index 4eda3f6..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", @@ -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..7a1dc61 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,8 @@ 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_call_notice) S3method(print,chat_config) S3method(print,chat_identity) S3method(print,chat_invite) diff --git a/NEWS.md b/NEWS.md index ca957de..ff38ed3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,22 @@ +# 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 + 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..7a2158a 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") @@ -141,7 +148,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 +1177,136 @@ 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{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")) + 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) +} + +#' @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/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..e627a9c --- /dev/null +++ b/R/matrix-call.R @@ -0,0 +1,237 @@ +# 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) + } + # 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) + } + 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), + shared_with = as.character(mx_call$keys$shared_with)) + } + 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(crypto$calls)) { + crypto$calls <- list() + } + crypto$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 + crypto <- matrix_crypto_require(client) + if (!is.null(crypto)) { + crypto$calls[[call$channel]] <- NULL + } + 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 +# 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) { + 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(), + 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) || length(traffic$room))) { + message("chat.api call: to-device raw [", + paste(traffic$raw, collapse = ", "), "] decrypted [", + paste(traffic$decrypted, collapse = ", "), "] room events [", + paste(traffic$room, collapse = ", "), "]") + } + 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) + shared_before <- mx_call$keys$shared_with + 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) + } + } + # 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, + 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..b674d2f 100644 --- a/R/matrix-crypto.R +++ b/R/matrix-crypto.R @@ -553,11 +553,45 @@ 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 + # 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 %||% "?"), ""), + # 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 } @@ -653,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)) { @@ -663,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 7cd1d7c..acffe12 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) @@ -566,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), @@ -659,6 +677,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) } @@ -953,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, @@ -979,6 +998,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. @@ -1168,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 @@ -1215,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/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.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) diff --git a/inst/tinytest/test_matrix_call.R b/inst/tinytest/test_matrix_call.R new file mode 100644 index 0000000..445bb8d --- /dev/null +++ b/inst/tinytest/test_matrix_call.R @@ -0,0 +1,346 @@ +# 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) +}) + +# ---- 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 +# 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() + 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..e7561ee --- /dev/null +++ b/man/chat_call_updates.Rd @@ -0,0 +1,27 @@ +% 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{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 +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}. 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