From e8d5eee659e6fe5647a5790cb055c8fd02ad65c3 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 5 Jun 2026 20:11:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20hivectl=20matrix=20invite=20=E2=80=94?= =?UTF-8?q?=20add=20a=20user=20to=20the=20hive=20Space=20or=20a=20room=20(?= =?UTF-8?q?closes=20#1402)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tools/hivectl.md | 8 ++++ hive-c0re/src/bin/hivectl.rs | 40 ++++++++++++++++++ hive-c0re/src/matrix.rs | 82 +++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 644efc9d..2570510a 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -44,6 +44,8 @@ hivectl matrix create-user mara --password hunter2 # set a client-login passwor hivectl matrix sync-admin # provision / refresh the hive internal admin account hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it +hivectl matrix invite mara # invite a user to the hive Space +hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a specific room/alias ``` - `create-user`: for agents, persists the `access_token` to @@ -58,6 +60,12 @@ hivectl matrix reset-password iris # generate and set a new random password - `reset-password`: calls the matrix admin API to set a new random password and prints it to stdout. Useful if an agent or human lost credentials. +- `invite`: invites a matrix user (full `@user:server` or a bare + localpart, qualified with the homeserver's `server_name`) to the hive + Space by default, or to a `--room` id / `#alias`. Uses the hive admin + token; the admin account must be a member of the target room with + invite power (it owns the hive Space, so that case always works). + Idempotent — already-member / already-invited is a no-op. ## Gateway diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 06f8208a..3cb9f157 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -195,6 +195,19 @@ enum MatrixCmd { /// Matrix localpart of the account to reset (e.g. `argus`). name: String, }, + /// Invite a matrix user to the hive Space (default) or a specific + /// room. Uses the hive admin token; the admin account must be a + /// member of the target room with invite power (it owns the hive + /// Space). Idempotent — already-member / already-invited is a no-op. + Invite { + /// User to invite: a full id (`@mara:server`) or a bare + /// localpart (qualified with the homeserver's `server_name`). + user: String, + /// Target room id (`!abc:server`) or alias (`#name:server`). + /// Omit to invite to the hive Space. + #[arg(long)] + room: Option, + }, } /// Default htpasswd file path — the host-side location of the gateway's @@ -300,6 +313,7 @@ async fn main() -> Result<()> { MatrixCmd::SyncAdmin => matrix_sync_admin().await, MatrixCmd::PromoteUser { name } => matrix_promote_user(&name).await, MatrixCmd::ResetPassword { name } => matrix_reset_password(&name).await, + MatrixCmd::Invite { user, room } => matrix_invite(&user, room.as_deref()).await, }, Cmd::Gateway { cmd } => match cmd { GatewayCmd::CreateUser { @@ -522,6 +536,32 @@ async fn matrix_promote_user(name: &str) -> Result<()> { Ok(()) } +async fn matrix_invite(user: &str, room: Option<&str>) -> Result<()> { + if !hive_c0re::matrix::is_present().await { + bail!( + "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) first" + ); + } + let admin_token = hive_c0re::matrix::read_admin_token()?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("build reqwest client")?; + let server_name = hive_c0re::matrix::discover_server_name(&client) + .await + .context("discover matrix server_name")?; + let room_id = hive_c0re::matrix::invite_user(&client, &admin_token, user, room, &server_name) + .await + .with_context(|| format!("matrix invite {user}"))?; + let target = if user.starts_with('@') { + user.to_owned() + } else { + format!("@{user}:{server_name}") + }; + println!("matrix: invited {target} to {room_id}"); + Ok(()) +} + async fn matrix_reset_password(name: &str) -> Result<()> { if !hive_c0re::matrix::is_present().await { bail!( diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 32397c6e..e973f18e 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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 { + // 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 { + 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::().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