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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 51 additions & 0 deletions R/messages.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions inst/tinytest/test_mx.client.R
Original file line number Diff line number Diff line change
Expand Up @@ -271,3 +271,62 @@ expect_true(grepl("token:\\s+<unset>", 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))
43 changes: 43 additions & 0 deletions man/mx_extract_invite_records.Rd
Original file line number Diff line number Diff line change
@@ -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")
}
Loading