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:
parent
6e39515669
commit
fe17b5f8a7
2 changed files with 271 additions and 0 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue