feat: multi-account matrix daemon (account-routed mcp surface)
This commit is contained in:
parent
73c53c7d4a
commit
8e79eb4f26
7 changed files with 613 additions and 156 deletions
141
hive-matrix-mcp/src/accounts.rs
Normal file
141
hive-matrix-mcp/src/accounts.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
//! Multi-account configuration + the dispatch registry.
|
||||
//!
|
||||
//! A single `hive-matrix-daemon` can serve N matrix accounts (one
|
||||
//! matrix-sdk `Client` each, with its own session/store dir + sync
|
||||
//! loop). The account list comes from the `HIVE_MATRIX_ACCOUNTS` env
|
||||
//! var (JSON, written by the nix harness module from
|
||||
//! `hyperhive.matrixAccounts`); when that var is absent the daemon
|
||||
//! synthesizes the single legacy account from the existing
|
||||
//! `HIVE_MATRIX_*` env so every current single-account agent keeps
|
||||
//! working with zero config change.
|
||||
//!
|
||||
//! The FIRST configured account is the **primary**: it is selected when
|
||||
//! a tool call omits `account`, so single-account callers never specify
|
||||
//! one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use matrix_sdk::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// One declared matrix account. `homeserver` is optional per account
|
||||
/// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the
|
||||
/// same homeserver need not repeat it.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AccountCfg {
|
||||
/// Logical name the agent uses to address this account
|
||||
/// (`account` arg on the MCP tools). Unique within the daemon.
|
||||
pub name: String,
|
||||
/// Path to the bearer-token file for this account (hive-c0re
|
||||
/// writes it; the daemon reads it).
|
||||
pub token_file: PathBuf,
|
||||
/// Per-account matrix-sdk sqlite store dir (crypto keys + cache).
|
||||
pub state_dir: PathBuf,
|
||||
/// Homeserver URL; falls back to [`paths::homeserver_url`] when absent.
|
||||
#[serde(default)]
|
||||
pub homeserver: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountCfg {
|
||||
/// Resolve the effective homeserver URL (per-account override or
|
||||
/// the daemon-wide default).
|
||||
#[must_use]
|
||||
pub fn homeserver(&self) -> String {
|
||||
self.homeserver
|
||||
.clone()
|
||||
.unwrap_or_else(paths::homeserver_url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the configured account list. Parses `HIVE_MATRIX_ACCOUNTS`
|
||||
/// (JSON array) when set; otherwise returns the single legacy account
|
||||
/// built from `HIVE_MATRIX_*` / `HYPERHIVE_STATE_DIR`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid
|
||||
/// JSON, is empty, or contains duplicate account names.
|
||||
pub fn configured() -> anyhow::Result<Vec<AccountCfg>> {
|
||||
let Some(raw) = std::env::var_os("HIVE_MATRIX_ACCOUNTS") else {
|
||||
// Legacy single-account fallback — the only deployed shape until
|
||||
// the nix `matrixAccounts` option lands.
|
||||
return Ok(vec![AccountCfg {
|
||||
name: "default".to_owned(),
|
||||
token_file: paths::token_file(),
|
||||
state_dir: paths::matrix_state_dir(),
|
||||
homeserver: None,
|
||||
}]);
|
||||
};
|
||||
let raw = raw.to_string_lossy();
|
||||
let accounts: Vec<AccountCfg> = serde_json::from_str(&raw)
|
||||
.map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?;
|
||||
if accounts.is_empty() {
|
||||
anyhow::bail!("HIVE_MATRIX_ACCOUNTS is an empty array — declare at least one account");
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for a in &accounts {
|
||||
if !seen.insert(a.name.as_str()) {
|
||||
anyhow::bail!(
|
||||
"duplicate matrix account name {:?} in HIVE_MATRIX_ACCOUNTS",
|
||||
a.name
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
/// Account name → live `Client` map plus the primary-account name used
|
||||
/// when a request omits `account`. Built once at daemon startup from
|
||||
/// the accounts that successfully restored a session.
|
||||
pub struct Registry {
|
||||
primary: String,
|
||||
by_name: HashMap<String, Arc<Client>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Build an empty registry whose primary is `primary`. Clients are
|
||||
/// added with [`Registry::insert`] as each account restores.
|
||||
#[must_use]
|
||||
pub fn new(primary: String) -> Self {
|
||||
Self {
|
||||
primary,
|
||||
by_name: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a restored client under `name`.
|
||||
pub fn insert(&mut self, name: String, client: Client) {
|
||||
self.by_name.insert(name, Arc::new(client));
|
||||
}
|
||||
|
||||
/// Whether any account restored successfully.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_name.is_empty()
|
||||
}
|
||||
|
||||
/// Resolve a request's `account` to a client. `None` → the primary
|
||||
/// account. Returns a human-readable error (listing known accounts)
|
||||
/// when the name is unknown — surfaced to the agent as a tool error.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Errors when the named account (or the primary, if `None`) has no
|
||||
/// live client — e.g. an unknown name, or the primary failed to
|
||||
/// restore at startup.
|
||||
pub fn resolve(&self, account: Option<&str>) -> Result<&Arc<Client>, String> {
|
||||
let name = account.unwrap_or(&self.primary);
|
||||
self.by_name.get(name).ok_or_else(|| {
|
||||
let mut known: Vec<&str> = self.by_name.keys().map(String::as_str).collect();
|
||||
known.sort_unstable();
|
||||
format!(
|
||||
"unknown matrix account {name:?}; available accounts: [{}]",
|
||||
known.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue