From fe17b5f8a7e4dba9cd0a48c8762db337c7a5019f Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 6 Jun 2026 07:51:43 +0200 Subject: [PATCH] feat(matrix): auto-create a hive chat room as a child of the hive Space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hive Space was created empty — joining it surfaced no rooms because Matrix doesn't auto-join a Space's children. Provision a default "hive-chat" room on the matrix sweep, wire it bidirectionally to the Space (m.space.child on the Space, m.space.parent on the room), and invite @hive + every agent. The room uses a restricted join rule allowing any Space member to join, so the operator (a Space member) can join it from the Space hierarchy without an explicit invite. Idempotent, mirroring ensure_hive_space: persisted chat-room-id wins, else rediscover a non-space room named hive-chat, else createRoom. The space-child link is re-applied each sweep (idempotent PUT) so a recovered room reconverges its hierarchy link. Room id persisted to matrix/chat-room-id (0600, survives destroy --purge). --- hive-c0re/src/matrix.rs | 264 ++++++++++++++++++++++++++++++++++++++++ hive-c0re/src/paths.rs | 7 ++ 2 files changed, 271 insertions(+) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 193b9d47..d76d6480 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -51,6 +51,16 @@ 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] @@ -95,6 +105,13 @@ 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 @@ -1100,6 +1117,222 @@ 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, + "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( @@ -1313,6 +1546,37 @@ 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 7cc0a2ed..3ffcf974 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -81,6 +81,13 @@ 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]