2026-03-23 18:33:13 +00:00
|
|
|
//! 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.
|
|
|
|
|
//!
|
2026-04-24 15:23:54 +00:00
|
|
|
//! 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.
|
2026-03-23 18:33:13 +00:00
|
|
|
|
2026-04-13 14:07:08 +00:00
|
|
|
use crate::http::context::{AppContext, OpenApiResult};
|
2026-04-24 15:23:54 +00:00
|
|
|
use crate::service::bot_command as svc;
|
2026-03-23 18:33:13 +00:00
|
|
|
use poem::http::StatusCode;
|
|
|
|
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-04-24 15:23:54 +00:00
|
|
|
use std::sync::Arc;
|
2026-03-23 18:33:13 +00:00
|
|
|
|
|
|
|
|
#[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.
|
2026-04-24 15:23:54 +00:00
|
|
|
///
|
|
|
|
|
/// # 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.
|
2026-03-23 18:33:13 +00:00
|
|
|
#[oai(path = "/bot/command", method = "post")]
|
|
|
|
|
async fn run_command(
|
|
|
|
|
&self,
|
|
|
|
|
body: Json<BotCommandRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<BotCommandResponse>> {
|
2026-04-13 14:07:08 +00:00
|
|
|
let project_root = self
|
|
|
|
|
.ctx
|
|
|
|
|
.state
|
|
|
|
|
.get_project_root()
|
|
|
|
|
.map_err(|e| poem::Error::from_string(e, StatusCode::BAD_REQUEST))?;
|
2026-03-23 18:33:13 +00:00
|
|
|
|
|
|
|
|
let cmd = body.command.trim().to_ascii_lowercase();
|
|
|
|
|
let args = body.args.trim();
|
2026-03-24 15:03:17 +00:00
|
|
|
|
2026-04-24 15:23:54 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})?;
|
2026-04-21 12:25:44 +00:00
|
|
|
|
2026-04-24 15:23:54 +00:00
|
|
|
Ok(Json(BotCommandResponse { response }))
|
2026-04-21 12:12:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 18:33:13 +00:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
2026-04-24 15:23:54 +00:00
|
|
|
assert!(result.is_err(), "unknown command should return HTTP 404");
|
2026-03-23 18:33:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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;
|
2026-04-24 15:23:54 +00:00
|
|
|
assert!(result.is_err(), "start with no args should return HTTP 400");
|
2026-03-23 18:33:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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!(
|
2026-04-24 15:23:54 +00:00
|
|
|
result.is_err(),
|
|
|
|
|
"delete with no args should return HTTP 400"
|
2026-03-23 18:33:13 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 21:29:24 +00:00
|
|
|
#[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;
|
2026-04-24 15:23:54 +00:00
|
|
|
assert!(
|
|
|
|
|
result.is_ok(),
|
|
|
|
|
"timer list should succeed, got err: {:?}",
|
|
|
|
|
result.err().map(|e| e.to_string())
|
|
|
|
|
);
|
2026-04-04 21:29:24 +00:00
|
|
|
let resp = result.unwrap().0;
|
|
|
|
|
assert!(
|
|
|
|
|
!resp.response.contains("Unknown command"),
|
|
|
|
|
"timer list should not return 'Unknown command': {}",
|
|
|
|
|
resp.response
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:12:58 +00:00
|
|
|
// -- 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:25:44 +00:00
|
|
|
// -- 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!(
|
2026-04-24 15:23:54 +00:00
|
|
|
result.is_err(),
|
|
|
|
|
"rmtree with no args should return HTTP 400"
|
2026-04-21 12:25:44 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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!(
|
2026-04-24 15:23:54 +00:00
|
|
|
result.is_err(),
|
|
|
|
|
"rmtree with non-numeric arg should return HTTP 400"
|
2026-04-21 12:25:44 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 12:12:58 +00:00
|
|
|
// -- 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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 18:33:13 +00:00
|
|
|
#[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");
|
|
|
|
|
}
|
|
|
|
|
}
|