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

544 lines
18 KiB
Rust
Raw Normal View History

//! 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, git, cost, move, show, overview, help) are
//! dispatched directly through the matrix command registry.
//! Asynchronous commands (assign, 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::chat::commands::CommandDispatch;
use crate::http::context::{AppContext, OpenApiResult};
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<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.
#[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 = 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<crate::agents::AgentPool>,
) -> String {
match cmd {
"assign" => dispatch_assign(args, project_root, agents).await,
"start" => dispatch_start(args, project_root, agents).await,
"delete" => dispatch_delete(args, project_root, agents).await,
"rebuild" => dispatch_rebuild(project_root, agents).await,
"rmtree" => dispatch_rmtree(args, project_root, agents).await,
"timer" => dispatch_timer(args, project_root).await,
"htop" => dispatch_htop(args, 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<crate::agents::AgentPool>,
) -> String {
let ambient_rooms: Arc<Mutex<HashSet<String>>> = 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::chat::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_assign(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
// args: "<number> <model>"
let mut parts = args.splitn(2, char::is_whitespace);
let number_str = parts.next().unwrap_or("").trim();
let model_str = parts.next().unwrap_or("").trim();
if number_str.is_empty()
|| !number_str.chars().all(|c| c.is_ascii_digit())
|| model_str.is_empty()
{
return "Usage: `/assign <number> <model>` (e.g. `/assign 42 opus`)".to_string();
}
crate::chat::transport::matrix::assign::handle_assign(
"web-ui",
number_str,
model_str,
project_root,
agents,
)
.await
}
async fn dispatch_start(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
// args: "<number>" or "<number> <model_hint>"
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 <number>` or `/start <number> <model>` (e.g. `/start 42 opus`)"
.to_string();
}
let agent_hint = if hint_str.is_empty() {
None
} else {
Some(hint_str)
};
crate::chat::transport::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<crate::agents::AgentPool>,
) -> String {
let number_str = args.trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
}
crate::chat::transport::matrix::delete::handle_delete(
"web-ui",
number_str,
project_root,
agents,
)
.await
}
async fn dispatch_rmtree(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
let number_str = args.trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/rmtree <number>` (e.g. `/rmtree 42`)".to_string();
}
crate::chat::transport::matrix::rmtree::handle_rmtree(
"web-ui",
number_str,
project_root,
agents,
)
.await
}
async fn dispatch_rebuild(
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
}
async fn dispatch_timer(args: &str, project_root: &std::path::Path) -> String {
// Re-use the existing parser by constructing a synthetic message that
// looks like a bot-addressed timer command.
let synthetic = format!("__web_ui__ timer {args}");
let timer_cmd = match crate::chat::timer::extract_timer_command(
&synthetic,
"__web_ui__",
"@__web_ui__:localhost",
) {
Some(cmd) => cmd,
None => {
return "Usage: `/timer list`, `/timer <number> <HH:MM>`, or `/timer cancel <number>`"
.to_string();
}
};
let store =
crate::chat::timer::TimerStore::load(project_root.join(".huskies").join("timers.json"));
crate::chat::timer::handle_timer_command(timer_cmd, &store, project_root).await
}
/// Handle the `htop` command from the web UI.
///
/// The web UI uses a one-shot HTTP request, so live updates are not possible
/// here. Returns a static snapshot of the process dashboard. For `htop stop`,
/// returns a helpful message (no persistent session state exists in the web UI).
async fn dispatch_htop(args: &str, agents: &Arc<crate::agents::AgentPool>) -> String {
use crate::chat::transport::matrix::htop::{HtopCommand, build_htop_message};
// Re-use the existing parser by constructing a synthetic message.
let synthetic = if args.is_empty() {
"__web_ui__ htop".to_string()
} else {
format!("__web_ui__ htop {args}")
};
match crate::chat::transport::matrix::htop::extract_htop_command(
&synthetic,
"__web_ui__",
"@__web_ui__:localhost",
) {
Some(HtopCommand::Stop) => "No active htop session in the web UI. \
Live sessions are only supported in chat transports (Matrix, Slack, Discord)."
.to_string(),
Some(HtopCommand::Start { duration_secs }) => build_htop_message(agents, 0, duration_secs),
None => build_htop_message(agents, 0, 300),
}
}
// ---------------------------------------------------------------------------
// 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 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());
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_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint for bare /rmtree: {}",
resp.response
);
}
#[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_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint for /rmtree foo: {}",
resp.response
);
}
#[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");
}
}