//! Bot command HTTP endpoint. //! //! `POST /api/bot/command` lets the web UI invoke the same deterministic bot //! commands available in Matrix without going through the LLM. //! //! Synchronous commands (status, assign, git, cost, move, show, overview, //! help) are dispatched directly through the matrix command registry. //! Asynchronous commands (start, delete, rebuild) are dispatched to their //! dedicated async handlers. The `reset` command is handled by the frontend //! (it clears local session state and message history) and is not routed here. use crate::http::context::{AppContext, OpenApiResult}; use crate::matrix::commands::CommandDispatch; use poem::http::StatusCode; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; #[derive(Tags)] enum BotCommandTags { BotCommand, } /// Body for `POST /api/bot/command`. #[derive(Object, Deserialize)] struct BotCommandRequest { /// The command keyword without the leading slash (e.g. `"status"`, `"start"`). command: String, /// Any text after the command keyword, trimmed (may be empty). #[oai(default)] args: String, } /// Response body for `POST /api/bot/command`. #[derive(Object, Serialize)] struct BotCommandResponse { /// Markdown-formatted response text. response: String, } pub struct BotCommandApi { pub ctx: Arc, } #[OpenApi(tag = "BotCommandTags::BotCommand")] impl BotCommandApi { /// Execute a slash command without LLM invocation. /// /// Dispatches to the same handlers used by the Matrix and Slack bots. /// Returns a markdown-formatted response that the frontend can display /// directly in the chat panel. #[oai(path = "/bot/command", method = "post")] async fn run_command( &self, body: Json, ) -> OpenApiResult> { let project_root = self.ctx.state.get_project_root().map_err(|e| { poem::Error::from_string(e, StatusCode::BAD_REQUEST) })?; let cmd = body.command.trim().to_ascii_lowercase(); let args = body.args.trim(); let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await; Ok(Json(BotCommandResponse { response })) } } /// Dispatch a command keyword + args to the appropriate handler. async fn dispatch_command( cmd: &str, args: &str, project_root: &std::path::Path, agents: &Arc, ) -> String { match cmd { "start" => dispatch_start(args, project_root, agents).await, "delete" => dispatch_delete(args, project_root, agents).await, "rebuild" => dispatch_rebuild(project_root, agents).await, // All other commands go through the synchronous command registry. _ => dispatch_sync(cmd, args, project_root, agents), } } fn dispatch_sync( cmd: &str, args: &str, project_root: &std::path::Path, agents: &Arc, ) -> String { let ambient_rooms: Arc>> = Arc::new(Mutex::new(HashSet::new())); // Use a synthetic bot name/id so strip_bot_mention passes through. let bot_name = "__web_ui__"; let bot_user_id = "@__web_ui__:localhost"; let room_id = "__web_ui__"; let dispatch = CommandDispatch { bot_name, bot_user_id, project_root, agents, ambient_rooms: &ambient_rooms, room_id, }; // Build a synthetic message that the registry can parse. let synthetic = if args.is_empty() { format!("{bot_name} {cmd}") } else { format!("{bot_name} {cmd} {args}") }; match crate::matrix::commands::try_handle_command(&dispatch, &synthetic) { Some(response) => response, None => { // Command exists in the registry but its fallback handler returns None // (start, delete, rebuild, reset, htop — handled elsewhere or in // the frontend). Should not be reached for those since we intercept // them above. For genuinely unknown commands, tell the user. format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.") } } } async fn dispatch_start( args: &str, project_root: &std::path::Path, agents: &Arc, ) -> String { // args: "" or " " let mut parts = args.splitn(2, char::is_whitespace); let number_str = parts.next().unwrap_or("").trim(); let hint_str = parts.next().unwrap_or("").trim(); if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { return "Usage: `/start ` or `/start ` (e.g. `/start 42 opus`)" .to_string(); } let agent_hint = if hint_str.is_empty() { None } else { Some(hint_str) }; crate::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents) .await } async fn dispatch_delete( args: &str, project_root: &std::path::Path, agents: &Arc, ) -> String { let number_str = args.trim(); if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { return "Usage: `/delete ` (e.g. `/delete 42`)".to_string(); } crate::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await } async fn dispatch_rebuild( project_root: &std::path::Path, agents: &Arc, ) -> String { crate::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn test_api(dir: &TempDir) -> BotCommandApi { BotCommandApi { ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())), } } #[tokio::test] async fn help_command_returns_response() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "help".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!(!resp.response.is_empty()); } #[tokio::test] async fn unknown_command_returns_error_message() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "nonexistent_xyz".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( resp.response.contains("Unknown command"), "expected 'Unknown command' in: {}", resp.response ); } #[tokio::test] async fn start_without_number_returns_usage() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "start".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( resp.response.contains("Usage"), "expected usage hint in: {}", resp.response ); } #[tokio::test] async fn delete_without_number_returns_usage() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "delete".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( resp.response.contains("Usage"), "expected usage hint in: {}", resp.response ); } #[tokio::test] async fn git_command_returns_response() { let dir = TempDir::new().unwrap(); // Initialise a bare git repo so the git command has something to query. std::process::Command::new("git") .args(["init"]) .current_dir(dir.path()) .output() .ok(); let api = test_api(&dir); let body = BotCommandRequest { command: "git".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); } #[tokio::test] async fn run_command_requires_project_root() { // Create a context with no project root set. let dir = TempDir::new().unwrap(); let ctx = AppContext::new_test(dir.path().to_path_buf()); // Clear the project root. *ctx.state.project_root.lock().unwrap() = None; let api = BotCommandApi { ctx: Arc::new(ctx) }; let body = BotCommandRequest { command: "status".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!(result.is_err(), "should fail when no project root is set"); } }