From a2c6e98389e5392284c5f46b378bd03c4219e5df Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Wed, 5 Aug 2026 17:55:19 -0500 Subject: [PATCH] Extract reactions, and stop dropping message relations Two gaps in the same walk, both blocking chat.api's reaction verb. mx_extract_reactions() reports every m.reaction in a sync: (room_id, event_id, sender, is_self, target_event_id, key, ts). The event's own id and the id it annotates are separate fields, since conflating them is the obvious mistake and the one a caller cannot detect afterwards. mx_extract_reaction_verdict() already existed and is not this. It answers one approve/deny question about one event with the key semantics baked in -- which keys mean yes, which mean no -- and those belong to whoever is asking. It stays for callers who want that; this one reports keys and leaves their meaning alone. Only additions are reported. Removing a reaction is an m.room.redaction of the m.reaction event, which is not an m.reaction, so a consumer that has to notice un-reactions reads redactions itself. Documented rather than left to be discovered. And mx_extract_text_events() now passes m.relates_to through. That field is what tells a threaded reply (rel_type m.thread) from a rich reply (an m.in_reply_to with no rel_type) from an edit (m.replace), and dropping it left every caller unable to tell any of them from an ordinary message -- which is why chat.api reports thread_replies = FALSE. Passed verbatim rather than interpreted: which of those a caller cares about is its own business. Bundled because it is the same walk over the same events, and because the alternative is a second trip through mx.client -> chat.api -> drat -> corteza for three lines. 183 assertions, three mutations, all caught. --- DESCRIPTION | 2 +- NAMESPACE | 1 + NEWS.md | 18 +++++ R/messages.R | 82 ++++++++++++++++++++++- inst/tinytest/test_mx.client.R | 118 +++++++++++++++++++++++++++++++++ man/mx_extract_reactions.Rd | 50 ++++++++++++++ man/mx_extract_text_events.Rd | 10 ++- 7 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 man/mx_extract_reactions.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 6870c29..13250bb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.0.2 +Version: 0.2.0.3 Date: 2026-08-04 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NAMESPACE b/NAMESPACE index b9769ed..1a0c310 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -28,6 +28,7 @@ export(mx_crypto_sessions_save) export(mx_crypto_store_dir) export(mx_extract_invites) export(mx_extract_reaction_verdict) +export(mx_extract_reactions) export(mx_extract_text_events) export(mx_markdown_to_html) export(mx_pill_mentions) diff --git a/NEWS.md b/NEWS.md index bcc5a8b..82b9446 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,21 @@ +# mx.client 0.2.0.3 + +## New + +* `mx_extract_reactions()` returns every `m.reaction` in a sync as a + record carrying `room_id`, `event_id` (the reaction, not its target), + `sender`, `is_self`, `target_event_id`, `key`, and `ts`. It is the + general form of `mx_extract_reaction_verdict()`, which answers one + approve/deny question about one event with the key semantics baked in; + that stays for callers who want it. Only additions are reported -- + removing a reaction is a redaction of the `m.reaction` event, which a + consumer that needs to notice un-reactions has to read itself. + +* `mx_extract_text_events()` records now carry `relates_to`, the event's + `m.relates_to` content verbatim. It is what distinguishes a threaded + reply from a rich reply from an edit, and dropping it left every caller + unable to tell any of them from an ordinary message. + # mx.client 0.2.0.2 ## Security diff --git a/R/messages.R b/R/messages.R index 5da1f9a..8bd1228 100644 --- a/R/messages.R +++ b/R/messages.R @@ -103,7 +103,15 @@ mx_sync_update <- function(client, timeout = 0L, filter = NULL, save = TRUE, #' \code{room_id}, \code{event_id}, \code{sender}, \code{is_self}, #' \code{body}, \code{msgtype}, \code{ts} (the event's #' \code{origin_server_ts}, in milliseconds since the epoch, or NULL -#' when the server omits it), and \code{mentions}. +#' when the server omits it), \code{mentions}, and \code{relates_to}. +#' +#' \code{relates_to} is the event's \code{m.relates_to} content, +#' verbatim, or NULL. It is what distinguishes a threaded reply +#' (\code{rel_type} of \code{m.thread}) from a rich reply (an +#' \code{m.in_reply_to} with no \code{rel_type}) from an edit +#' (\code{m.replace}), and dropping it left every caller unable to tell +#' any of them from an ordinary message. Passed through rather than +#' interpreted: which of those a caller cares about is its own business. #' @examples #' sync_resp <- list(rooms = list(join = list("!room:example.org" = list( #' timeline = list(events = list(list(type = "m.room.message", @@ -132,7 +140,8 @@ mx_extract_text_events <- function(sync_resp, self_id, msgtypes = "m.text") { is_self = isTRUE(ev$sender == self_id), body = ev$content$body, msgtype = ev$content$msgtype, ts = ev$origin_server_ts, - mentions = ev$content$`m.mentions`$user_ids) + mentions = ev$content$`m.mentions`$user_ids, + relates_to = ev$content$`m.relates_to`) } } } @@ -190,6 +199,75 @@ mx_accept_invites <- function(client, invites) { joined } +#' Extract reaction events from a sync response +#' +#' Walks joined-room timelines and returns every \code{m.reaction} that +#' annotates another event. Self reactions are retained and tagged with +#' \code{is_self}, the same way \code{\link{mx_extract_text_events}} +#' treats the client's own messages: a consumer that wants only other +#' people's reactions filters on the flag, and one tracking its own +#' (which reactions it has already placed) needs them. +#' +#' This is the general form of \code{\link{mx_extract_reaction_verdict}}, +#' which answers one approve/deny question about one event. That function +#' bakes in which keys mean yes and which mean no, and stays for callers +#' who want that; this one reports keys and leaves their meaning alone. +#' +#' Only additions are reported. Removing a reaction is an +#' \code{m.room.redaction} of the \code{m.reaction} event, which is not an +#' \code{m.reaction} and so does not appear here -- a consumer that has to +#' notice un-reactions needs to read redactions itself. +#' +#' @param sync_resp Parsed \code{/sync} response. +#' @param self_id Current user's Matrix id. +#' @return List of records, each carrying \code{room_id}, +#' \code{event_id} (the reaction's own id, not its target), +#' \code{sender}, \code{is_self}, \code{target_event_id} (the annotated +#' event), \code{key} (the reaction text, usually an emoji), and +#' \code{ts} (\code{origin_server_ts} in milliseconds, or NULL when the +#' server omits it). An \code{m.reaction} carrying no annotation +#' relation, no target, or no key is not a reaction to anything and is +#' skipped. +#' @examples +#' sync_resp <- list(rooms = list(join = list("!room:example.org" = list( +#' timeline = list(events = list(list(type = "m.reaction", +#' event_id = "$r1", sender = "@alice:example.org", +#' content = list("m.relates_to" = list(rel_type = "m.annotation", +#' event_id = "$msg", key = "+1"))))))))) +#' mx_extract_reactions(sync_resp, self_id = "@bot:example.org") +#' @export +mx_extract_reactions <- function(sync_resp, self_id) { + joined <- sync_resp$rooms$join + if (!length(joined)) { + return(list()) + } + out <- list() + for (rid in names(joined)) { + for (ev in joined[[rid]]$timeline$events %||% list()) { + if (!isTRUE(ev$type == "m.reaction")) { + next + } + rel <- ev$content$`m.relates_to` + # rel_type is checked rather than assumed: m.reaction is + # defined to carry m.annotation, and an event using it for + # something else is not a reaction whose target we can name. + if (!is.list(rel) || !isTRUE(rel$rel_type == "m.annotation")) { + next + } + if (!is.character(rel$event_id) || !length(rel$event_id) || + !is.character(rel$key) || !length(rel$key)) { + next + } + out[[length(out) + 1L]] <- list(room_id = rid, + event_id = ev$event_id, sender = ev$sender, + is_self = isTRUE(ev$sender == self_id), + target_event_id = rel$event_id, key = rel$key, + ts = ev$origin_server_ts) + } + } + out +} + #' Extract a reaction approval verdict from sync events #' #' Scans a room timeline for a reaction on \code{target_event_id} from diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index 730a2dd..5076e6c 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -112,6 +112,124 @@ expect_null(mx.client::mx_extract_reaction_verdict( reaction_sync, "!missing:ex", "@bot:ex", "$target" )) +# --- Message relations survive extraction --- +# m.relates_to is what tells a threaded reply from a rich reply from an +# edit. Dropping it left every caller unable to tell any of them from an +# ordinary message. + +rel_sync <- list(rooms = list(join = list("!r:ex" = list(timeline = list( + events = list( + list(type = "m.room.message", event_id = "$plain", sender = "@a:ex", + content = list(msgtype = "m.text", body = "hello")), + list(type = "m.room.message", event_id = "$thread", sender = "@a:ex", + content = list(msgtype = "m.text", body = "in a thread", + `m.relates_to` = list(rel_type = "m.thread", + event_id = "$root"))), + list(type = "m.room.message", event_id = "$reply", sender = "@a:ex", + content = list(msgtype = "m.text", body = "a rich reply", + `m.relates_to` = list( + `m.in_reply_to` = list(event_id = "$orig")))) + )))))) +rel <- mx.client::mx_extract_text_events(rel_sync, "@bot:ex") +expect_equal(length(rel), 3L) +# An ordinary message has no relation, and NULL is the answer -- not an +# empty list a caller has to test the shape of. +expect_null(rel[[1]]$relates_to) +# A thread carries its rel_type and root. +expect_equal(rel[[2]]$relates_to$rel_type, "m.thread") +expect_equal(rel[[2]]$relates_to$event_id, "$root") +# A rich reply has no rel_type at all, which is exactly how it differs +# from a thread. Passed through verbatim rather than normalized, so the +# caller can tell. +expect_null(rel[[3]]$relates_to$rel_type) +expect_equal(rel[[3]]$relates_to$`m.in_reply_to`$event_id, "$orig") +# The fields that were already there are unchanged. +expect_equal(rel[[2]]$body, "in a thread") +expect_equal(rel[[2]]$event_id, "$thread") + +# --- General reaction extraction --- +# mx_extract_reaction_verdict() answers one approve/deny question about +# one event, with the key semantics baked in. This reports every reaction +# and leaves what the keys mean to the caller. + +rx_sync <- list(rooms = list(join = list( + "!r:ex" = list(timeline = list(events = list( + list(type = "m.reaction", event_id = "$r1", sender = "@alice:ex", + origin_server_ts = 1700000000000, + content = list(`m.relates_to` = list(rel_type = "m.annotation", + event_id = "$msg", + key = "\U0001F44D"))), + list(type = "m.reaction", event_id = "$r2", sender = "@bot:ex", + content = list(`m.relates_to` = list(rel_type = "m.annotation", + event_id = "$msg", + key = "eyes"))), + # An ordinary message in the same timeline is not a reaction. + list(type = "m.room.message", event_id = "$m1", sender = "@alice:ex", + content = list(msgtype = "m.text", body = "hello")) + ))), + "!other:ex" = list(timeline = list(events = list( + list(type = "m.reaction", event_id = "$r3", sender = "@carol:ex", + content = list(`m.relates_to` = list(rel_type = "m.annotation", + event_id = "$elsewhere", + key = "x"))) + ))) +))) + +rx <- mx.client::mx_extract_reactions(rx_sync, "@bot:ex") +expect_equal(length(rx), 3L) +# Every room is walked, not just the first. +expect_equal(sort(vapply(rx, function(r) r$room_id, "")), + c("!other:ex", "!r:ex", "!r:ex")) +# event_id is the reaction's own, and the annotated event is separate -- +# conflating them is the mistake this record shape exists to prevent. +expect_equal(rx[[1]]$event_id, "$r1") +expect_equal(rx[[1]]$target_event_id, "$msg") +expect_equal(rx[[1]]$sender, "@alice:ex") +expect_equal(rx[[1]]$key, "\U0001F44D") +expect_equal(rx[[1]]$ts, 1700000000000) +# Self reactions are kept and tagged, like self messages: a consumer +# tracking which reactions it has already placed needs them. +expect_false(rx[[1]]$is_self) +expect_true(rx[[2]]$is_self) +expect_equal(rx[[2]]$key, "eyes") +# A server that omits origin_server_ts leaves ts NULL rather than +# inventing one. +expect_null(rx[[2]]$ts) + +# An m.reaction that annotates nothing is not a reaction to anything. +malformed <- list(rooms = list(join = list("!r:ex" = list(timeline = list( + events = list( + # no m.relates_to at all + list(type = "m.reaction", event_id = "$a", sender = "@alice:ex", + content = list()), + # a relation that is not an annotation + list(type = "m.reaction", event_id = "$b", sender = "@alice:ex", + content = list(`m.relates_to` = list(rel_type = "m.thread", + event_id = "$msg", + key = "x"))), + # annotation with no target + list(type = "m.reaction", event_id = "$c", sender = "@alice:ex", + content = list(`m.relates_to` = list(rel_type = "m.annotation", + key = "x"))), + # annotation with no key + list(type = "m.reaction", event_id = "$d", sender = "@alice:ex", + content = list(`m.relates_to` = list(rel_type = "m.annotation", + event_id = "$msg"))) + )))))) +expect_equal(length(mx.client::mx_extract_reactions(malformed, "@bot:ex")), 0L) + +# Empty and message-only syncs return an empty list, not an error. +expect_equal(length(mx.client::mx_extract_reactions(list(), "@bot:ex")), 0L) +expect_equal(length(mx.client::mx_extract_reactions( + list(rooms = list(join = list())), "@bot:ex")), 0L) +expect_equal(length(mx.client::mx_extract_reactions( + list(rooms = list(join = list("!r:ex" = list(timeline = list( + events = list(list(type = "m.room.message", event_id = "$m", + sender = "@a:ex", + content = list(msgtype = "m.text", + body = "hi")))))))), + "@bot:ex")), 0L) + if (is.na(old_env)) { Sys.unsetenv("MX_CLIENT_TEST_MATRIX_CONFIG") } else { diff --git a/man/mx_extract_reactions.Rd b/man/mx_extract_reactions.Rd new file mode 100644 index 0000000..9900d7f --- /dev/null +++ b/man/mx_extract_reactions.Rd @@ -0,0 +1,50 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_extract_reactions} +\alias{mx_extract_reactions} +\title{Extract reaction events from a sync response} +\usage{ +mx_extract_reactions(sync_resp, self_id) +} +\arguments{ +\item{sync_resp}{Parsed \code{/sync} response.} + +\item{self_id}{Current user's Matrix id.} +} +\value{ +List of records, each carrying \code{room_id}, + \code{event_id} (the reaction's own id, not its target), + \code{sender}, \code{is_self}, \code{target_event_id} (the annotated + event), \code{key} (the reaction text, usually an emoji), and + \code{ts} (\code{origin_server_ts} in milliseconds, or NULL when the + server omits it). An \code{m.reaction} carrying no annotation + relation, no target, or no key is not a reaction to anything and is + skipped. +} +\description{ +Walks joined-room timelines and returns every \code{m.reaction} that +annotates another event. Self reactions are retained and tagged with +\code{is_self}, the same way \code{\link{mx_extract_text_events}} +treats the client's own messages: a consumer that wants only other +people's reactions filters on the flag, and one tracking its own +(which reactions it has already placed) needs them. +} +\details{ +This is the general form of \code{\link{mx_extract_reaction_verdict}}, +which answers one approve/deny question about one event. That function +bakes in which keys mean yes and which mean no, and stays for callers +who want that; this one reports keys and leaves their meaning alone. + +Only additions are reported. Removing a reaction is an +\code{m.room.redaction} of the \code{m.reaction} event, which is not an +\code{m.reaction} and so does not appear here -- a consumer that has to +notice un-reactions needs to read redactions itself. + +} +\examples{ +sync_resp <- list(rooms = list(join = list("!room:example.org" = list( + timeline = list(events = list(list(type = "m.reaction", + event_id = "$r1", sender = "@alice:example.org", + content = list("m.relates_to" = list(rel_type = "m.annotation", + event_id = "$msg", key = "+1"))))))))) +mx_extract_reactions(sync_resp, self_id = "@bot:example.org") +} diff --git a/man/mx_extract_text_events.Rd b/man/mx_extract_text_events.Rd index cd41db8..161cba1 100644 --- a/man/mx_extract_text_events.Rd +++ b/man/mx_extract_text_events.Rd @@ -17,7 +17,15 @@ List of normalized event records, each carrying \code{room_id}, \code{event_id}, \code{sender}, \code{is_self}, \code{body}, \code{msgtype}, \code{ts} (the event's \code{origin_server_ts}, in milliseconds since the epoch, or NULL - when the server omits it), and \code{mentions}. + when the server omits it), \code{mentions}, and \code{relates_to}. + + \code{relates_to} is the event's \code{m.relates_to} content, + verbatim, or NULL. It is what distinguishes a threaded reply + (\code{rel_type} of \code{m.thread}) from a rich reply (an + \code{m.in_reply_to} with no \code{rel_type}) from an edit + (\code{m.replace}), and dropping it left every caller unable to tell + any of them from an ordinary message. Passed through rather than + interpreted: which of those a caller cares about is its own business. } \description{ Walks joined-room timeline events and returns normalized text-message