huskies: merge 607_story_extract_bot_command_service
This commit is contained in:
+35
-248
@@ -3,19 +3,16 @@
|
||||
//! `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.
|
||||
//! 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::chat::commands::CommandDispatch;
|
||||
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::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum BotCommandTags {
|
||||
@@ -50,6 +47,11 @@ impl BotCommandApi {
|
||||
/// 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,
|
||||
@@ -63,221 +65,23 @@ impl BotCommandApi {
|
||||
|
||||
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;
|
||||
|
||||
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 }))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -316,13 +120,7 @@ mod tests {
|
||||
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
|
||||
);
|
||||
assert!(result.is_err(), "unknown command should return HTTP 404");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -334,13 +132,7 @@ mod tests {
|
||||
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
|
||||
);
|
||||
assert!(result.is_err(), "start with no args should return HTTP 400");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -352,12 +144,9 @@ mod tests {
|
||||
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
|
||||
result.is_err(),
|
||||
"delete with no args should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,7 +177,11 @@ mod tests {
|
||||
args: "list".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
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"),
|
||||
@@ -469,12 +262,9 @@ mod tests {
|
||||
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
|
||||
result.is_err(),
|
||||
"rmtree with no args should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,12 +277,9 @@ mod tests {
|
||||
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
|
||||
result.is_err(),
|
||||
"rmtree with non-numeric arg should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user