diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index e8d862e5..b964a864 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -4,7 +4,7 @@ use crate::chat::ChatTransport; use crate::chat::timer::TimerStore; use crate::http::context::{PermissionDecision, PermissionForward}; use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; @@ -65,6 +65,10 @@ pub struct BotContext { /// In gateway mode: valid project names accepted by the `switch` command. /// Empty in standalone mode. pub gateway_projects: Vec, + /// In gateway mode: mapping of project name → base URL (e.g. `"http://localhost:3001"`). + /// Used to proxy bot commands to the active project's `/api/bot/command` endpoint. + /// Empty in standalone mode. + pub gateway_project_urls: BTreeMap, } impl BotContext { @@ -82,6 +86,46 @@ impl BotContext { self.project_root.clone() } } + + /// Returns `true` if the bot is running in gateway mode. + pub fn is_gateway(&self) -> bool { + self.gateway_active_project.is_some() + } + + /// Return the base URL for the currently active project, if in gateway mode. + pub async fn active_project_url(&self) -> Option { + let ap = self.gateway_active_project.as_ref()?; + let name = ap.read().await.clone(); + self.gateway_project_urls.get(&name).cloned() + } + + /// Proxy a bot command to the active project's `/api/bot/command` endpoint. + /// + /// Returns the Markdown response from the project server, or an error + /// message if the request failed. + pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option { + let base_url = self.active_project_url().await?; + let url = format!("{base_url}/api/bot/command"); + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "command": command, + "args": args, + }); + match client.post(&url).json(&body).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(json) => json.get("response").and_then(|v| v.as_str()).map(String::from), + Err(e) => Some(format!("Failed to parse response from project server: {e}")), + } + } + Ok(resp) => Some(format!( + "Project server returned HTTP {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + )), + Err(e) => Some(format!("Failed to reach project server at {url}: {e}")), + } + } } // --------------------------------------------------------------------------- @@ -135,6 +179,7 @@ mod tests { )), gateway_active_project: None, gateway_projects: vec![], + gateway_project_urls: BTreeMap::new(), }; assert_eq!( ctx.effective_project_root().await, @@ -172,6 +217,10 @@ mod tests { )), gateway_active_project: Some(Arc::clone(&active)), gateway_projects: vec!["huskies".into(), "robot-studio".into()], + gateway_project_urls: BTreeMap::from([ + ("huskies".into(), "http://localhost:3001".into()), + ("robot-studio".into(), "http://localhost:3002".into()), + ]), }; assert_eq!( ctx.effective_project_root().await, @@ -209,6 +258,10 @@ mod tests { )), gateway_active_project: Some(Arc::clone(&active)), gateway_projects: vec!["huskies".into(), "robot-studio".into()], + gateway_project_urls: BTreeMap::from([ + ("huskies".into(), "http://localhost:3001".into()), + ("robot-studio".into(), "http://localhost:3002".into()), + ]), }; assert_eq!( @@ -255,6 +308,7 @@ mod tests { )), gateway_active_project: None, gateway_projects: vec![], + gateway_project_urls: BTreeMap::new(), }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); diff --git a/server/src/chat/transport/matrix/bot/messages.rs b/server/src/chat/transport/matrix/bot/messages.rs index e7ef18cd..26dc17d3 100644 --- a/server/src/chat/transport/matrix/bot/messages.rs +++ b/server/src/chat/transport/matrix/bot/messages.rs @@ -179,6 +179,57 @@ pub(super) async fn on_room_message( // a subdirectory named after the project. Standalone mode is unaffected. let effective_root = ctx.effective_project_root().await; + // ── Gateway command proxy ─────────────────────────────────────────── + // In gateway mode the bot has no local CRDT or project filesystem, so most + // commands must be forwarded to the active project's `/api/bot/command` + // endpoint. Only a small set of gateway-local commands are handled here. + if ctx.is_gateway() { + // Commands that are meaningful on the gateway itself (no project state needed). + const GATEWAY_LOCAL_COMMANDS: &[&str] = &["help", "ambient", "reset", "switch"]; + + 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, args) = match stripped.split_once(char::is_whitespace) { + Some((c, a)) => (c.to_ascii_lowercase(), a.trim().to_string()), + None => (stripped.to_ascii_lowercase(), String::new()), + }; + + if !cmd.is_empty() && !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str()) { + // Proxy to the active project server. + let response = match ctx.proxy_bot_command(&cmd, &args).await { + Some(r) => r, + None => "No active project selected or project URL not configured.".to_string(), + }; + 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); + } + // If the command was recognized by the project server, we're done. + // If it was not a command at all (freeform text), fall through to the LLM. + if crate::chat::commands::commands() + .iter() + .any(|c| c.name == cmd) + || ["assign", "start", "delete", "rebuild", "rmtree", "htop", "timer"] + .contains(&cmd.as_str()) + { + return; + } + } + // Gateway-local commands and freeform text fall through to normal handling below. + } + // Check for bot-level commands (help, status, ambient, …) before invoking // the LLM. All commands are registered in commands.rs — no special-casing // needed here. diff --git a/server/src/chat/transport/matrix/bot/run.rs b/server/src/chat/transport/matrix/bot/run.rs index 254197bb..d69f9ae7 100644 --- a/server/src/chat/transport/matrix/bot/run.rs +++ b/server/src/chat/transport/matrix/bot/run.rs @@ -30,6 +30,7 @@ pub async fn run_bot( shutdown_rx: watch::Receiver>, gateway_active_project: Option>>, gateway_projects: Vec, + gateway_project_urls: std::collections::BTreeMap, ) -> Result<(), String> { let store_path = project_root.join(".huskies").join("matrix_store"); let client = Client::builder() @@ -247,6 +248,7 @@ pub async fn run_bot( timer_store, gateway_active_project, gateway_projects, + gateway_project_urls, }; slog!( diff --git a/server/src/chat/transport/matrix/mod.rs b/server/src/chat/transport/matrix/mod.rs index b9fd18fd..f12af56a 100644 --- a/server/src/chat/transport/matrix/mod.rs +++ b/server/src/chat/transport/matrix/mod.rs @@ -62,6 +62,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch}; /// Returns an [`tokio::task::AbortHandle`] if the bot was actually spawned (Matrix/Discord /// transports), or `None` if the config is absent, disabled, or uses a webhook-based /// transport (Slack/WhatsApp) that does not require a persistent background task. +#[allow(clippy::too_many_arguments)] pub fn spawn_bot( project_root: &Path, watcher_tx: broadcast::Sender, @@ -70,6 +71,7 @@ pub fn spawn_bot( shutdown_rx: watch::Receiver>, gateway_active_project: Option>>, gateway_projects: Vec, + gateway_project_urls: std::collections::BTreeMap, ) -> Option { let config = match BotConfig::load(project_root) { Some(c) => c, @@ -108,6 +110,7 @@ pub fn spawn_bot( shutdown_rx, gateway_active_project, gateway_projects, + gateway_project_urls, ) .await { diff --git a/server/src/gateway.rs b/server/src/gateway.rs index 3f66872c..f98ceb6d 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -1410,10 +1410,18 @@ pub async fn gateway_bot_config_save_handler( h.abort(); } let gateway_projects: Vec = state.projects.read().await.keys().cloned().collect(); + let gateway_project_urls: std::collections::BTreeMap = state + .projects + .read() + .await + .iter() + .map(|(name, entry)| (name.clone(), entry.url.clone())) + .collect(); let new_handle = spawn_gateway_bot( &state.config_dir, Arc::clone(&state.active_project), gateway_projects, + gateway_project_urls, state.port, ); *handle = new_handle; @@ -1738,10 +1746,18 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> { // Spawn the Matrix bot if `.huskies/bot.toml` exists in the config directory. let gateway_projects: Vec = state_arc.projects.read().await.keys().cloned().collect(); + let gateway_project_urls: std::collections::BTreeMap = state_arc + .projects + .read() + .await + .iter() + .map(|(name, entry)| (name.clone(), entry.url.clone())) + .collect(); let bot_abort = spawn_gateway_bot( &config_dir, Arc::clone(&state_arc.active_project), gateway_projects, + gateway_project_urls, port, ); *state_arc.bot_handle.lock().await = bot_abort; @@ -1791,6 +1807,7 @@ fn spawn_gateway_bot( config_dir: &Path, active_project: ActiveProject, gateway_projects: Vec, + gateway_project_urls: std::collections::BTreeMap, port: u16, ) -> Option { use crate::agents::AgentPool; @@ -1822,6 +1839,7 @@ fn spawn_gateway_bot( shutdown_rx, Some(active_project), gateway_projects, + gateway_project_urls, ) } diff --git a/server/src/main.rs b/server/src/main.rs index 59c20534..f92e2e2e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -868,6 +868,7 @@ async fn main() -> Result<(), std::io::Error> { matrix_shutdown_rx, None, vec![], + std::collections::BTreeMap::new(), ); } else { // Keep the receiver alive (drop it) so the sender never errors.