diff --git a/DESCRIPTION b/DESCRIPTION index 13250bb..8df026c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.2.0.3 +Version: 0.2.0.4 Date: 2026-08-04 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NAMESPACE b/NAMESPACE index 1a0c310..5d56149 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -26,6 +26,7 @@ export(mx_crypto_sessions_load) export(mx_crypto_sessions_new) export(mx_crypto_sessions_save) export(mx_crypto_store_dir) +export(mx_extract_invite_records) export(mx_extract_invites) export(mx_extract_reaction_verdict) export(mx_extract_reactions) diff --git a/NEWS.md b/NEWS.md index 82b9446..51536a7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,22 @@ +# mx.client 0.2.0.4 + +## New + +* `mx_extract_invite_records()` reports pending invites with the member + who sent each one, as `list(room_id, inviter)`. `mx_extract_invites()` + gives only room ids, and an invite's sender is the whole of whether it + should be accepted -- auto-joining anyone's invite hands a stranger a + session with whatever the client can do -- so a caller that wanted to + decide had to walk `invite_state` itself. + + `inviter` is NA when the stripped state carries no `m.room.member` + event for `self_id`, rather than being guessed at, so a caller gating + on it can tell "nobody I trust" from "I could not tell". Both refuse; + they are different reasons. + + No timestamp: stripped state has no reliable `origin_server_ts`, and an + invite is a standing state rather than an event at a moment. + # mx.client 0.2.0.3 ## New diff --git a/R/messages.R b/R/messages.R index 8bd1228..acf4d30 100644 --- a/R/messages.R +++ b/R/messages.R @@ -164,6 +164,57 @@ mx_extract_invites <- function(sync_resp) { names(invited) } +#' Extract pending invites with the member who sent them +#' +#' The fuller form of \code{\link{mx_extract_invites}}, which reports +#' only room ids. An invite's sender is the whole of whether it should be +#' accepted -- auto-joining anyone's invite hands a stranger a session +#' with whatever the client can do -- and a caller that wants to decide +#' had to walk \code{invite_state} itself to find out. +#' +#' The sender comes from the stripped state the homeserver sends +#' alongside an invite: the \code{m.room.member} event whose +#' \code{state_key} is \code{self_id} and whose membership is +#' \code{"invite"}. That is not always present, so \code{inviter} is NA +#' when it is missing rather than guessed at, and a caller gating on it +#' can tell "nobody I trust" from "I could not tell". +#' +#' Stripped state carries no reliable \code{origin_server_ts}, so there +#' is no timestamp here. An invite is a standing state, not an event at a +#' moment. +#' +#' @param sync_resp Parsed \code{/sync} response. +#' @param self_id Current user's Matrix id, whose membership event names +#' the inviter. NULL reports every invite with \code{inviter} NA. +#' @return List of records, each \code{list(room_id, inviter)}. +#' @examples +#' sync_resp <- list(rooms = list(invite = list(`!inv:example.org` = list( +#' invite_state = list(events = list(list(type = "m.room.member", +#' state_key = "@bot:example.org", sender = "@ann:example.org", +#' content = list(membership = "invite")))))))) +#' mx_extract_invite_records(sync_resp, self_id = "@bot:example.org") +#' @export +mx_extract_invite_records <- function(sync_resp, self_id = NULL) { + invited <- sync_resp$rooms$invite + if (!length(invited)) { + return(list()) + } + lapply(names(invited), function(rid) { + who <- NA_character_ + if (!is.null(self_id)) { + for (ev in invited[[rid]]$invite_state$events %||% list()) { + if (isTRUE(ev$type == "m.room.member") && + isTRUE(ev$state_key == self_id) && + isTRUE(ev$content$membership == "invite")) { + who <- ev$sender %||% NA_character_ + break + } + } + } + list(room_id = rid, inviter = who) + }) +} + #' Accept pending Matrix room invites #' #' @param client Matrix client config. diff --git a/inst/tinytest/test_mx.client.R b/inst/tinytest/test_mx.client.R index 5076e6c..724c468 100644 --- a/inst/tinytest/test_mx.client.R +++ b/inst/tinytest/test_mx.client.R @@ -271,3 +271,62 @@ expect_true(grepl("token:\\s+", out_empty)) capture.output(vis <- withVisible(print(secret_cfg))) expect_false(vis$visible) expect_identical(vis$value, secret_cfg) + +# --- Invite records carry who sent them --- +# mx_extract_invites() reports only room ids, and the sender is the whole +# of whether an invite should be accepted -- so a caller that wants to +# decide had to walk invite_state itself. + +inv_member <- function(sender, self = "@bot:ex", membership = "invite") { + list(type = "m.room.member", state_key = self, sender = sender, + content = list(membership = membership)) +} +inv_sync <- list(rooms = list(invite = list( + `!a:ex` = list(invite_state = list(events = list( + list(type = "m.room.name", content = list(name = "Lab")), + inv_member("@ann:ex")))), + `!b:ex` = list(invite_state = list(events = list(inv_member("@bob:ex")))), + # No membership event for us: the sender cannot be determined. + `!c:ex` = list(invite_state = list(events = list( + list(type = "m.room.name", content = list(name = "Mystery"))))), + # No invite_state at all. + `!d:ex` = list() +))) + +recs <- mx.client::mx_extract_invite_records(inv_sync, self_id = "@bot:ex") +expect_equal(length(recs), 4L) +expect_equal(vapply(recs, function(r) r$room_id, ""), + c("!a:ex", "!b:ex", "!c:ex", "!d:ex")) +expect_equal(recs[[1]]$inviter, "@ann:ex") +expect_equal(recs[[2]]$inviter, "@bob:ex") +# NA, not NULL and not a guess: a caller gating on the sender can tell +# "nobody I trust" from "I could not tell", and both refuse. +expect_true(is.na(recs[[3]]$inviter)) +expect_true(is.na(recs[[4]]$inviter)) + +# A membership event for someone else is not our invitation. +other <- list(rooms = list(invite = list(`!a:ex` = list(invite_state = list( + events = list(inv_member("@ann:ex", self = "@someone:ex"))))))) +expect_true(is.na(mx.client::mx_extract_invite_records( + other, self_id = "@bot:ex")[[1]]$inviter)) +# Nor is a join or a leave. +for (m in c("join", "leave", "ban")) { + s <- list(rooms = list(invite = list(`!a:ex` = list(invite_state = list( + events = list(inv_member("@ann:ex", membership = m))))))) + expect_true(is.na(mx.client::mx_extract_invite_records( + s, self_id = "@bot:ex")[[1]]$inviter)) +} + +# Without a self_id there is no membership event to match, so every +# invite reports NA rather than picking an arbitrary sender. +expect_true(all(vapply(mx.client::mx_extract_invite_records(inv_sync), + function(r) is.na(r$inviter), logical(1)))) + +# No invites is an empty list, not an error. +expect_equal(length(mx.client::mx_extract_invite_records(list())), 0L) +expect_equal(length(mx.client::mx_extract_invite_records( + list(rooms = list(invite = list())), self_id = "@bot:ex")), 0L) + +# The room ids agree with the older, simpler extractor. +expect_equal(vapply(recs, function(r) r$room_id, ""), + mx.client::mx_extract_invites(inv_sync)) diff --git a/man/mx_extract_invite_records.Rd b/man/mx_extract_invite_records.Rd new file mode 100644 index 0000000..b62cbf4 --- /dev/null +++ b/man/mx_extract_invite_records.Rd @@ -0,0 +1,43 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_extract_invite_records} +\alias{mx_extract_invite_records} +\title{Extract pending invites with the member who sent them} +\usage{ +mx_extract_invite_records(sync_resp, self_id = NULL) +} +\arguments{ +\item{sync_resp}{Parsed \code{/sync} response.} + +\item{self_id}{Current user's Matrix id, whose membership event names +the inviter. NULL reports every invite with \code{inviter} NA.} +} +\value{ +List of records, each \code{list(room_id, inviter)}. +} +\description{ +The fuller form of \code{\link{mx_extract_invites}}, which reports +only room ids. An invite's sender is the whole of whether it should be +accepted -- auto-joining anyone's invite hands a stranger a session +with whatever the client can do -- and a caller that wants to decide +had to walk \code{invite_state} itself to find out. +} +\details{ +The sender comes from the stripped state the homeserver sends +alongside an invite: the \code{m.room.member} event whose +\code{state_key} is \code{self_id} and whose membership is +\code{"invite"}. That is not always present, so \code{inviter} is NA +when it is missing rather than guessed at, and a caller gating on it +can tell "nobody I trust" from "I could not tell". + +Stripped state carries no reliable \code{origin_server_ts}, so there +is no timestamp here. An invite is a standing state, not an event at a +moment. + +} +\examples{ +sync_resp <- list(rooms = list(invite = list(`!inv:example.org` = list( + invite_state = list(events = list(list(type = "m.room.member", + state_key = "@bot:example.org", sender = "@ann:example.org", + content = list(membership = "invite")))))))) +mx_extract_invite_records(sync_resp, self_id = "@bot:example.org") +}