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.
|
/// being created on the next sweep.
|
||||||
pub const HIVE_SPACE_NAME: &str = "hive";
|
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
|
/// Host path for the hive admin matrix access token. Outside every
|
||||||
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -95,6 +105,13 @@ pub fn hive_space_room_id_path() -> PathBuf {
|
||||||
crate::paths::matrix_space_room_id()
|
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 —
|
/// Probe whether `hive-matrix` exists as a nixos-container. Cheap —
|
||||||
/// `nixos-container list` is just a directory scan in /etc. Same shape
|
/// `nixos-container list` is just a directory scan in /etc. Same shape
|
||||||
/// as `forge::is_present` — routed through hive-priv since
|
/// 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)
|
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
|
/// Invite `@{localpart}:{server_name}` to `room_id` using the admin
|
||||||
/// account. Idempotent — treats already-member responses as success.
|
/// account. Idempotent — treats already-member responses as success.
|
||||||
async fn invite_to_room(
|
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");
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,13 @@ pub fn matrix_space_room_id() -> PathBuf {
|
||||||
matrix_dir().join("space-room-id")
|
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
|
/// `matrix/creds/` — per-agent throwaway matrix passwords (survive
|
||||||
/// `destroy --purge`; agents auth by token, this is recovery only).
|
/// `destroy --purge`; agents auth by token, this is recovery only).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue