feat(matrix): auto-create a hive chat room as a child of the hive Space

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).
This commit is contained in:
atlas 2026-06-06 07:51:43 +02:00 committed by mara
commit fe17b5f8a7
2 changed files with 271 additions and 0 deletions

View file

@ -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<String> {
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::<serde_json::Value>()
.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::<serde_json::Value>()
.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::<serde_json::Value>().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<String> {
// 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::<serde_json::Value>()
.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)]

View file

@ -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]