From c83a8c9ad39276d4dcbe798063d8d871d005ee04 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:25:39 -0500 Subject: [PATCH 1/5] Carry Matrix threads in both directions Inbound: chat_poll and chat_history map an event's m.relates_to onto chat_message$thread when its rel_type is m.thread. Only that rel_type counts -- a rich reply carries an m.in_reply_to with no rel_type, and reading that as a thread would report every quoted reply as one, which chat_message has no reply_to slot to undo. A threaded event carries an m.in_reply_to too, as its fallback, so the test is rel_type and not the relation's presence. Outbound: chat_send passes thread through to mx_send_text, and the rich path builds the same relation itself since it bypasses that function. The argument is named only when there is a thread, so an mx.client predating it keeps working for ordinary replies. thread_replies is now TRUE. threads is TRUE when the installed mx.client can attach a relation (0.2.0.5) and the client is not e2ee: the encrypted send path has nowhere to put an m.relates_to, so a threaded send there is refused rather than posted to the room's main timeline -- files' bargain, not rich's, because a reply that leaves its thread is misrouted rather than merely undecorated. --- R/matrix.R | 113 ++++++++++++++++++++++----- inst/tinytest/test_matrix.R | 111 +++++++++++++++++++++++--- inst/tinytest/test_matrix_mxclient.R | 45 ++++++++++- 3 files changed, 237 insertions(+), 32 deletions(-) diff --git a/R/matrix.R b/R/matrix.R index b1d357f..ff55a50 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -300,6 +300,16 @@ matrix_reactions_available <- function() { "mx_extract_reactions" %in% getNamespaceExports("mx.client") } +# Whether the installed mx.client can send into a thread. Probed rather +# than assumed: mx_send_text() grew `thread` in 0.2.0.5, and against an +# older one passing it is an unused-argument error on an ordinary +# reply. Reporting the capability off what is installed lets a consumer +# gate instead of discovering it mid-conversation. +matrix_threads_available <- function() { + requireNamespace("mx.client", quietly = TRUE) && + "thread" %in% names(formals(mx.client::mx_send_text)) +} + # The sync's pending invites, as contract records. # # mx_extract_invite_records() carries the inviter, which is the whole of @@ -348,6 +358,29 @@ matrix_kind <- function(msgtype) { "message") } +# The thread root an event belongs to, from its m.relates_to, or NULL. +# +# Only rel_type "m.thread" counts. A rich reply carries an +# m.in_reply_to with no rel_type, and reading that as a thread would +# report every quoted reply in a room as one -- chat_message has no +# reply_to slot to tell them apart afterwards, so the distinction has +# to be made here. An edit (m.replace) is not a thread either. +# +# A threaded message also carries an m.in_reply_to inside the same +# block as its reply fallback, which is why this reads rel_type and +# not the presence of a relation. +matrix_thread_root <- function(relates_to) { + if (!is.list(relates_to) || + !identical(relates_to$rel_type %||% "", "m.thread")) { + return(NULL) + } + root <- relates_to$event_id + if (is.null(root) || !length(root) || !nzchar(as.character(root)[[1L]])) { + return(NULL) + } + as.character(root)[[1L]] +} + # The same mapping, but NA rather than "message" for a msgtype the # contract has no word for. chat_poll() can afford the lenient version: # its extractor has already filtered to the msgtypes it was asked for. @@ -456,6 +489,7 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { channel = as.character(r$room_id), sender = as.character(r$sender), body = as.character(r$body), ts = ts, + thread = matrix_thread_root(r$relates_to), markup = "plain", kind = matrix_kind(r$msgtype), self = isTRUE(r$is_self), mentions = unlist(r$mentions, use.names = FALSE), @@ -475,6 +509,7 @@ chat_poll.chat_matrix <- function(client, since = NULL, timeout = NULL, ...) { body = as.character(d$body), ts = if (is.null(ms)) as.POSIXct(NA) else as.POSIXct(ms / 1000, origin = "1970-01-01"), + thread = matrix_thread_root(d$relates_to), markup = "plain", kind = matrix_kind(d$msgtype), self = isTRUE(d$is_self), mentions = unlist(d$mentions, use.names = FALSE), @@ -539,6 +574,20 @@ chat_send.chat_matrix <- function(client, channel, text, crypto <- matrix_crypto_require(client) encrypted <- !is.null(crypto) && client$crypto_ops$encrypted(crypto, client$env$mx, channel) + if (encrypted && !is.null(thread)) { + # Refused rather than dropped, which is the bargain files gets + # and rich does not. crypto_ops$send() has nowhere to put a + # relation, so the message would arrive -- in the room's main + # timeline instead of the thread it answers. That is a routing + # failure, not lost decoration: the reply lands where nobody is + # looking, and a consumer keying state off the thread never sees + # it again. chat_capabilities()$threads is FALSE on an e2ee + # client so a caller can know beforehand. + stop("chat.api: cannot send into a thread in the encrypted room ", + channel, ". The encrypted send path carries no m.relates_to, ", + "so the reply would leave the thread. Check ", + "chat_capabilities()$threads.", call. = FALSE) + } if (encrypted && !is.null(files)) { # mx_send_media() posts an ordinary cleartext m.file event: the # upload is not encrypted, and neither is the URL. There is no @@ -588,12 +637,20 @@ chat_send.chat_matrix <- function(client, channel, text, # A different function, because mx_send_text() renders its # own HTML from markdown and has no argument for a supplied # one. mx_send() takes the content wholesale. - event <- matrix_send_rich(client, channel, text, rich, msgtype) + event <- matrix_send_rich(client, channel, text, rich, msgtype, + thread = thread) return(invisible(c(media_ids, as.character(event)))) } - event <- client$send_fn(client$env$mx, text, room = channel, - msgtype = msgtype, - markdown = identical(markup, "markdown"), ...) + # thread is passed only when there is one. An mx.client older + # than 0.2.0.5 has no such argument, and naming it unconditionally + # would turn every ordinary reply into an unused-argument error + # on installs that never send into a thread at all. + args <- list(client$env$mx, text, room = channel, msgtype = msgtype, + markdown = identical(markup, "markdown"), ...) + if (!is.null(thread)) { + args$thread <- thread + } + event <- do.call(client$send_fn, args) return(invisible(c(media_ids, as.character(event)))) } invisible(media_ids) @@ -709,17 +766,21 @@ chat_resolve.chat_matrix <- function(client, name, ...) { #' @export chat_capabilities.chat_matrix <- function(client, ...) { - # thread_replies is FALSE because chat_poll cannot populate - # chat_message$thread. Its only event source is - # mx.client::mx_extract_text_events(), which returns room_id, - # event_id, sender, is_self, body, msgtype, and mentions -- it drops - # content$m.relates_to, so the relation never reaches this adapter. - # Re-walking the raw sync behind the extractor would duplicate its - # msgtype filtering and quietly break for anyone supplying .extract. - # Mapping m.in_reply_to into $thread would be worse: chat_message - # has no reply_to slot, so plain rich replies would report as - # threads. Flip this to TRUE when mx.client surfaces relations and - # chat_poll maps a real m.thread rel_type. + # thread_replies is TRUE: mx_extract_text_events() carries the + # event's m.relates_to, and chat_poll maps a real m.thread rel_type + # onto chat_message$thread. Only that rel_type counts -- a rich + # reply's m.in_reply_to is deliberately not read as a thread, + # because chat_message has no reply_to slot to tell the two apart + # downstream. Unconditional, because reading a relation needs + # nothing of the installed mx.client but the field, absent on none + # this package supports. + # + # threads (the send side) is conditional twice over. It needs an + # mx.client that can attach the relation, which is 0.2.0.5 and + # later; and it is FALSE on an e2ee client, where the encrypted + # send path has nowhere to put an m.relates_to and chat_send() + # refuses a threaded send rather than posting it to the room's + # main timeline. # # Every flag here answers "can this adapter do it", not "can Matrix # do it". Matrix has reactions and E2EE; this adapter reaches @@ -748,7 +809,8 @@ chat_capabilities.chat_matrix <- function(client, ...) { # adapter has no encrypted-attachment path, so chat_send() refuses # attachments to an encrypted room. TRUE would advertise something # that fails in exactly the rooms such a client exists for. - list(threads = FALSE, thread_replies = FALSE, + 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. @@ -909,6 +971,7 @@ chat_history.chat_matrix <- function(client, channel, limit = 50L, body = as.character(ev$content$body %||% ""), ts = if (is.null(ms)) as.POSIXct(NA) else as.POSIXct(ms / 1000, origin = "1970-01-01"), + thread = matrix_thread_root(ev$content[["m.relates_to"]]), markup = "plain", kind = kind, self = identical(ev$sender, client$env$mx$user_id), mentions = unlist(ev$content[["m.mentions"]]$user_ids, @@ -1017,11 +1080,23 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, # text is still the body. Matrix's own model is a plain body plus an # optional formatted one, and a client that cannot render the markup -- # or a push notification, which never does -- shows the body. -matrix_send_rich <- function(client, channel, text, rich, msgtype) { +matrix_send_rich <- function(client, channel, text, rich, msgtype, + thread = NULL) { sess <- mx.client::mx_client_session(client$env$mx) fn <- client$rich_fn %||% mx.api::mx_send - fn(sess, channel, text, msgtype = msgtype, - extra = list(format = "org.matrix.custom.html", formatted_body = rich)) + extra <- list(format = "org.matrix.custom.html", formatted_body = rich) + if (!is.null(thread)) { + # Built here rather than borrowed from mx_send_text(), which + # this path exists to bypass: it renders its own HTML and has + # nowhere to put a supplied fragment. The relation is the same + # one, fallback included, so a rich threaded reply is threaded + # in exactly the clients a plain one is. + extra[["m.relates_to"]] <- list( + rel_type = "m.thread", event_id = thread, + is_falling_back = TRUE, + "m.in_reply_to" = list(event_id = thread)) + } + fn(sess, channel, text, msgtype = msgtype, extra = extra) } # The contract's kind vocabulary as a Matrix msgtype, plus a documented diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 4a44f24..f90499d 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -292,14 +292,22 @@ expect_null(mapped$messages[[2L]]$mentions) threaded_sync <- wrap_sync(list(ev("$4", body = "in a thread", content = list("m.relates_to" = list(rel_type = "m.thread", event_id = "$root"))))) -tp <- seam_client(recs = list(rec("$4", body = "in a thread")), +tp <- seam_client(recs = list(modifyList(rec("$4", body = "in a thread"), + list(relates_to = list(rel_type = "m.thread", event_id = "$root")))), sync = threaded_sync) tres <- chat_poll(tp) tmsg <- tres$messages[[1L]] expect_identical(tmsg$body, "in a thread") -expect_null(tmsg$thread) -expect_false(chat_capabilities(tp)$thread_replies) +# The relation reaches the message through the extractor record, which +# is the only event source this adapter has -- not by re-walking the +# sync behind it, which would duplicate the extractor's msgtype +# filtering and break for anyone supplying their own .extract. +expect_identical(tmsg$thread, "$root") +expect_true(chat_capabilities(tp)$thread_replies) +# The record's field is relates_to; nothing renames it to the wire's +# m.relates_to on the way through. expect_null(tmsg$raw[["m.relates_to"]]) +expect_identical(tmsg$raw$relates_to$rel_type, "m.thread") expect_identical( tres$raw$rooms$join[["!room:ex"]]$timeline$events[[1L]]$content[["m.relates_to"]]$rel_type, "m.thread") @@ -447,12 +455,14 @@ expect_identical(only, c("$media1", "$media2")) caps <- chat_capabilities(s) # Every flag answers "can this adapter do it", not "can Matrix do it". -# Matrix threads are replies, not first-class channels -expect_false(caps$threads) -# thread_replies stays FALSE while chat_poll cannot fill in $thread: -# mx_extract_text_events drops content$m.relates_to, so the relation -# never reaches the adapter. The flag and the mapping move together. -expect_false(caps$thread_replies) +# threads is the send side: it needs an mx.client that can attach an +# m.relates_to, which is 0.2.0.5 and later, so the flag tracks what is +# installed rather than asserting it. +expect_identical(caps$threads, chat.api:::matrix_threads_available()) +# thread_replies is the receive side, and needs nothing but the +# extractor field every supported mx.client carries. The flag and the +# mapping move together. +expect_true(caps$thread_replies) # reactions is TRUE and there is a verb behind it, which is the whole # rule: a flag without its implementation is what this suite exists to # catch. reaction_events tracks the installed mx.client, since an older @@ -595,6 +605,16 @@ expect_true(chat_capabilities(enc)$e2ee) # ... and files is FALSE, because mx_send_media posts a cleartext m.file # event whatever the room's encryption state says. expect_false(chat_capabilities(enc)$files) +# ... and so is threads: the encrypted send path has nowhere to put an +# m.relates_to. A threaded send is refused rather than posted to the +# room's main timeline, which is files' bargain and not rich's -- a +# reply that leaves its thread is misrouted, not merely undecorated. +expect_false(chat_capabilities(enc)$threads) +expect_error(chat_send(enc, "!enc:ex", "hi", thread = "$root"), + "leave the thread") +# Reading threads still works on an encrypted client; only sending is +# refused. +expect_true(chat_capabilities(enc)$thread_replies) # A construction that would have died now survives to the first poll, # which is where relogin gets its chance. @@ -1506,6 +1526,79 @@ local({ expect_error(chat_leave(seam_client(.leave = function(...) stop("M_UNKNOWN")), "!a:ex"), "M_UNKNOWN") +# ---- Threads ---- +# Inbound: only rel_type m.thread becomes $thread. A rich reply carries +# an m.in_reply_to and no rel_type, and reading that as a thread would +# report every quoted reply in a room as one -- chat_message has no +# reply_to slot to tell them apart afterwards. +local({ + recs <- list( + rec("$plain"), + modifyList(rec("$thr"), list(relates_to = list( + rel_type = "m.thread", event_id = "$root", + is_falling_back = TRUE, + `m.in_reply_to` = list(event_id = "$root")))), + modifyList(rec("$reply"), list(relates_to = list( + `m.in_reply_to` = list(event_id = "$orig")))), + modifyList(rec("$edit"), list(relates_to = list( + rel_type = "m.replace", event_id = "$orig")))) + msgs <- chat_poll(seam_client(recs = recs))$messages + expect_null(msgs[[1L]]$thread) + expect_identical(msgs[[2L]]$thread, "$root") + # A threaded message carries m.in_reply_to too, as its fallback, so + # this is read off rel_type rather than the relation's presence. + expect_null(msgs[[3L]]$thread) + expect_null(msgs[[4L]]$thread) +}) + +# A relation naming no event is no thread, not a thread pointing nowhere. +local({ + recs <- list(modifyList(rec("$x"), list(relates_to = list( + rel_type = "m.thread", event_id = "")))) + expect_null(chat_poll(seam_client(recs = recs))$messages[[1L]]$thread) +}) + +# Outbound: the root reaches the send seam, and only when there is one. +# Naming the argument unconditionally would break every install whose +# mx.client predates it, on ordinary replies that use no thread at all. +local({ + seen <- NULL + cl <- seam_client(send = function(mx, text, room = NULL, ...) { + seen <<- list(text = text, args = list(...)) + "$id" + }) + chat_send(cl, "!a:ex", "ordinary") + expect_false("thread" %in% names(seen$args)) + chat_send(cl, "!a:ex", "threaded", thread = "$root") + expect_identical(seen$args$thread, "$root") +}) + +# A rich threaded reply is threaded too: that path bypasses +# mx_send_text, so it builds the same relation itself. +local({ + seen <- NULL + cl <- seam_client(.rich = function(session, channel, text, ...) { + seen <<- list(...) + "$id" + }) + chat_send(cl, "!a:ex", "hi", rich = "hi", thread = "$root") + rel <- seen$extra$`m.relates_to` + expect_identical(rel$rel_type, "m.thread") + expect_identical(rel$event_id, "$root") + expect_true(rel$is_falling_back) + # And an unthreaded rich send carries no relation. + chat_send(cl, "!a:ex", "hi", rich = "hi") + expect_null(seen$extra$`m.relates_to`) +}) + +# Capabilities answer for this client. Reading a relation needs nothing +# special; attaching one needs an mx.client new enough to carry it. +local({ + caps <- chat_capabilities(seam_client()) + expect_true(caps$thread_replies) + expect_identical(caps$threads, chat.api:::matrix_threads_available()) +}) + # ---- Channel state ---- # The seam replaces mx.api::mx_set_state. The default state_key is the # empty string, where most Matrix state lives, and it is passed by name diff --git a/inst/tinytest/test_matrix_mxclient.R b/inst/tinytest/test_matrix_mxclient.R index 29a4d7c..361ba04 100644 --- a/inst/tinytest/test_matrix_mxclient.R +++ b/inst/tinytest/test_matrix_mxclient.R @@ -171,14 +171,51 @@ expect_equal(as.numeric(got$messages[[2L]]$ts), 1700000001, tolerance = 1e-6) # receives what it can send. The notice above does not come back. expect_false("a notice" %in% vapply(got$messages, `[[`, "", "body")) -# The extractor drops content$m.relates_to, so a threaded reply arrives -# with $thread empty and the relation unreachable from the message-level -# raw. thread_replies reports FALSE for exactly this reason. +# The record shape the adapter reads. The wire's content$m.relates_to +# arrives as the record's `relates_to`, so nothing here looks for the +# wire name. expect_true(all(c("room_id", "event_id", "sender", "is_self", "body", "msgtype", "mentions") %in% names(m$raw))) expect_false("content" %in% names(m$raw)) expect_null(m$raw[["m.relates_to"]]) -expect_false(chat_capabilities(p)$thread_replies) +# An unthreaded message has no thread, and NULL is the answer. +expect_null(m$thread) +expect_true(chat_capabilities(p)$thread_replies) + +# ---- A thread survives the real extractor ---- +# The end of the round trip, against the installed mx.client rather +# than a faked record: if that build ever stops carrying relates_to, +# $thread goes quietly empty and rehydration downstream loses the +# conversation it was keyed to. This is the line that would go red. +local({ + thr_sync <- list(rooms = list(join = list("!room:ex" = list( + timeline = list(events = list( + list(type = "m.room.message", event_id = "$t1", + sender = "@alice:ex", origin_server_ts = 1700000003000, + content = list(msgtype = "m.text", body = "in a thread", + "m.relates_to" = list(rel_type = "m.thread", + event_id = "$root", is_falling_back = TRUE, + "m.in_reply_to" = list(event_id = "$root")))), + list(type = "m.room.message", event_id = "$t2", + sender = "@alice:ex", origin_server_ts = 1700000004000, + content = list(msgtype = "m.text", body = "a rich reply", + "m.relates_to" = list( + "m.in_reply_to" = list(event_id = "$orig")))) + )))))) + tp <- chat_matrix(mx = fake_mx(), save_cursor = FALSE, + .sync = function(client, ...) { + list(sync = thr_sync, client = fake_mx("s2"), + first_run = FALSE) + }, + .send = function(...) "$id", + .media = function(...) NULL) + msgs <- chat_poll(tp)$messages + expect_identical(length(msgs), 2L) + expect_identical(msgs[[1L]]$thread, "$root") + # And a rich reply is still not a thread, through the real + # extractor as much as through a faked record. + expect_null(msgs[[2L]]$thread) +}) # ---- Resolve ---- # Offline: mx_resolve_room short-circuits on a !-prefixed id and on an From 2e512937069e0912077b4f5a894496ed805e41df Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:26:03 -0500 Subject: [PATCH 2/5] rformat + document --- R/matrix.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/R/matrix.R b/R/matrix.R index ff55a50..336ddad 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -1092,9 +1092,9 @@ matrix_send_rich <- function(client, channel, text, rich, msgtype, # one, fallback included, so a rich threaded reply is threaded # in exactly the clients a plain one is. extra[["m.relates_to"]] <- list( - rel_type = "m.thread", event_id = thread, - is_falling_back = TRUE, - "m.in_reply_to" = list(event_id = thread)) + rel_type = "m.thread", event_id = thread, + is_falling_back = TRUE, + "m.in_reply_to" = list(event_id = thread)) } fn(sess, channel, text, msgtype = msgtype, extra = extra) } From 07f664e9f3f58b271755ebd13b5d14036c470964 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:26:47 -0500 Subject: [PATCH 3/5] Bump version to 0.0.1.23 --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 8191952..6ee6361 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.22 +Version: 0.0.1.23 Date: 2026-08-18 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), From 12240f0d2519a2a2828032e59d11125377e1d19f Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:30:33 -0500 Subject: [PATCH 4/5] Read durable channel state back chat_get_state() is the counterpart of chat_set_state(), on the same set_state capability: a transport with durable channel state has both halves or neither. Matrix reads through mx.api::mx_get_state with an injectable .get_state seam; loopback reads its keyed store. State that was never written reads as NULL rather than erroring -- "no marker here" is an ordinary answer for a caller checking whether one is set, and it should not need a handler to get it. A state store that cannot be reached at all still errors, because that is a different fact. Needed by threaded rehydration, which maps a thread root to the archive it stands for by reading an index the fold wrote into the topic room's state; with only a setter that lookup would have to drop below the transport seam. --- NAMESPACE | 4 ++++ R/contract.R | 38 ++++++++++++++++++++++++++++++++++- R/loopback.R | 9 +++++++++ R/matrix.R | 14 ++++++++++++- inst/tinytest/test_contract.R | 25 +++++++++++++++++++++++ inst/tinytest/test_matrix.R | 25 +++++++++++++++++++++++ man/chat_capabilities.Rd | 4 +++- man/chat_get_state.Rd | 37 ++++++++++++++++++++++++++++++++++ man/chat_matrix.Rd | 1 + 9 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 man/chat_get_state.Rd diff --git a/NAMESPACE b/NAMESPACE index c6506b9..c91f39a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -10,6 +10,7 @@ export(chat_config) export(chat_config_save) export(chat_disconnect) export(chat_edit) +export(chat_get_state) export(chat_history) export(chat_identity) export(chat_invite) @@ -61,6 +62,9 @@ S3method(chat_edit,chat_loopback) S3method(chat_edit,chat_matrix) S3method(chat_edit,chat_slack) S3method(chat_edit,default) +S3method(chat_get_state,chat_loopback) +S3method(chat_get_state,chat_matrix) +S3method(chat_get_state,default) S3method(chat_history,chat_loopback) S3method(chat_history,chat_matrix) S3method(chat_history,chat_slack) diff --git a/R/contract.R b/R/contract.R index 7ec645a..e74c6f8 100644 --- a/R/contract.R +++ b/R/contract.R @@ -118,7 +118,9 @@ chat_resolve <- function(client, name, ...) { #' \code{\link{chat_addressed}}), \code{channel_create} #' (\code{\link{chat_channel_create}} works), \code{leave} #' (\code{\link{chat_leave}} works), \code{set_state} -#' (\code{\link{chat_set_state}} works), \code{files} (outbound: +#' (durable channel state: both \code{\link{chat_set_state}} and +#' \code{\link{chat_get_state}} work -- a transport has the pair or +#' neither), \code{files} (outbound: #' \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}, @@ -886,6 +888,40 @@ chat_set_state.default <- function(client, channel, type, content, "). Check chat_capabilities()$set_state.", call. = FALSE) } +#' Read durable typed state from a channel +#' +#' The counterpart of \code{\link{chat_set_state}}, reading back what +#' it wrote. Capability-gated on the same +#' \code{chat_capabilities()$set_state}: a transport with durable +#' channel state has both halves or neither. +#' +#' Absent state is \code{NULL}, not an error. "Nothing was ever +#' written here" is an ordinary answer for a caller checking whether a +#' marker is set, and one it should not have to wrap in a handler. A +#' state store that cannot be reached at all still errors, because +#' that is a different fact. +#' +#' @param client A \code{chat_client}. +#' @param channel Channel/room identifier. +#' @param type Character. Namespaced event type. +#' @param state_key Character. Sub-key within the type, defaulting to +#' the empty string. +#' @param ... Adapter-specific options. +#' @return The stored content as a named list, or \code{NULL} when no +#' state is set for that \code{type}/\code{state_key} pair. +#' @export +chat_get_state <- function(client, channel, type, state_key = "", ...) { + UseMethod("chat_get_state") +} + +#' @export +chat_get_state.default <- function(client, channel, type, state_key = "", + ...) { + stop("chat_get_state() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$set_state.", call. = FALSE) +} + #' Refresh this client's credentials #' #' Forces the re-authentication that adapters otherwise perform on diff --git a/R/loopback.R b/R/loopback.R index e9df0e2..76fc6a0 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -55,6 +55,15 @@ chat_set_state.chat_loopback <- function(client, channel, type, content, invisible(sprintf("loopback-state-%d", length(client$env$state))) } +#' @export +chat_get_state.chat_loopback <- function(client, channel, type, + state_key = "", ...) { + # NULL for state never written, which is the generic's contract and + # what makes the reference adapter usable as a test double for a + # consumer checking whether a marker is set. + client$env$state[[paste(channel, type, state_key, sep = "\r")]] +} + #' @export chat_send.chat_loopback <- function(client, channel, text, markup = c("plain", "markdown"), diff --git a/R/matrix.R b/R/matrix.R index 336ddad..15d6262 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -159,6 +159,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .create = NULL, .leave = NULL, .state = NULL, + .get_state = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, .edit = NULL, .rich = NULL) { @@ -216,7 +217,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, typing_fn = .typing, react_fn = .react, info_fn = .info, members_fn = .members, join_fn = .join, create_fn = .create, leave_fn = .leave, - state_fn = .state, + state_fn = .state, get_state_fn = .get_state, channels_fn = .channels, history_fn = .history, pending_fn = .pending, read_fn = .read, identity_fn = .identity, edit_fn = .edit, @@ -738,6 +739,17 @@ chat_leave.chat_matrix <- function(client, channel, ...) { invisible(channel) } +#' @export +chat_get_state.chat_matrix <- function(client, channel, type, state_key = "", + ...) { + # mx_get_state() already answers NULL for state that is not set, + # which is this generic's contract, so no error is absorbed here: a + # homeserver that cannot be reached still propagates. + sess <- mx.client::mx_client_session(client$env$mx) + state_fn <- client$get_state_fn %||% mx.api::mx_get_state + state_fn(sess, channel, type, state_key = state_key) +} + #' @export chat_set_state.chat_matrix <- function(client, channel, type, content, state_key = "", ...) { diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index 6a9cb9a..3b5327d 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -364,6 +364,31 @@ local({ nothing <- structure(list(), class = c("chat_nothing", "chat_client")) expect_error(chat_set_state(nothing, "c", "t", list()), "not supported by this adapter") + expect_error(chat_get_state(nothing, "c", "t"), + "not supported by this adapter") +}) + +# Reading state back is the other half of the same capability. +local({ + lo <- chat_loopback() + # State never written reads as NULL rather than erroring: "no marker + # here" is an ordinary answer, and a caller checking for one should + # not need a handler to get it. + expect_null(chat_get_state(lo, "general", "ai.example.marker")) + chat_set_state(lo, "general", "ai.example.marker", + list(state = "parked", since = "2026-08-20")) + expect_identical(chat_get_state(lo, "general", "ai.example.marker"), + list(state = "parked", since = "2026-08-20")) + # Keyed by channel, type and state_key alike, so neither a + # different room nor a different key sees this one. + expect_null(chat_get_state(lo, "other", "ai.example.marker")) + expect_null(chat_get_state(lo, "general", "ai.other.marker")) + expect_null(chat_get_state(lo, "general", "ai.example.marker", + state_key = "alt")) + # A write replaces the whole content, and the read sees that. + chat_set_state(lo, "general", "ai.example.marker", list(state = "active")) + expect_identical(chat_get_state(lo, "general", "ai.example.marker"), + list(state = "active")) }) # An adapter that cannot create says so. diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index f90499d..8c2040a 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1629,6 +1629,31 @@ expect_error( "!a:ex", "ai.example.marker", list()), "M_FORBIDDEN") +# Reading it back. mx_get_state() answers NULL for state that is not +# set, which is the generic's contract, so nothing is absorbed here. +local({ + seen <- NULL + cl <- seam_client(.get_state = function(session, channel, type, + state_key = "") { + seen <<- list(channel = channel, type = type, state_key = state_key) + list(state = "segment") + }) + expect_identical(chat_get_state(cl, "!a:ex", "ai.example.marker"), + list(state = "segment")) + expect_identical(seen$channel, "!a:ex") + expect_identical(seen$type, "ai.example.marker") + expect_identical(seen$state_key, "") + chat_get_state(cl, "!a:ex", "ai.example.marker", state_key = "$root") + expect_identical(seen$state_key, "$root") +}) +expect_null(chat_get_state(seam_client(.get_state = function(...) NULL), + "!a:ex", "ai.example.marker")) +# An unreachable state store is a different fact from absent state, and +# still errors. +expect_error( + chat_get_state(seam_client(.get_state = function(...) stop("HTTP 502")), + "!a:ex", "ai.example.marker"), "HTTP 502") + # ---- The record ---- iv <- chat_invite(channel = "!a:ex", inviter = "@ann:ex") expect_inherits(iv, "chat_invite") diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index fe7879b..310c706 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -24,7 +24,9 @@ A list with at least: \code{threads} (can post into \code{\link{chat_addressed}}), \code{channel_create} (\code{\link{chat_channel_create}} works), \code{leave} (\code{\link{chat_leave}} works), \code{set_state} - (\code{\link{chat_set_state}} works), \code{files} (outbound: + (durable channel state: both \code{\link{chat_set_state}} and + \code{\link{chat_get_state}} work -- a transport has the pair or + neither), \code{files} (outbound: \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}, diff --git a/man/chat_get_state.Rd b/man/chat_get_state.Rd new file mode 100644 index 0000000..033078b --- /dev/null +++ b/man/chat_get_state.Rd @@ -0,0 +1,37 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_get_state} +\alias{chat_get_state} +\title{Read durable typed state from a channel} +\usage{ +chat_get_state(client, channel, type, state_key = "", ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{channel}{Channel/room identifier.} + +\item{type}{Character. Namespaced event type.} + +\item{state_key}{Character. Sub-key within the type, defaulting to +the empty string.} + +\item{...}{Adapter-specific options.} +} +\value{ +The stored content as a named list, or \code{NULL} when no + state is set for that \code{type}/\code{state_key} pair. +} +\description{ +The counterpart of \code{\link{chat_set_state}}, reading back what +it wrote. Capability-gated on the same +\code{chat_capabilities()$set_state}: a transport with durable +channel state has both halves or neither. +} +\details{ +Absent state is \code{NULL}, not an error. "Nothing was ever +written here" is an ordinary answer for a caller checking whether a +marker is set, and one it should not have to wrap in a handler. A +state store that cannot be reached at all still errors, because +that is a different fact. + +} diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index e3a24c5..6bf9a17 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -25,6 +25,7 @@ chat_matrix( .create = NULL, .leave = NULL, .state = NULL, + .get_state = NULL, .channels = NULL, .history = NULL, .pending = NULL, From d6a2703871b0ff37569b3eeabffacb343b9a4ae5 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Thu, 20 Aug 2026 09:31:09 -0500 Subject: [PATCH 5/5] rformat + document --- R/contract.R | 3 +-- R/loopback.R | 2 +- R/matrix.R | 7 +++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/R/contract.R b/R/contract.R index e74c6f8..4b61b89 100644 --- a/R/contract.R +++ b/R/contract.R @@ -915,8 +915,7 @@ chat_get_state <- function(client, channel, type, state_key = "", ...) { } #' @export -chat_get_state.default <- function(client, channel, type, state_key = "", - ...) { +chat_get_state.default <- function(client, channel, type, state_key = "", ...) { stop("chat_get_state() is not supported by this adapter (", paste(class(client), collapse = "/"), "). Check chat_capabilities()$set_state.", call. = FALSE) diff --git a/R/loopback.R b/R/loopback.R index 76fc6a0..9feac87 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -57,7 +57,7 @@ chat_set_state.chat_loopback <- function(client, channel, type, content, #' @export chat_get_state.chat_loopback <- function(client, channel, type, - state_key = "", ...) { + state_key = "", ...) { # NULL for state never written, which is the generic's contract and # what makes the reference adapter usable as a test double for a # consumer checking whether a marker is set. diff --git a/R/matrix.R b/R/matrix.R index 15d6262..d7cfbe1 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -159,10 +159,9 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .create = NULL, .leave = NULL, .state = NULL, - .get_state = NULL, - .channels = NULL, .history = NULL, .pending = NULL, - .read = NULL, .identity = NULL, .edit = NULL, - .rich = NULL) { + .get_state = NULL, .channels = NULL, .history = NULL, + .pending = NULL, .read = NULL, .identity = NULL, + .edit = NULL, .rich = NULL) { seams <- list(.sync, .extract, .send, .media) if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) && !requireNamespace("mx.client", quietly = TRUE)) {