huskies: merge 558_story_matrix_bot_can_run_on_the_gateway_to_manage_multiple_projects_from_one_chat
This commit is contained in:
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::sync::{RwLock, mpsc, oneshot};
|
||||
|
||||
use super::history::ConversationHistory;
|
||||
|
||||
@@ -59,6 +59,12 @@ pub struct BotContext {
|
||||
pub transport: Arc<dyn ChatTransport>,
|
||||
/// Persistent store for pending deferred-start timers.
|
||||
pub timer_store: Arc<TimerStore>,
|
||||
/// In gateway mode: the currently active project (shared with the gateway HTTP handler).
|
||||
/// `None` in standalone single-project mode.
|
||||
pub gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
/// In gateway mode: valid project names accepted by the `switch` command.
|
||||
/// Empty in standalone mode.
|
||||
pub gateway_projects: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,6 +116,8 @@ mod tests {
|
||||
timer_store: Arc::new(crate::chat::timer::TimerStore::load(
|
||||
std::path::PathBuf::from("/tmp/timers.json"),
|
||||
)),
|
||||
gateway_active_project: None,
|
||||
gateway_projects: vec![],
|
||||
};
|
||||
// Clone must work (required by Matrix SDK event handler injection).
|
||||
let _cloned = ctx.clone();
|
||||
|
||||
@@ -450,6 +450,47 @@ pub(super) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, handle the "switch <project>" command to change the
|
||||
// active project without invoking the LLM.
|
||||
if let Some(ref active_project) = ctx.gateway_active_project {
|
||||
let stripped = crate::chat::util::strip_bot_mention(
|
||||
&user_message,
|
||||
&ctx.bot_name,
|
||||
ctx.bot_user_id.as_str(),
|
||||
)
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric())
|
||||
.to_string();
|
||||
|
||||
let (cmd, arg) = match stripped.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c.to_string(), a.trim().to_string()),
|
||||
None => (stripped.clone(), String::new()),
|
||||
};
|
||||
|
||||
if cmd.eq_ignore_ascii_case("switch") {
|
||||
let response = if arg.is_empty() {
|
||||
let available = ctx.gateway_projects.join(", ");
|
||||
format!("Usage: `switch <project>`. Available projects: {available}")
|
||||
} else if ctx.gateway_projects.iter().any(|p| p == &arg) {
|
||||
*active_project.write().await = arg.clone();
|
||||
format!("Switched to project **{arg}**.")
|
||||
} else {
|
||||
let available = ctx.gateway_projects.join(", ");
|
||||
format!("Unknown project `{arg}`. Available: {available}")
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for the timer command, which requires async file I/O and cannot
|
||||
// be handled by the sync command registry.
|
||||
if let Some(timer_cmd) = crate::chat::timer::extract_timer_command(
|
||||
@@ -501,8 +542,14 @@ pub(super) async fn handle_message(
|
||||
// The prompt is just the current message with sender attribution.
|
||||
// Prior conversation context is carried by the Claude Code session.
|
||||
let bot_name = &ctx.bot_name;
|
||||
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
|
||||
let name = ap.read().await.clone();
|
||||
format!("[Active project: {name}]\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let prompt = format!(
|
||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{}",
|
||||
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n{active_project_ctx}\n{}",
|
||||
format_user_prompt(&sender, &user_message)
|
||||
);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::sync::{RwLock, mpsc, watch};
|
||||
|
||||
use super::context::BotContext;
|
||||
use super::format::{format_startup_announcement, markdown_to_html};
|
||||
@@ -19,6 +19,7 @@ use super::verification::{on_room_verification_request, on_to_device_verificatio
|
||||
/// Connect to the Matrix homeserver, join all configured rooms, and start
|
||||
/// listening for messages. Runs the full Matrix sync loop — call from a
|
||||
/// `tokio::spawn` task so it doesn't block the main thread.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_bot(
|
||||
config: super::super::config::BotConfig,
|
||||
project_root: PathBuf,
|
||||
@@ -27,6 +28,8 @@ pub async fn run_bot(
|
||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
|
||||
agents: Arc<AgentPool>,
|
||||
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||
let client = Client::builder()
|
||||
@@ -242,6 +245,8 @@ pub async fn run_bot(
|
||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||
transport: Arc::clone(&transport),
|
||||
timer_store,
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
};
|
||||
|
||||
slog!(
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::io::watcher::WatcherEvent;
|
||||
use crate::rebuild::ShutdownReason;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc, watch};
|
||||
use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
|
||||
|
||||
/// Attempt to start the Matrix bot.
|
||||
///
|
||||
@@ -64,6 +64,8 @@ pub fn spawn_bot(
|
||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
agents: Arc<AgentPool>,
|
||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||
gateway_active_project: Option<Arc<RwLock<String>>>,
|
||||
gateway_projects: Vec<String>,
|
||||
) {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
@@ -100,6 +102,8 @@ pub fn spawn_bot(
|
||||
perm_rx,
|
||||
agents,
|
||||
shutdown_rx,
|
||||
gateway_active_project,
|
||||
gateway_projects,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -19,6 +19,9 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// Re-export active_project type alias for clarity in gateway bot helpers.
|
||||
type ActiveProject = Arc<RwLock<String>>;
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A single project entry in `projects.toml`.
|
||||
@@ -563,6 +566,27 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
// Locate the gateway config directory (parent of `projects.toml`).
|
||||
let config_dir = config_path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.to_path_buf();
|
||||
|
||||
// Write `.mcp.json` so that the gateway's Matrix bot's Claude Code CLI
|
||||
// connects to this gateway's MCP endpoint (which proxies to the active project).
|
||||
if let Err(e) = write_gateway_mcp_json(&config_dir, port) {
|
||||
crate::slog!("[gateway] Warning: could not write .mcp.json: {e}");
|
||||
}
|
||||
|
||||
// Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory.
|
||||
let gateway_projects: Vec<String> = state_arc.config.projects.keys().cloned().collect();
|
||||
spawn_gateway_bot(
|
||||
&config_dir,
|
||||
Arc::clone(&state_arc.active_project),
|
||||
gateway_projects,
|
||||
port,
|
||||
);
|
||||
|
||||
let route = poem::Route::new()
|
||||
.at(
|
||||
"/mcp",
|
||||
@@ -580,6 +604,72 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Matrix bot integration ───────────────────────────────────────────
|
||||
|
||||
/// Write (or overwrite) a `.mcp.json` in `config_dir` that points Claude Code
|
||||
/// CLI at the gateway's own `/mcp` endpoint. This lets the gateway's Matrix
|
||||
/// bot use gateway-proxied tool calls instead of a project-specific server.
|
||||
fn write_gateway_mcp_json(config_dir: &Path, port: u16) -> Result<(), std::io::Error> {
|
||||
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let url = format!("http://{host}:{port}/mcp");
|
||||
let content = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"huskies": {
|
||||
"type": "http",
|
||||
"url": url
|
||||
}
|
||||
}
|
||||
});
|
||||
let path = config_dir.join(".mcp.json");
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&content).unwrap())?;
|
||||
crate::slog!("[gateway] Wrote {} pointing to {}", path.display(), url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt to spawn the Matrix bot against the gateway config directory.
|
||||
///
|
||||
/// Reads `<config_dir>/.huskies/bot.toml`. If absent or disabled the function
|
||||
/// returns immediately without spawning anything. When the bot is enabled it
|
||||
/// receives a shared reference to the gateway's active-project `RwLock` so the
|
||||
/// `switch` command can change the active project without going through HTTP.
|
||||
fn spawn_gateway_bot(
|
||||
config_dir: &Path,
|
||||
active_project: ActiveProject,
|
||||
gateway_projects: Vec<String>,
|
||||
port: u16,
|
||||
) {
|
||||
use crate::agents::AgentPool;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
// Create a watcher broadcast channel (no file-system watcher in gateway mode).
|
||||
let (watcher_tx, _) = broadcast::channel(16);
|
||||
|
||||
// Create a dummy permission channel — permission prompts are not forwarded
|
||||
// across the proxy boundary in this initial implementation.
|
||||
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
||||
|
||||
// Create a shutdown watch channel. Gateway process exit signals Ctrl-C
|
||||
// via OS signal, not through a watch channel, so we leave this at None
|
||||
// (no shutdown announcement). The sender is kept alive for the duration.
|
||||
let (shutdown_tx, shutdown_rx) =
|
||||
tokio::sync::watch::channel::<Option<crate::rebuild::ShutdownReason>>(None);
|
||||
// Keep sender alive so the receiver is never prematurely closed.
|
||||
std::mem::forget(shutdown_tx);
|
||||
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
|
||||
crate::chat::transport::matrix::spawn_bot(
|
||||
config_dir,
|
||||
watcher_tx,
|
||||
perm_rx,
|
||||
agents,
|
||||
shutdown_rx,
|
||||
Some(active_project),
|
||||
gateway_projects,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -747,4 +837,83 @@ url = "http://localhost:9999"
|
||||
let result = GatewayConfig::load(Path::new("/nonexistent/projects.toml"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── bot.toml in gateway and standalone modes ─────────────────────────
|
||||
//
|
||||
// Both gateway and standalone modes load bot.toml via `BotConfig::load(dir)`
|
||||
// which looks for `dir/.huskies/bot.toml`. These tests document that the
|
||||
// same loading convention works from a gateway config directory.
|
||||
|
||||
#[test]
|
||||
fn bot_config_loads_from_gateway_config_dir() {
|
||||
use crate::chat::transport::matrix::BotConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
std::fs::write(
|
||||
huskies_dir.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Gateway passes config_dir (parent of projects.toml) to spawn_bot,
|
||||
// which calls BotConfig::load(config_dir). Verify this resolves correctly.
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(
|
||||
config.is_some(),
|
||||
"bot.toml should load from gateway config dir"
|
||||
);
|
||||
let config = config.unwrap();
|
||||
assert_eq!(
|
||||
config.homeserver.as_deref(),
|
||||
Some("https://matrix.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_config_absent_returns_none_in_gateway_mode() {
|
||||
use crate::chat::transport::matrix::BotConfig;
|
||||
|
||||
// A gateway config directory without a .huskies/bot.toml should yield None,
|
||||
// allowing the gateway to start without a Matrix bot.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(
|
||||
config.is_none(),
|
||||
"absent bot.toml must return None in gateway mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_config_disabled_returns_none_in_gateway_mode() {
|
||||
use crate::chat::transport::matrix::BotConfig;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
std::fs::write(
|
||||
huskies_dir.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(
|
||||
config.is_none(),
|
||||
"disabled bot.toml must return None in gateway mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +824,8 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
perm_rx_for_bot,
|
||||
Arc::clone(&startup_agents),
|
||||
matrix_shutdown_rx,
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
} else {
|
||||
// Keep the receiver alive (drop it) so the sender never errors.
|
||||
|
||||
Reference in New Issue
Block a user