Files
huskies/server/src/http/bot_command.rs
T

331 lines
11 KiB
Rust

//! 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<AppContext>,
}
#[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<BotCommandRequest>,
) -> OpenApiResult<Json<BotCommandResponse>> {
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");
}
}