Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
145 changes: 143 additions & 2 deletions R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("<chat_call> ", x$channel, " as ", x$identity,
if (isTRUE(x$changes$ended)) " (left)" else "", "\n", sep = "")
invisible(x)
}

#' @export
print.chat_call_notice <- function(x, ...) {
cat("<chat_call_notice> ", 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)
}
2 changes: 1 addition & 1 deletion R/irc.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
Expand Down
Loading
Loading