feat: hivectl matrix invite — add a user to the hive Space or a room (closes #1402)

This commit is contained in:
damocles 2026-06-05 20:11:30 +02:00 committed by mara
commit e8d5eee659
3 changed files with 128 additions and 2 deletions

View file

@ -1108,12 +1108,24 @@ async fn invite_to_room(
room_id: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, admin_token, room_id, &user_id).await
}
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
/// using the admin token. Idempotent: a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// (already a member or pending invite) is treated as success.
async fn invite_user_id(
client: &reqwest::Client,
admin_token: &str,
room_id: &str,
user_id: &str,
) -> Result<()> {
// `:` must be percent-encoded in the room-id path segment; `!` is
// permitted in URL path characters per RFC 3986.
let encoded_room_id = room_id.replace(':', "%3A");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let user_id = format!("@{localpart}:{server_name}");
let resp = client
.post(&url)
.bearer_auth(admin_token)
@ -1123,7 +1135,7 @@ async fn invite_to_room(
.with_context(|| format!("matrix: POST /rooms/.../invite for {user_id}"))?;
let status = resp.status();
if status.is_success() {
tracing::debug!(%user_id, %room_id, "matrix: invited to hive space");
tracing::debug!(%user_id, %room_id, "matrix: invited to room");
return Ok(());
}
// 403 with M_FORBIDDEN or M_BAD_STATE typically means the user is
@ -1141,6 +1153,72 @@ async fn invite_to_room(
anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}")
}
/// Invite an arbitrary Matrix user to the hive Space (default) or an
/// explicit `room_id` / alias. `user` may be a fully-qualified id
/// (`@name:server`) or a bare localpart, which is qualified with the
/// homeserver's `server_name`. Returns the resolved room id the invite
/// targeted. Used by `hivectl matrix invite`.
///
/// # Errors
///
/// Returns an error if the admin token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user
/// already being a member / invited.
pub async fn invite_user(
client: &reqwest::Client,
admin_token: &str,
user: &str,
room_override: Option<&str>,
server_name: &str,
) -> Result<String> {
// Qualify a bare localpart to a full user id on the hive homeserver.
let user_id = if user.starts_with('@') {
user.to_owned()
} else {
format!("@{user}:{server_name}")
};
// Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space.
let room_id = match room_override {
Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?,
Some(r) => r.to_owned(),
None => std::fs::read_to_string(hive_space_room_id_path())
.map(|s| s.trim().to_owned())
.context(
"matrix: no --room given and no persisted hive space \
(run the hive-c0re matrix sweep first)",
)?,
};
invite_user_id(client, admin_token, &room_id, &user_id).await?;
Ok(room_id)
}
/// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias(
client: &reqwest::Client,
admin_token: &str,
alias: &str,
) -> Result<String> {
let encoded = alias.replace('#', "%23").replace(':', "%3A");
let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}");
let resp = client
.get(&url)
.bearer_auth(admin_token)
.send()
.await
.with_context(|| format!("matrix: GET directory for {alias}"))?;
let status = resp.status();
let json = resp.json::<serde_json::Value>().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("matrix: resolve alias {alias}: HTTP {status}, body: {json}");
}
json["room_id"]
.as_str()
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}"))
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a matrix user + token on the local homeserver. Called once
/// at hive-c0re startup, alongside `forge::ensure_all`. No-op when the