A call in a room, between everybody in it

#81 gave Matrix calls of two, which is what `m.call.*` was written for:
one offer, one answer, one other person. A room with three people in it
had no call at all.

A group call has no invitation - it is a piece of room state saying
people are in one, which is how anybody learns there is a call to join.
So `m.call.member` is published on joining, refreshed while still here,
and withdrawn on leaving; it carries an expiry because a client that dies
mid-call never withdraws anything, and a membership past its stamp is
somebody's crash rather than a participant.

The mesh underneath is one connection per pair. Both ends see each other
arrive, so the rule for who calls is a property of the pair rather than
of who was first - "first" is not something two clients can agree on -
and the smaller key calls the larger, computed the same way on both
sides.

The permission is the part that would otherwise fail silently: a
membership is a state event, and a room's default is that only moderators
may send those. So a call started in an ordinary room is one nobody else
can join, and they learn it from a bare "not authorized". Starting a call
now lowers that one event to what everybody has, where this account may
say so - and where it may not, the refusal explains itself.

Closes #122.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 14:01:01 -04:00
co-authored by Claude Opus 5
parent c04d211b44
commit 6fd10f745e
4 changed files with 318 additions and 0 deletions
+176
View File
@@ -111,3 +111,179 @@ pub async fn turn_servers(state: &AppState, account_id: &str) -> anyhow::Result<
}
}
}
/// How long a published membership stands before it is treated as stale.
///
/// A client that is killed mid-call never withdraws its membership, so the
/// state event would say somebody is in a call for ever. Every client that
/// implements MSC3401 stamps an expiry and refreshes it while it is still
/// there; anything past its expiry is somebody's crash, not a participant.
pub const MEMBERSHIP_TTL_MS: i64 = 90_000;
/// The event that says who is in a room's call.
pub const EVENT_MEMBER: &str = "m.call.member";
/// Says this account is in the room's call, or is no longer.
///
/// One state event per user, keyed by user id, holding a list of memberships -
/// one per device, since the same person may be in a call from a phone and a
/// desktop and only one of those should stop when the other leaves.
///
/// Withdrawing sends the remaining memberships rather than empty content, for
/// the same reason: another device of this account may still be in the call.
pub async fn set_membership(
state: &AppState,
account_id: &str,
buffer_id: &str,
device_id: &str,
joined: bool,
) -> anyhow::Result<()> {
let account = state.accounts.get_matrix(account_id).ok_or_else(|| anyhow::anyhow!("account not connected"))?;
let room_id = state
.runtime
.get_matrix_room(buffer_id)
.ok_or_else(|| anyhow::anyhow!("no known Matrix room for this buffer"))?;
let now = chrono::Utc::now().timestamp_millis();
let mut memberships: Vec<Value> = state
.runtime
.matrix_call_members(account_id, &room_id)
.into_iter()
.filter(|m| m["user_id"].as_str() == Some(account.user_id.as_str()))
.filter_map(|m| m["membership"].clone().into())
// This device's own entry is rewritten below either way.
.filter(|m: &Value| m["device_id"].as_str() != Some(device_id))
.filter(|m: &Value| m["expires_ts"].as_i64().unwrap_or(0) > now)
.collect();
if joined {
memberships.push(json!({
"application": "m.call",
"call_id": "",
"scope": "m.room",
"device_id": device_id,
"expires_ts": now + MEMBERSHIP_TTL_MS,
// No focus: this is a mesh between the people in the room rather
// than a conference on somebody's server. See the module note.
"foci_active": [],
}));
}
// Whether everybody in the room may join a call at all.
//
// A membership is a *state* event, and a room's default is that only
// moderators may send those - so a call started in an ordinary room is
// one nobody else can join, and they find out with a bare "not
// authorized". Where this account can change the permissions, it lowers
// that one event to what everybody has; where it cannot, the call still
// works for whoever may already send it.
if joined {
allow_everybody_to_join(state, account_id, &room_id).await;
}
let result = super::put_room_state(
state,
account_id,
&room_id,
EVENT_MEMBER,
&account.user_id,
json!({ "memberships": memberships }),
)
.await;
if let Err(e) = &result {
// The refusal that has an explanation worth giving.
if e.to_string().contains("M_FORBIDDEN") {
anyhow::bail!(
"this room does not let its members join calls - somebody who can change the room's \
permissions has to allow the m.call.member event"
);
}
}
result
}
/// Lets ordinary members join this room's calls, where we may say so.
///
/// One key in the power levels: `events["m.call.member"] = 0`. Without it the
/// room's `state_default` applies, which is 50 - a moderator - and a call in
/// an ordinary room is a call of one.
///
/// Best-effort and silent on refusal: somebody without the power to change
/// permissions can still be in a call, and failing to start one because the
/// room could not be reconfigured would be worse than a call only some people
/// can join.
async fn allow_everybody_to_join(state: &AppState, account_id: &str, room_id: &str) {
let Some(levels) = state.runtime.matrix_power_levels(account_id, room_id) else { return };
if levels["events"][EVENT_MEMBER].as_i64() == Some(0) {
return;
}
let mut next = levels.clone();
next["events"][EVENT_MEMBER] = json!(0);
match super::put_room_state(state, account_id, room_id, "m.room.power_levels", "", next).await {
Ok(()) => tracing::info!("matrix[{account_id}]: {room_id} now lets its members join calls"),
Err(e) => tracing::debug!("matrix[{account_id}]: cannot open {room_id} to calls: {e:#}"),
}
}
/// Reads one `m.call.member` state event into the memberships it carries.
///
/// Tolerant, and expiry-aware: a membership past its stamp is somebody whose
/// client died, and treating it as a participant would leave a tile in the
/// grid for somebody who left hours ago.
pub fn read_memberships(user_id: &str, content: &Value, now_ms: i64) -> Vec<Value> {
content["memberships"]
.as_array()
.map(|list| {
list.iter()
.filter(|m| m["application"].as_str().unwrap_or("m.call") == "m.call")
.filter(|m| m["expires_ts"].as_i64().unwrap_or(0) > now_ms)
.map(|m| json!({ "user_id": user_id, "membership": m }))
.collect()
})
.unwrap_or_default()
}
/// Who to call, and who to wait for.
///
/// Both ends of every pair see each other arrive, and if both call, both
/// answer, and the call collides with itself. So the rule is one line and the
/// same on both sides: the smaller id calls the larger. It has to be a
/// property of the pair rather than of who arrived first, because "first" is
/// not something two clients can agree on.
pub fn should_offer(own_key: &str, their_key: &str) -> bool {
own_key < their_key
}
#[cfg(test)]
mod member_tests {
use super::*;
#[test]
fn a_membership_past_its_stamp_is_not_a_participant() {
let content = json!({ "memberships": [
{ "application": "m.call", "device_id": "HERE", "expires_ts": 2_000 },
{ "application": "m.call", "device_id": "GONE", "expires_ts": 500 },
]});
let live = read_memberships("@a:example.org", &content, 1_000);
assert_eq!(live.len(), 1);
assert_eq!(live[0]["membership"]["device_id"], "HERE");
assert_eq!(live[0]["user_id"], "@a:example.org");
}
#[test]
fn something_that_is_not_a_call_is_not_read_as_one() {
let content = json!({ "memberships": [
{ "application": "m.whiteboard", "device_id": "X", "expires_ts": 9_000 },
]});
assert!(read_memberships("@a:example.org", &content, 1_000).is_empty());
assert!(read_memberships("@a:example.org", &json!({}), 1_000).is_empty());
}
#[test]
fn exactly_one_end_of_every_pair_offers() {
// Whichever way round the two clients ask, they must not agree.
assert!(should_offer("@a:example.org|AAA", "@b:example.org|BBB"));
assert!(!should_offer("@b:example.org|BBB", "@a:example.org|AAA"));
// Including two devices of the same person, which is the case that
// makes a user-id comparison alone wrong.
assert!(should_offer("@a:example.org|AAA", "@a:example.org|ZZZ"));
assert!(!should_offer("@a:example.org|ZZZ", "@a:example.org|AAA"));
}
}
+22
View File
@@ -55,6 +55,28 @@ pub async fn process_state_events(state: &AppState, account_id: &str, room_id: &
);
}
}
// Who is in this room's call. A state event per person, and the
// one thing a client needs to know before it can join one: a
// group call has no invitation, only people already in it.
"m.call.member" => {
let who = event["state_key"].as_str().unwrap_or_default();
let live = crate::backend::matrix::calls::read_memberships(
who,
&event["content"],
chrono::Utc::now().timestamp_millis(),
);
let everybody = state.runtime.set_matrix_call_members(account_id, room_id, who, live);
if let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, room_id) {
state.events.emit(
"matrixCallMembers",
serde_json::json!({
"accountId": account_id,
"bufferId": buffer_id,
"members": everybody,
}),
);
}
}
"m.room.avatar" => {
if let Some(mxc) = event["content"]["url"].as_str() {
if let Some(path) = cached_media_path(homeserver_url, access_token, mxc, "").await {
+75
View File
@@ -311,6 +311,28 @@ pub async fn dispatch(
// read something new.
backend::matrix::replay_read_receipts(state, buffer_id);
// A call already happening in this room. Replayed for the
// same reason the roster is: it is state rather than news, so
// a window opened after it started would show no sign of it
// until somebody joined or left - which on a call of two
// people already talking is never.
if let Some(room_id) = state.runtime.get_matrix_room(buffer_id) {
let account_id = state.runtime.get_buffer(buffer_id).map(|b| b.account_id).unwrap_or_default();
let now = chrono::Utc::now().timestamp_millis();
let members: Vec<serde_json::Value> = state
.runtime
.matrix_call_members(&account_id, &room_id)
.into_iter()
.filter(|m| m["membership"]["expires_ts"].as_i64().unwrap_or(0) > now)
.collect();
if !members.is_empty() {
state.events.emit(
"matrixCallMembers",
serde_json::json!({ "accountId": account_id, "bufferId": buffer_id, "members": members }),
);
}
}
// And what a Kick channel is broadcasting, which changes while
// nobody is looking: a viewer count from an hour ago is worse
// than none, so opening the channel asks again.
@@ -3623,6 +3645,59 @@ pub async fn dispatch(
}
}
// Joining a room's call, or leaving it. The membership is state in
// the room, so everybody else in it learns there is a call to join.
"setMatrixCallMembership" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("setMatrixCallMembership requires \"bufferId\"".to_string()));
};
let joined = params.get("joined").and_then(|v| v.as_bool()).unwrap_or(true);
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such buffer".to_string()));
};
let Some(account) = state.accounts.get_matrix(&buffer.account_id) else {
return (None, Some("account not connected".to_string()));
};
match backend::matrix::calls::set_membership(state, &buffer.account_id, buffer_id, &account.device_id, joined).await {
Ok(()) => (
Some(serde_json::json!({
"userId": account.user_id,
"deviceId": account.device_id,
// The room, which is what both ends of a call have in
// common: each account names the same room by its own
// buffer id, so a conference named after the buffer
// would be two conferences that never meet.
"roomId": state.runtime.get_matrix_room(buffer_id),
})),
None,
),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"listMatrixCallMembers" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listMatrixCallMembers requires \"bufferId\"".to_string()));
};
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such buffer".to_string()));
};
let Some(room_id) = state.runtime.get_matrix_room(buffer_id) else {
return (Some(serde_json::json!([])), None);
};
let now = chrono::Utc::now().timestamp_millis();
// Filtered on the way out as well as on the way in: a membership
// read an hour ago may have expired since, and nobody refreshes
// somebody else's.
let live: Vec<serde_json::Value> = state
.runtime
.matrix_call_members(&buffer.account_id, &room_id)
.into_iter()
.filter(|m| m["membership"]["expires_ts"].as_i64().unwrap_or(0) > now)
.collect();
(Some(serde_json::json!(live)), None)
}
// Not hearing from somebody, on whichever service they are on.
//
// One method rather than one per backend, because the person doing it
+45
View File
@@ -432,6 +432,12 @@ pub struct Runtime {
/// refuses that line. Asking for history from a server that never granted
/// it is a command it will answer with an error in the server tab.
irc_caps: Mutex<HashMap<String, std::collections::HashSet<String>>>,
/// Who is in a room's call, per account and room.
///
/// Read from `m.call.member` state rather than tracked here: the state is
/// the truth, this is only the copy the client is told about without
/// re-reading the room every time somebody asks.
matrix_call_members: Mutex<HashMap<String, Vec<serde_json::Value>>>,
/// Sticker packs, per account: pack key -> the images it offers.
///
/// One flat table rather than per-room, because a picker offers everything
@@ -836,6 +842,7 @@ impl Runtime {
irc_caps: Mutex::new(HashMap::new()),
irc_monitor: Mutex::new(std::collections::HashSet::new()),
matrix_stickers: Mutex::new(HashMap::new()),
matrix_call_members: Mutex::new(HashMap::new()),
matrix_rooms: Mutex::new(HashMap::new()),
matrix_room_names: Mutex::new(HashMap::new()),
matrix_space_parents: Mutex::new(HashMap::new()),
@@ -2343,6 +2350,15 @@ impl Runtime {
self.matrix_room_avatars.lock().unwrap().get(&(account_id.to_string(), room_id.to_string())).cloned()
}
/// This room's power levels, as the state last said.
pub fn matrix_power_levels(&self, account_id: &str, room_id: &str) -> Option<Value> {
self.matrix_power_levels
.lock()
.unwrap()
.get(&(account_id.to_string(), room_id.to_string()))
.cloned()
}
pub fn set_matrix_power_levels(&self, account_id: &str, room_id: &str, content: Value) {
self.matrix_power_levels.lock().unwrap().insert((account_id.to_string(), room_id.to_string()), content);
}
@@ -2919,6 +2935,35 @@ impl Runtime {
self.irc_caps.lock().unwrap().remove(account_id);
}
/// Everybody currently in this room's call, as the state last said.
pub fn matrix_call_members(&self, account_id: &str, room_id: &str) -> Vec<serde_json::Value> {
self.matrix_call_members
.lock()
.unwrap()
.get(&format!("{account_id}|{room_id}"))
.cloned()
.unwrap_or_default()
}
/// Replaces one person's memberships in a room, and answers with everybody
/// who is in the call afterwards.
///
/// Per user because that is how the state is keyed: one event per person,
/// listing the devices of theirs that are in the call.
pub fn set_matrix_call_members(
&self,
account_id: &str,
room_id: &str,
user_id: &str,
memberships: Vec<serde_json::Value>,
) -> Vec<serde_json::Value> {
let mut all = self.matrix_call_members.lock().unwrap();
let room = all.entry(format!("{account_id}|{room_id}")).or_default();
room.retain(|m| m["user_id"].as_str() != Some(user_id));
room.extend(memberships);
room.clone()
}
/// Records one sticker pack, replacing whatever was under that key.
///
/// An empty pack removes the key rather than leaving an empty group in