Matrix: make a room, name it, and promote somebody in it

Closes #77. `createRoom` was only ever called to open a direct message, so
there was no way to make a room at all; a room's name and topic could be
read and never written; and power levels moved only downwards, through
mute, so nobody could be made an operator of anything from here.

One call makes a room or a space, because a space is a room with `m.space`
as its creation type and nothing else different - a second path for it
would be inventing a distinction the protocol does not have.

Encryption is offered and defaults to off, which is the reversible choice:
a room can be encrypted later and never un-encrypted. Where it is asked
for it goes in as initial state, since a room encrypted from its first
message has no plaintext history to leak and one turned on later always
does.

No buffer is created on the way out. The room arrives through the next
sync like any other, with the name and kind the server settled on -
guessing at both now would only mean reconciling later.

Setting a power level is the same read-modify-write `mute_member` already
does, for the same reason: a power-levels event is a full replace, so
PUTting one user would silently wipe every other rank and threshold the
room had. Giving somebody a rank above your own is refused here rather
than by the server, whose error talks about event authorisation instead of
about what was attempted. Giving somebody your own rank is allowed - it is
a real thing people do.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 08:32:28 -04:00
co-authored by Claude Opus 5
parent 3ec9d34bb3
commit 27ce6a112a
3 changed files with 192 additions and 0 deletions
+80
View File
@@ -1223,6 +1223,86 @@ pub async fn leave_room(state: &AppState, account_id: &str, room_id: &str) -> Re
/// creates is reconciled the normal way once the room's own first real
/// `/sync` response arrives (process_sync_response's cached-name check
/// already no-ops once a name/kind is set, same as any other room).
/// Makes a room, or a space.
///
/// A space is a room with `m.space` as its creation type and nothing else
/// different, which is why one call makes both - inventing a second path for
/// it would be inventing a distinction the protocol does not have.
///
/// Encryption is offered and is off by default. Turning it on afterwards is
/// possible and turning it off never is, so the default is the one that can
/// still be changed - and a room somebody meant to be private is a room they
/// will say so about.
///
/// The buffer is not created here. The room arrives through the next sync
/// like any other, with the name and kind the server settled on, and creating
/// one now would mean guessing at both and reconciling later.
pub async fn create_room(
state: &AppState,
account_id: &str,
name: &str,
topic: &str,
is_space: bool,
is_public: bool,
encrypted: bool,
) -> Result<String> {
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let base = account.homeserver_url.trim_end_matches('/');
let mut body = serde_json::json!({
"name": name,
// A public room is one anybody can find and join; a private one is
// invite-only. Both are ordinary presets rather than a pile of state
// events, which is what every other client sends too.
"preset": if is_public { "public_chat" } else { "private_chat" },
"visibility": if is_public { "public" } else { "private" },
});
if !topic.trim().is_empty() {
body["topic"] = serde_json::Value::String(topic.trim().to_string());
}
if is_space {
body["creation_content"] = serde_json::json!({ "type": "m.space" });
}
if encrypted {
// The one piece of initial state worth sending: a room encrypted from
// its first message has no plaintext history to leak, and one turned
// on later always does.
body["initial_state"] = serde_json::json!([{
"type": "m.room.encryption",
"state_key": "",
"content": { "algorithm": "m.megolm.v1.aes-sha2" }
}]);
}
let resp = http::post_json(&format!("{base}/_matrix/client/v3/createRoom"), Some(&account.access_token), body)
.await
.context("creating room")?;
resp["room_id"].as_str().map(str::to_string).context("createRoom response missing room_id")
}
/// Changes one of a room's own state events - its name or its topic.
///
/// Both are the same shape of call with a different type, and both are
/// refused by the server if this account's power level is too low, which is
/// where that question belongs.
pub async fn set_room_state(
state: &AppState,
account_id: &str,
buffer_id: &str,
event_type: &str,
content: serde_json::Value,
) -> Result<()> {
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known Matrix room for this buffer")?;
let base = account.homeserver_url.trim_end_matches('/');
let url = format!(
"{base}/_matrix/client/v3/rooms/{}/state/{event_type}/",
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>()
);
http::put_json(&url, &account.access_token, content).await.context("setting room state")?;
Ok(())
}
pub async fn open_dm(state: &AppState, account_id: &str, target_user_id: &str, target_display_name: &str) -> Result<String> {
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
let base = account.homeserver_url.trim_end_matches('/');
+36
View File
@@ -122,6 +122,42 @@ pub async fn unban_member(state: &AppState, account_id: &str, buffer_id: &str, t
Ok(())
}
/// Sets a member's power level outright - promoting or demoting them.
///
/// The same read-modify-write as `mute_member` and for the same reason: a
/// power-levels event is always a full replace, so PUTting only the one user
/// would silently wipe every other rank and threshold the room had.
///
/// Refusing to set a level above our own is this client's own rule, not the
/// server's - the server refuses it too, but with an error about event
/// authorisation rather than about what was actually attempted. Somebody
/// making another person an admin equal to themselves is allowed and is
/// deliberately not blocked; it is a real thing people do.
pub async fn set_power_level(
state: &AppState,
account_id: &str,
buffer_id: &str,
target_user_id: &str,
level: i64,
) -> Result<()> {
let (homeserver_url, access_token, room_id) = homeserver_token_room(state, account_id, buffer_id).await?;
let base = homeserver_url.trim_end_matches('/');
let encoded_room = url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>();
let state_url = format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/m.room.power_levels/");
let mut pl = http::get_json(&state_url, &access_token).await.context("fetching current power levels")?;
// The account id is "matrix:@user:server"; the power levels are keyed by
// the Matrix id itself.
let own_mxid = account_id.strip_prefix("matrix:").unwrap_or(account_id);
let own = user_power_level(&pl, own_mxid);
if level > own {
anyhow::bail!("you cannot give somebody a rank above your own ({own})");
}
pl["users"][target_user_id] = Value::from(level);
http::put_json(&state_url, &access_token, pl).await.context("setting power level")?;
Ok(())
}
/// Lowers a member's power level to one below whatever's needed to send
/// `m.room.message` in this room, without removing them from it - "mute"
/// as a power-level floor rather than a membership change. Read-modify-
+76
View File
@@ -2233,6 +2233,82 @@ pub async fn dispatch(
}
}
// Making a room, or a space - which is a room with a different
// creation type and nothing else different, so it is one call.
"createMatrixRoom" => {
let Some(account_id) = p_str_opt(params, "accountId") else {
return (None, Some("createMatrixRoom requires \"accountId\"".to_string()));
};
let name = p_str(params, "name", "").trim().to_string();
if name.is_empty() {
return (None, Some("a room needs a name".to_string()));
}
match backend::matrix::create_room(
state,
account_id,
&name,
p_str(params, "topic", ""),
p_bool(params, "isSpace", false),
p_bool(params, "isPublic", false),
p_bool(params, "encrypted", false),
)
.await
{
Ok(room_id) => (Some(serde_json::json!({ "roomId": room_id })), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// A room's own name and topic. Two state events with one shape, which
// is why they share a method rather than having one each.
"setMatrixRoomState" => {
let (account_id, buffer_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "bufferId")) {
(Some(a), Some(b)) => (a, b),
_ => return (None, Some("setMatrixRoomState requires \"accountId\" and \"bufferId\"".to_string())),
};
let mut done = false;
if let Some(name) = p_str_opt(params, "name") {
if let Err(e) = backend::matrix::set_room_state(state, account_id, buffer_id, "m.room.name", serde_json::json!({ "name": name })).await {
return (None, Some(format!("{e:#}")));
}
done = true;
}
if let Some(topic) = p_str_opt(params, "topic") {
if let Err(e) = backend::matrix::set_room_state(state, account_id, buffer_id, "m.room.topic", serde_json::json!({ "topic": topic })).await {
return (None, Some(format!("{e:#}")));
}
done = true;
}
if done {
(Some(ok_node()), None)
} else {
(None, Some("setMatrixRoomState needs a \"name\" or a \"topic\"".to_string()))
}
}
// Promoting or demoting somebody. Power levels were readable and
// movable only downwards, through mute - so nobody could be made an
// operator of a room from here.
"setMatrixPowerLevel" => {
let (account_id, buffer_id, user_id) =
match (p_str_opt(params, "accountId"), p_str_opt(params, "bufferId"), p_str_opt(params, "userId")) {
(Some(a), Some(b), Some(u)) => (a, b, u),
_ => {
return (
None,
Some("setMatrixPowerLevel requires \"accountId\", \"bufferId\" and \"userId\"".to_string()),
)
}
};
let Some(level) = params.get("level").and_then(|v| v.as_i64()) else {
return (None, Some("setMatrixPowerLevel requires a numeric \"level\"".to_string()));
};
match backend::matrix::moderation::set_power_level(state, account_id, buffer_id, user_id, level).await {
Ok(()) => (Some(ok_node()), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
// Asking somebody into a room. Its absence was the odd one out: this
// client could accept an invitation and decline one, and never send
// one - so a room created here could never gain a second member here.