//! 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. //! //! Dispatches to [`crate::service::bot_command::execute`], which owns all //! parsing and routing logic. This handler is a thin OpenAPI adapter: it //! receives JSON, calls the service, and maps typed errors to HTTP status codes. use crate::http::context::{AppContext, OpenApiResult}; use crate::service::bot_command as svc; use poem::http::StatusCode; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::{Deserialize, Serialize}; use std::sync::Arc; #[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. /// /// # Errors /// - `400 Bad Request` — project root not set, or invalid command arguments. /// - `404 Not Found` — unrecognised command keyword. /// - `500 Internal Server Error` — command execution failed. #[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 = svc::execute(&cmd, args, &project_root, &self.ctx.agents) .await .map_err(|e| match e { svc::Error::UnknownCommand(msg) => { poem::Error::from_string(msg, StatusCode::NOT_FOUND) } svc::Error::BadArgs(msg) => poem::Error::from_string(msg, StatusCode::BAD_REQUEST), svc::Error::CommandFailed(msg) => { poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR) } })?; Ok(Json(BotCommandResponse { response })) } } // --------------------------------------------------------------------------- // 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_err(), "unknown command should return HTTP 404"); } #[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_err(), "start with no args should return HTTP 400"); } #[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_err(), "delete with no args should return HTTP 400" ); } #[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 timer_list_returns_response_not_unknown_command() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "timer".to_string(), args: "list".to_string(), }; let result = api.run_command(Json(body)).await; assert!( result.is_ok(), "timer list should succeed, got err: {:?}", result.err().map(|e| e.to_string()) ); let resp = result.unwrap().0; assert!( !resp.response.contains("Unknown command"), "timer list should not return 'Unknown command': {}", resp.response ); } // -- htop (web-UI slash-command path) ------------------------------------ #[tokio::test] async fn htop_returns_dashboard_not_unknown_command() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "htop".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"), "htop should not return 'Unknown command': {}", resp.response ); assert!( resp.response.contains("htop"), "htop response should contain 'htop': {}", resp.response ); } #[tokio::test] async fn htop_with_duration_returns_dashboard() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "htop".to_string(), args: "10m".to_string(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( !resp.response.contains("Unknown command"), "htop 10m should not return 'Unknown command': {}", resp.response ); } #[tokio::test] async fn htop_stop_returns_response_not_unknown_command() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "htop".to_string(), args: "stop".to_string(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( !resp.response.contains("Unknown command"), "htop stop should not return 'Unknown command': {}", resp.response ); } // -- rmtree ---------------------------------------------------------------- #[tokio::test] async fn rmtree_without_number_returns_usage() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "rmtree".to_string(), args: String::new(), }; let result = api.run_command(Json(body)).await; assert!( result.is_err(), "rmtree with no args should return HTTP 400" ); } #[tokio::test] async fn rmtree_with_non_numeric_arg_returns_usage() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "rmtree".to_string(), args: "foo".to_string(), }; let result = api.run_command(Json(body)).await; assert!( result.is_err(), "rmtree with non-numeric arg should return HTTP 400" ); } #[tokio::test] async fn rmtree_does_not_return_unknown_command() { let dir = TempDir::new().unwrap(); let api = test_api(&dir); let body = BotCommandRequest { command: "rmtree".to_string(), args: "999".to_string(), }; let result = api.run_command(Json(body)).await; assert!(result.is_ok()); let resp = result.unwrap().0; assert!( !resp.response.contains("Unknown command"), "/rmtree should not return 'Unknown command': {}", resp.response ); } // -- htop bot-command path (regression: htop must remain in command registry) -- #[test] fn htop_is_registered_in_bot_command_registry() { let commands = crate::chat::commands::commands(); assert!( commands.iter().any(|c| c.name == "htop"), "htop must be registered in the bot command registry so /help lists it" ); } #[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"); } }