diff --git a/server/src/chat/transport/matrix/bot/context.rs b/server/src/chat/transport/matrix/bot/context.rs index fa56e6f8..b3fc6b4c 100644 --- a/server/src/chat/transport/matrix/bot/context.rs +++ b/server/src/chat/transport/matrix/bot/context.rs @@ -147,11 +147,16 @@ impl BotContext { pub async fn active_project_url(&self) -> Option { let ap = self.gateway_active_project.as_ref()?; let name = ap.read().await.clone(); + self.project_url(&name).await + } + + /// Return the base URL for a named project from the live gateway store. + pub async fn project_url(&self, name: &str) -> Option { let store = self.gateway_projects_store.as_ref()?; store .read() .await - .get(&name) + .get(name) .and_then(|entry| entry.url.clone()) } @@ -162,18 +167,28 @@ impl BotContext { /// `rpc_response` frame. Returns an error message string if the /// connection or command fails. pub async fn proxy_bot_command(&self, command: &str, args: &str) -> Option { + let base_url = self.active_project_url().await?; + Some(Self::run_proxy_bot_command(&base_url, command, args).await) + } + + /// Run the `bot.command` WebSocket RPC call against `base_url` and return + /// the Markdown response, or an error message string on failure. + /// + /// `pub(crate)` so callers that need to target a project by name (rather + /// than always the active one, as [`Self::proxy_bot_command`] does — e.g. + /// the `status ` command) can resolve their own URL and reuse + /// this transport logic. + pub(crate) async fn run_proxy_bot_command(base_url: &str, command: &str, args: &str) -> String { use futures::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message as WsMsg; - let base_url = self.active_project_url().await?; - // Convert http(s):// → ws(s):// let ws_base = if let Some(rest) = base_url.strip_prefix("https://") { format!("wss://{rest}") } else if let Some(rest) = base_url.strip_prefix("http://") { format!("ws://{rest}") } else { - base_url.clone() + base_url.to_string() }; let ws_url = format!("{ws_base}/ws"); @@ -188,7 +203,7 @@ impl BotContext { }); let request_text = match serde_json::to_string(&request) { Ok(t) => t, - Err(e) => return Some(format!("Failed to serialize RPC request: {e}")), + Err(e) => return format!("Failed to serialize RPC request: {e}"), }; let connect_timeout = std::time::Duration::from_secs(5); @@ -200,21 +215,19 @@ impl BotContext { { Ok(Ok((stream, _))) => stream, Ok(Err(e)) => { - return Some(format!( - "Failed to connect to project server at {ws_url}: {e}" - )); + return format!("Failed to connect to project server at {ws_url}: {e}"); } Err(_) => { - return Some(format!( + return format!( "Project server at {ws_url} is unreachable (connect timed out after {connect_timeout:?})" - )); + ); } }; let (mut sink, mut stream) = ws_stream.split(); if let Err(e) = sink.send(WsMsg::Text(request_text.into())).await { - return Some(format!("Failed to send RPC request: {e}")); + return format!("Failed to send RPC request: {e}"); } let response_timeout = std::time::Duration::from_secs(30); @@ -243,24 +256,24 @@ impl BotContext { .and_then(|r| r.get("response")) .and_then(|v| v.as_str()) .map(String::from) - .or_else(|| { - Some("Command succeeded with no response text".to_string()) + .unwrap_or_else(|| { + "Command succeeded with no response text".to_string() }); } else { let err = frame .get("error") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); - return Some(format!("Project server command failed: {err}")); + return format!("Project server command failed: {err}"); } } Ok(WsMsg::Close(_)) => break, - Err(e) => return Some(format!("WebSocket error: {e}")), + Err(e) => return format!("WebSocket error: {e}"), _ => continue, } } - Some("Project server did not respond in time (connection closed or timed out)".to_string()) + "Project server did not respond in time (connection closed or timed out)".to_string() } } diff --git a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs index c2476cc7..2105eead 100644 --- a/server/src/chat/transport/matrix/bot/messages/on_room_message.rs +++ b/server/src/chat/transport/matrix/bot/messages/on_room_message.rs @@ -68,6 +68,50 @@ fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: & .unwrap_or(false) } +/// Evaluate a `status ` command in gateway mode, routing it to the +/// right project before falling back to the generic per-command proxy. +/// +/// - Empty arg: proxies bare `status` to the active project (pipeline status). +/// - All-digit arg: proxies `status ` to the active project — stories +/// live within whichever project is currently active, not by name. +/// - Arg matching a known project name: proxies bare `status` to *that* +/// project rather than whichever one is active. +/// - Anything else: a clear error listing the valid project names. +async fn eval_gateway_status_command( + arg: &str, + active_project: &tokio::sync::RwLock, + store: &tokio::sync::RwLock< + std::collections::BTreeMap, + >, +) -> String { + const NO_ACTIVE_PROJECT: &str = "No active project selected or project URL not configured."; + + if arg.is_empty() || arg.chars().all(|c| c.is_ascii_digit()) { + let name = active_project.read().await.clone(); + let url = store.read().await.get(&name).and_then(|e| e.url.clone()); + return match url { + Some(url) => { + super::super::context::BotContext::run_proxy_bot_command(&url, "status", arg).await + } + None => NO_ACTIVE_PROJECT.to_string(), + }; + } + + let known: Vec = store.read().await.keys().cloned().collect(); + if known.iter().any(|p| p == arg) { + let url = store.read().await.get(arg).and_then(|e| e.url.clone()); + return match url { + Some(url) => { + super::super::context::BotContext::run_proxy_bot_command(&url, "status", "").await + } + None => format!("Project `{arg}` has no URL configured."), + }; + } + + let available = known.join(", "); + format!("Unknown project or story number `{arg}`. Available projects: {available}") +} + /// Evaluate a `switch ` command against the live project store. /// /// Reads valid project names from the store at call time so newly added @@ -304,6 +348,29 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( None => (stripped.to_ascii_lowercase(), String::new()), }; + // `status ` is special-cased ahead of the generic per-command proxy + // below so `` can resolve to a *named* project rather than always + // being forwarded to whichever project is currently active. + if cmd == "status" { + slog!("[matrix-bot] Handling 'status {args}' from {sender}"); + let response = match (&ctx.gateway_active_project, &ctx.gateway_projects_store) { + (Some(active_project), Some(store)) => { + eval_gateway_status_command(&args, active_project, store).await + } + _ => "Gateway projects store unavailable.".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); + } + return; + } + // Only proxy if the first word is a known bot command (sync or async). let is_known_command = !cmd.is_empty() && !GATEWAY_LOCAL_COMMANDS.contains(&cmd.as_str()) @@ -1188,7 +1255,7 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message( #[cfg(test)] mod tests { - use super::eval_switch_command; + use super::{eval_gateway_status_command, eval_switch_command}; use crate::service::gateway::config::ProjectEntry; use std::collections::BTreeMap; use tokio::sync::RwLock; @@ -1259,4 +1326,82 @@ mod tests { "usage should list available projects: {resp}" ); } + + /// Build a one-project store entry with no URL, to exercise the + /// no-network-call branches of `eval_gateway_status_command`. + fn project_without_url() -> ProjectEntry { + ProjectEntry { + url: None, + auth_token: None, + ssh_port: None, + host_path: None, + expected_node_id: None, + } + } + + #[tokio::test] + async fn status_empty_arg_targets_active_project() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([( + "huskies".to_string(), + project_without_url(), + )])); + + let resp = eval_gateway_status_command("", &active, &store).await; + assert!( + resp.contains("No active project selected"), + "should report no URL configured for the active project: {resp}" + ); + } + + #[tokio::test] + async fn status_numeric_arg_targets_active_project_not_by_name() { + // A project literally named "42" must not shadow story-number routing: + // digits always mean "look up this story on the active project". + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([ + ("huskies".to_string(), project_without_url()), + ("42".to_string(), project_without_url()), + ])); + + let resp = eval_gateway_status_command("42", &active, &store).await; + assert!( + resp.contains("No active project selected"), + "numeric arg should resolve via the active project, not the '42' project: {resp}" + ); + } + + #[tokio::test] + async fn status_known_project_name_targets_that_project() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([ + ("huskies".to_string(), project_without_url()), + ("robot-studio".to_string(), project_without_url()), + ])); + + let resp = eval_gateway_status_command("robot-studio", &active, &store).await; + assert!( + resp.contains("robot-studio") && resp.contains("no URL configured"), + "should target the named project, not the active one: {resp}" + ); + } + + #[tokio::test] + async fn status_unrecognized_arg_lists_valid_projects() { + let active = RwLock::new("huskies".to_string()); + let store: RwLock> = RwLock::new(BTreeMap::from([ + ("huskies".to_string(), project_without_url()), + ("robot-studio".to_string(), project_without_url()), + ])); + + let resp = eval_gateway_status_command("not-a-project", &active, &store).await; + assert!( + resp.contains("Unknown project or story number"), + "should give a clear unrecognized-argument error: {resp}" + ); + assert!( + resp.contains("huskies") && resp.contains("robot-studio"), + "error should list valid projects: {resp}" + ); + } }