feat: hivectl matrix invite — add a user to the hive Space or a room (closes #1402)
This commit is contained in:
parent
153870ec3a
commit
e8d5eee659
3 changed files with 128 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue