diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0205c1e4..193b9d47 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -51,16 +51,6 @@ pub const HIVE_ADMIN_LOCALPART: &str = "hive"; /// being created on the next sweep. pub const HIVE_SPACE_NAME: &str = "hive"; -/// Display name of the default "hive chat" room — the `m.space.child` of -/// the hive Space that every agent + the operator can join. Plain text so -/// it stays rediscoverable by name (mirrors [`HIVE_SPACE_NAME`]) when the -/// persisted room-id file is lost, preventing duplicate chat rooms. -pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; - -/// Topic for the default hive chat room. -const HIVE_CHAT_ROOM_TOPIC: &str = - "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; - /// Host path for the hive admin matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] @@ -105,13 +95,6 @@ pub fn hive_space_room_id_path() -> PathBuf { crate::paths::matrix_space_room_id() } -/// Host path where the default hive chat room ID is persisted. Outside -/// every purgeable path — not deleted by `destroy --purge`. -#[must_use] -pub fn hive_chat_room_id_path() -> PathBuf { - crate::paths::matrix_chat_room_id() -} - /// Probe whether `hive-matrix` exists as a nixos-container. Cheap — /// `nixos-container list` is just a directory scan in /etc. Same shape /// as `forge::is_present` — routed through hive-priv since @@ -1117,229 +1100,6 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R Ok(room_id) } -/// Persist the hive chat room id to [`hive_chat_room_id_path()`] (0600). -fn persist_chat_room_id(room_id: &str) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let path = hive_chat_room_id_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).ok(); - } - std::fs::write(&path, format!("{room_id}\n")) - .with_context(|| format!("matrix: write chat room_id to {}", path.display()))?; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - Ok(()) -} - -/// Scan the admin account's joined rooms for the canonical hive chat room: -/// a non-space room named [`HIVE_CHAT_ROOM_NAME`]. Mirrors -/// [`find_space_by_name`] so a lost room-id file recovers the existing chat -/// room instead of spawning a duplicate. `None` if the homeserver is -/// unreachable or no match exists. -async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option { - let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); - let joined: serde_json::Value = client - .get(&joined_url) - .bearer_auth(admin_token) - .send() - .await - .ok()? - .json() - .await - .ok()?; - let rooms = joined["joined_rooms"].as_array()?; - for room in rooms { - let Some(room_id) = room.as_str() else { - continue; - }; - let encoded = encode_room_id_for_url(room_id); - // Skip the Space itself (and any other m.space). - let create_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(admin_token) - .send() - .await - { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .is_some_and(|c| c["type"].as_str() == Some("m.space")), - _ => false, - }; - if is_space { - continue; - } - // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). - let name_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { - Ok(r) if r.status().is_success() => r - .json::() - .await - .ok() - .is_some_and(|n| n["name"].as_str() == Some(HIVE_CHAT_ROOM_NAME)), - _ => false, - }; - if name_matches { - return Some(room_id.to_owned()); - } - } - None -} - -/// PUT a state event into `room_id` using the admin token. Idempotent — -/// re-sending identical content is a no-op on the homeserver. -async fn set_room_state( - client: &reqwest::Client, - admin_token: &str, - room_id: &str, - event_type: &str, - state_key: &str, - content: &serde_json::Value, -) -> Result<()> { - let encoded_room = encode_room_id_for_url(room_id); - let encoded_key = encode_room_id_for_url(state_key); - let url = format!( - "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}" - ); - let resp = client - .put(&url) - .bearer_auth(admin_token) - .json(content) - .send() - .await - .with_context(|| format!("matrix: PUT state {event_type} into {room_id}"))?; - let status = resp.status(); - if status.is_success() { - return Ok(()); - } - let body = resp.json::().await.unwrap_or_default(); - anyhow::bail!("matrix: set state {event_type} in {room_id}: HTTP {status}, body: {body}") -} - -/// Create (or recover) the default hive chat room and wire it as a child of -/// the hive Space, persisting its room id to [`hive_chat_room_id_path()`]. -/// -/// The room is a normal room (not an `m.space`) named [`HIVE_CHAT_ROOM_NAME`] -/// with a `restricted` join rule allowing any member of the hive Space to -/// join — so the operator (who is in the Space) and every agent can chat -/// without needing an explicit invite. It's linked bidirectionally to the -/// Space: `m.space.child` on the Space points at the room, `m.space.parent` -/// on the room points back. Joining a Space does NOT auto-join its children -/// (Matrix semantics), so this gives clients a concrete room to surface + -/// join instead of an empty Space. -/// -/// Dedup mirrors [`ensure_hive_space`]: persisted id wins, else rediscover -/// by name, else create. The space-child link is re-applied on every call -/// (idempotent PUT) so a recovered room reconverges its hierarchy link. -/// -/// # Errors -/// -/// Returns an error if the homeserver is unreachable, `createRoom` fails, -/// or the room-ID file cannot be written. A failure wiring the space-child -/// link is logged but not fatal (the room still exists + is joinable). -pub async fn ensure_hive_chat_room( - client: &reqwest::Client, - admin_token: &str, - space_room_id: &str, - server_name: &str, -) -> Result { - // 1. Stored room id wins (fast path). 2. Rediscover by name before - // creating (prevents duplicates after a state wipe). 3. Create. - let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path()) - .ok() - .map(|s| s.trim().to_owned()) - .filter(|s| !s.is_empty()) - { - tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); - id - } else if let Some(id) = find_chat_room_by_name(client, admin_token).await { - persist_chat_room_id(&id)?; - tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); - id - } else { - // `initial_state` is applied after the preset-derived state, so the - // restricted join rule overrides private_chat's invite-only default. - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); - let body = serde_json::json!({ - "name": HIVE_CHAT_ROOM_NAME, - "topic": HIVE_CHAT_ROOM_TOPIC, - // Pin the room version explicitly: the `restricted` join rule - // below needs room version >= 8. tuwunel's default is currently - // higher, but pinning makes the dependency explicit so a future - // homeserver-default change can't silently invalidate the - // restricted rule (which would quietly fall back to invite-only - // and break the "operator joins from the Space" path). - "room_version": "10", - "preset": "private_chat", - "visibility": "private", - "initial_state": [ - { - "type": "m.room.join_rules", - "state_key": "", - "content": { - "join_rule": "restricted", - "allow": [ - { "type": "m.room_membership", "room_id": space_room_id } - ] - } - }, - { - "type": "m.space.parent", - "state_key": space_room_id, - "content": { "via": [server_name], "canonical": true } - } - ] - }); - let resp = client - .post(&url) - .bearer_auth(admin_token) - .json(&body) - .send() - .await - .context("matrix: POST /createRoom (hive chat room)")?; - let status = resp.status(); - let json = resp - .json::() - .await - .context("matrix: parse /createRoom response (chat room)")?; - if !status.is_success() { - anyhow::bail!("matrix: createRoom (chat) HTTP {status}, body: {json}"); - } - let id = json["room_id"] - .as_str() - .with_context(|| format!("matrix: createRoom (chat) missing room_id: {json}"))? - .to_owned(); - persist_chat_room_id(&id)?; - tracing::info!(room_id = %id, "matrix: created hive chat room"); - id - }; - - // Wire the Space → room child link (idempotent). Without an - // `m.space.child` carrying a `via`, the room won't surface in the Space - // hierarchy. `suggested` hints clients to surface it prominently. - let child_content = serde_json::json!({ - "via": [server_name], - "suggested": true, - }); - if let Err(e) = set_room_state( - client, - admin_token, - space_room_id, - "m.space.child", - &room_id, - &child_content, - ) - .await - { - tracing::warn!(error = ?e, "matrix: set m.space.child on hive space failed"); - } - - Ok(room_id) -} - /// Invite `@{localpart}:{server_name}` to `room_id` using the admin /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( @@ -1553,37 +1313,6 @@ pub async fn ensure_all() { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); } } - - // Provision the default hive chat room as an m.space.child of the Space - // and invite @hive + every agent. Joining a Space alone surfaces no - // rooms to chat in (Matrix semantics — children aren't auto-joined), so - // without this the Space is empty. The restricted join rule additionally - // lets the operator (a Space member) join from the Space hierarchy. - match ensure_hive_chat_room(&client, &admin_token, &room_id, &server_name).await { - Ok(chat_room_id) => { - if let Err(e) = invite_to_room( - &client, - &admin_token, - &chat_room_id, - HIVE_ADMIN_LOCALPART, - &server_name, - ) - .await - { - tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed"); - } - for name in &agent_names { - if let Err(e) = - invite_to_room(&client, &admin_token, &chat_room_id, name, &server_name).await - { - tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); - } - } - } - Err(e) => { - tracing::warn!(error = ?e, "matrix: ensure_hive_chat_room failed"); - } - } } #[cfg(test)] diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 3ffcf974..7cc0a2ed 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -81,13 +81,6 @@ pub fn matrix_space_room_id() -> PathBuf { matrix_dir().join("space-room-id") } -/// `matrix/chat-room-id` — persisted default "hive chat" room id (the -/// `m.space.child` of the hive Space every agent + the operator can join). -#[must_use] -pub fn matrix_chat_room_id() -> PathBuf { - matrix_dir().join("chat-room-id") -} - /// `matrix/creds/` — per-agent throwaway matrix passwords (survive /// `destroy --purge`; agents auth by token, this is recovery only). #[must_use]