Join the calls Element is actually in

moho could see an Element call and not be in it: Element's media goes
through a LiveKit media server and moho only knew how to mesh. Now it
does both, and which one a call uses is the call's own answer rather than
a setting.

Finding the server, in the order that answers best: the call itself,
since everybody has to be where the media already is and that is what
makes joining somebody else's call possible; then this account's own
setting, for a homeserver that publishes nothing - Element carries the
same fallback for the same reason; then the homeserver's `.well-known`,
which is where a server set up for calls says so.

Getting in is an OpenID token, not an access token. The service beside
the SFU has no way to check a Matrix access token, so the homeserver
vouches for the account in a form a third party can verify by asking the
homeserver back, and that is exchanged for a URL and a JWT good for one
room. Asked for before joining, so a refusal fails the join rather than
leaving this account listed in a call it never reached.

The membership then says `livekit` rather than `moho.mesh`, which is what
makes it legible to Element: its clients read the transports and connect
to the one they can use.

The mesh stays for rooms with no server at all, which is most self-hosted
homeservers, and is what MSC4143's pluggable transports are for.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 20:45:46 -04:00
co-authored by Claude Opus 5
parent 9e07dba3c2
commit 2d96e89a34
6 changed files with 186 additions and 56 deletions
+26
View File
@@ -199,6 +199,15 @@ pub struct MatrixAccountConfig {
pub next_batch: Option<String>,
#[serde(default)]
pub display_name: Option<String>,
/// A media server to hold calls on, where the homeserver names none.
///
/// A Matrix room call runs either between the people in it or through an
/// SFU, and which one is a property of the homeserver: a server set up for
/// calls publishes `org.matrix.msc4143.rtc_foci` in its `.well-known`.
/// Plenty do not, so this is the same manual fallback Element carries -
/// name a LiveKit JWT service here and calls go through it.
#[serde(default)]
pub rtc_focus_url: Option<String>,
}
impl MatrixAccountConfig {
@@ -605,6 +614,23 @@ impl AccountStore {
self.mutate(account_id, |a| a.notify = nicks_csv.to_string())
}
/// Names the media server this account's room calls go through.
///
/// Empty clears it, which puts calls back on the mesh between the people
/// in the room - that being what works with no infrastructure at all.
pub fn set_matrix_rtc_focus(&self, account_id: &str, url: &str) -> Result<bool> {
let mut matrix = self.matrix.lock().unwrap();
match matrix.get_mut(account_id) {
None => Ok(false),
Some(a) => {
let url = url.trim();
a.rtc_focus_url = if url.is_empty() { None } else { Some(url.to_string()) };
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
Ok(true)
}
}
}
pub fn set_nickserv_password(&self, account_id: &str, password: &str) -> Result<bool> {
self.mutate(account_id, |a| {
a.nickserv_password = if password.is_empty() { None } else { Some(password.to_string()) }
+1
View File
@@ -265,6 +265,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
+102 -30
View File
@@ -23,6 +23,7 @@
use serde_json::{json, Value};
use anyhow::Context;
use crate::state::AppState;
/// Events a client has to see to be one end of a call.
@@ -170,6 +171,8 @@ pub async fn set_membership(
buffer_id: &str,
device_id: &str,
joined: bool,
// The media server this end is on, where it is on one.
focus: Option<Value>,
) -> anyhow::Result<()> {
let account = state.accounts.get_matrix(account_id).ok_or_else(|| anyhow::anyhow!("account not connected"))?;
let room_id = state
@@ -198,12 +201,13 @@ pub async fn set_membership(
"membershipID": format!("{}:{}", account.user_id, device_id),
"created_ts": now,
"expires": MEMBERSHIP_TTL_MS,
// What this end can actually be reached on. Not a LiveKit focus,
// because moho has no SFU to offer and claiming one would send
// everybody else to dial nothing; a client that does not know
// this transport ignores it, which is the honest outcome.
"focus_active": { "type": TRANSPORT_MESH },
"foci_preferred": [{ "type": TRANSPORT_MESH }],
// What this end is actually on. A media server where the call
// has one - which is what makes this membership legible to
// Element, since that is the only transport its clients speak -
// and the mesh where there is none, which a client that does not
// know it ignores rather than dialling nothing.
"focus_active": focus.clone().unwrap_or_else(|| json!({ "type": TRANSPORT_MESH })),
"foci_preferred": [focus.clone().unwrap_or_else(|| json!({ "type": TRANSPORT_MESH }))],
})
} else {
// Leaving is an empty membership rather than a deleted event, because
@@ -293,10 +297,8 @@ pub fn read_membership(user_id: &str, content: &Value, now_ms: i64) -> Option<Va
if expires_ts <= now_ms {
return None;
}
let transports: Vec<String> = content["foci_preferred"]
.as_array()
.map(|list| list.iter().filter_map(|t| t["type"].as_str().map(String::from)).collect())
.unwrap_or_default();
let foci = content["foci_preferred"].as_array().cloned().unwrap_or_default();
let transports: Vec<String> = foci.iter().filter_map(|t| t["type"].as_str().map(String::from)).collect();
Some(json!({
"user_id": user_id,
"membership": {
@@ -304,20 +306,90 @@ pub fn read_membership(user_id: &str, content: &Value, now_ms: i64) -> Option<Va
"expires_ts": expires_ts,
},
"transports": transports,
// The media server this participant is on, where they are on one:
// everybody in a call has to be where the media already is, so this
// is what a client joining afterwards has to use.
"focus": foci.iter().find(|f| f["type"].as_str() == Some("livekit")).cloned().unwrap_or(Value::Null),
}))
}
/// What a call needs from whoever wants to join it.
/// Where the media server is, if there is one to use.
///
/// Read from the participants rather than decided here: a call where anybody
/// offers a LiveKit focus is a call held on that server, and one where
/// everybody is on the mesh is a call held between the people in it. moho can
/// be in the second kind and not yet the first, and the difference has to be
/// said rather than discovered by joining and hearing nothing.
pub fn needs_a_focus(members: &[Value]) -> bool {
members
/// Three places, in the order that answers the question best:
///
/// 1. The call itself. A call already running names its focus in every
/// membership, and everybody has to be where the media already is - this
/// is what makes joining *Element's* call possible rather than starting a
/// second one beside it.
/// 2. This account's own setting, for a homeserver that publishes nothing.
/// Element has the same fallback for the same reason.
/// 3. The homeserver's `.well-known`, which is where a server that has been
/// set up for calls says so.
pub async fn find_focus(state: &AppState, account_id: &str, room_id: &str) -> Option<Value> {
let members = state.runtime.matrix_call_members(account_id, room_id);
if let Some(focus) = members.iter().find_map(|m| m["focus"].as_object()) {
return Some(Value::Object(focus.clone()));
}
let account = state.accounts.get_matrix(account_id)?;
if let Some(url) = account.rtc_focus_url.as_deref().filter(|u| !u.trim().is_empty()) {
return Some(json!({ "type": "livekit", "livekit_service_url": url.trim() }));
}
// The homeserver's own answer. Fetched rather than remembered: it changes
// when somebody sets a server up for calls, which is exactly when a
// client that cached "none" would be wrong.
let base = account.homeserver_url.trim_end_matches('/');
let host = base.split("://").nth(1).unwrap_or(base).split('/').next().unwrap_or(base);
let well_known = super::http::get_json(&format!("https://{host}/.well-known/matrix/client"), "").await.ok()?;
well_known["org.matrix.msc4143.rtc_foci"]
.as_array()?
.iter()
.any(|m| m["transports"].as_array().is_some_and(|t| t.iter().any(|t| t.as_str() == Some("livekit"))))
.find(|f| f["type"].as_str() == Some("livekit"))
.cloned()
}
/// Trades this account's identity for a way into the media server.
///
/// The server beside a LiveKit SFU does not take a Matrix access token - it
/// would have no way to check one. It takes an OpenID token, which is the
/// homeserver saying "this really is who they claim to be" in a form a third
/// party can verify by asking the homeserver back. That is exchanged for a
/// LiveKit URL and a JWT good for one room.
pub async fn rtc_token(state: &AppState, account_id: &str, room_id: &str, focus: &Value) -> anyhow::Result<Value> {
let account = state.accounts.get_matrix(account_id).ok_or_else(|| anyhow::anyhow!("account not connected"))?;
let base = account.homeserver_url.trim_end_matches('/');
let openid = super::http::post_json(
&format!(
"{base}/_matrix/client/v3/user/{}/openid/request_token",
url::form_urlencoded::byte_serialize(account.user_id.as_bytes()).collect::<String>()
),
Some(&account.access_token),
json!({}),
)
.await
.context("asking the homeserver to vouch for this account")?;
let service = focus["livekit_service_url"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("that call's media server has no address"))?
.trim_end_matches('/');
// The room as the media server knows it. The call names its own alias
// where it has one, since everybody has to land in the same LiveKit room.
let alias = focus["livekit_alias"].as_str().unwrap_or(room_id);
let answer = super::http::post_json(
&format!("{service}/sfu/get"),
None,
json!({ "room": alias, "openid_token": openid, "device_id": account.device_id }),
)
.await
.context("asking the media server for a way in")?;
if answer["jwt"].as_str().unwrap_or_default().is_empty() {
anyhow::bail!("the media server would not let this account in");
}
Ok(json!({
"url": answer["url"],
"jwt": answer["jwt"],
"identity": format!("{}:{}", account.user_id, account.device_id),
}))
}
/// Who to call, and who to wait for./// Who to call, and who to wait for.
@@ -375,6 +447,17 @@ mod member_tests {
assert!(read_membership("@them:example.org", &json!({}), 1_000).is_none());
}
#[test]
fn a_membership_carries_the_media_server_it_is_on() {
let read = read_membership("@them:example.org", &element_membership(1_000, 9_000), 2_000).unwrap();
// Everybody in a call has to be where the media already is, so this
// is what a client joining afterwards connects to.
assert_eq!(read["focus"]["livekit_service_url"], "https://livekit-jwt.call.matrix.org");
let mesh = json!({ "application": "m.call", "device_id": "D", "created_ts": 1_000, "expires": 9_000,
"foci_preferred": [{ "type": "moho.mesh" }] });
assert!(read_membership("@a:example.org", &mesh, 2_000).unwrap()["focus"].is_null());
}
#[test]
fn the_newer_shape_is_read_too() {
let content = json!({
@@ -394,17 +477,6 @@ mod member_tests {
assert!(read_membership("@a:example.org", &content, 1_000).is_none());
}
#[test]
fn a_call_with_a_focus_in_it_is_not_a_mesh() {
let livekit = read_membership("@them:example.org", &element_membership(1_000, 9_000), 2_000).unwrap();
let mesh = json!({ "transports": ["moho.mesh"] });
assert!(needs_a_focus(&[livekit.clone()]));
assert!(!needs_a_focus(&[mesh.clone()]));
// One person on an SFU makes it an SFU call: everybody has to be
// where the media is.
assert!(needs_a_focus(&[mesh, livekit]));
}
#[test]
fn the_state_key_is_the_one_element_writes() {
// Prefixed on an ordinary room, because a key starting with `@` is
+11 -3
View File
@@ -3724,7 +3724,8 @@ async fn try_sso_login(state: &AppState, login_id: &str, homeserver_url: &str, p
device_id: login.device_id,
next_batch: None,
display_name: None,
};
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config)?;
let account = crate::accounts::matrix_account_to_json(&saved, "connecting", false);
spawn(state.clone(), saved);
@@ -3813,7 +3814,8 @@ async fn try_login(state: &AppState, login_id: &str, homeserver_url: &str, usern
device_id: String::new(),
next_batch: None,
display_name: None,
};
rtc_focus_url: None,
};
state.accounts.add_matrix(pending.clone())?;
let login = auth::login(homeserver_url, username, password, None).await.context("logging in")?;
@@ -3826,7 +3828,8 @@ async fn try_login(state: &AppState, login_id: &str, homeserver_url: &str, usern
device_id: login.device_id,
next_batch: None,
display_name: None,
};
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config)?;
// The placeholder above was keyed on whatever was typed, and the server
// answers with the canonical user id - "salastil" against "@salastil:
@@ -4035,6 +4038,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
@@ -4152,6 +4156,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
@@ -4263,6 +4268,7 @@ mod tests {
device_id: login_a.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved_a = state.accounts.add_matrix(config_a).expect("add_matrix failed");
let account_id_a = saved_a.account_id();
@@ -4419,6 +4425,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
@@ -4574,6 +4581,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
+1
View File
@@ -547,6 +547,7 @@ mod tests {
device_id: login.device_id.clone(),
next_batch: None,
display_name: None,
rtc_focus_url: None,
};
let saved = state.accounts.add_matrix(config).expect("add_matrix failed");
let account_id = saved.account_id();
+45 -23
View File
@@ -3658,30 +3658,42 @@ pub async fn dispatch(
let Some(account) = state.accounts.get_matrix(&buffer.account_id) else {
return (None, Some("account not connected".to_string()));
};
// A call held on somebody's SFU is not one moho can be in yet:
// its media never goes peer to peer, and joining it would put
// this account in the participant list and send nothing. Said
// before joining rather than discovered by silence afterwards.
if joined {
if let Some(room_id) = state.runtime.get_matrix_room(buffer_id) {
let members = state.runtime.matrix_call_members(&buffer.account_id, &room_id);
if backend::matrix::calls::needs_a_focus(&members) {
return (
None,
Some(
concat!(
"this call runs through a media server, which is what Element's own ",
"calls use - moho cannot join one yet. It can hold a call between the ",
"people in the room, which is what it offers where no media server is ",
"in use."
)
.to_string(),
),
);
let Some(room_id) = state.runtime.get_matrix_room(buffer_id) else {
return (None, Some("that conversation is not a Matrix room".to_string()));
};
// Which kind of call this is. A media server where the call
// already runs on one, or where this account has one to offer;
// otherwise a mesh between the people in the room. Decided before
// joining, because it decides what the membership says.
let focus = if joined {
backend::matrix::calls::find_focus(state, &buffer.account_id, &room_id).await
} else {
None
};
// And the way in, which is a token rather than a promise: asked
// for here so that failing to get one fails the join rather than
// leaving this account listed in a call it never reached.
let media = match (&focus, joined) {
(Some(focus), true) => {
match backend::matrix::calls::rtc_token(state, &buffer.account_id, &room_id, focus).await {
Ok(token) => Some(token),
Err(e) => return (None, Some(format!("{e:#}"))),
}
}
}
match backend::matrix::calls::set_membership(state, &buffer.account_id, buffer_id, &account.device_id, joined).await {
_ => None,
};
match backend::matrix::calls::set_membership(
state,
&buffer.account_id,
buffer_id,
&account.device_id,
joined,
focus.clone(),
)
.await
{
Ok(()) => (
Some(serde_json::json!({
"userId": account.user_id,
@@ -3690,7 +3702,10 @@ pub async fn dispatch(
// 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),
"roomId": room_id,
// Where the media goes. Absent means the mesh.
"media": media,
"encrypted": state.runtime.is_matrix_room_encrypted(buffer_id),
})),
None,
),
@@ -3698,6 +3713,13 @@ pub async fn dispatch(
}
}
// Where this account's room calls go, for a homeserver that names no
// media server of its own.
"setMatrixRtcFocus" => match p_str_opt(params, "accountId") {
None => (None, Some("setMatrixRtcFocus requires \"accountId\"".to_string())),
Some(id) => account_mutation_result(state.accounts.set_matrix_rtc_focus(id, p_str(params, "url", ""))),
},
"listMatrixCallMembers" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("listMatrixCallMembers requires \"bufferId\"".to_string()));