storkit: merge 374_story_web_ui_implements_all_bot_commands_as_slash_commands
This commit is contained in:
286
server/src/http/bot_command.rs
Normal file
286
server/src/http/bot_command.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! 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, assign, git, cost, move, show, overview,
|
||||
//! help) are dispatched directly through the matrix command registry.
|
||||
//! Asynchronous commands (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::http::context::{AppContext, OpenApiResult};
|
||||
use crate::matrix::commands::CommandDispatch;
|
||||
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 {
|
||||
"start" => dispatch_start(args, project_root, agents).await,
|
||||
"delete" => dispatch_delete(args, project_root, agents).await,
|
||||
"rebuild" => dispatch_rebuild(project_root, 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::matrix::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_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::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::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
|
||||
}
|
||||
|
||||
async fn dispatch_rebuild(
|
||||
project_root: &std::path::Path,
|
||||
agents: &Arc<crate::agents::AgentPool>,
|
||||
) -> String {
|
||||
crate::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod agents;
|
||||
pub mod agents_sse;
|
||||
pub mod anthropic;
|
||||
pub mod assets;
|
||||
pub mod bot_command;
|
||||
pub mod chat;
|
||||
pub mod context;
|
||||
pub mod health;
|
||||
@@ -16,6 +17,7 @@ pub mod ws;
|
||||
|
||||
use agents::AgentsApi;
|
||||
use anthropic::AnthropicApi;
|
||||
use bot_command::BotCommandApi;
|
||||
use chat::ChatApi;
|
||||
use context::AppContext;
|
||||
use health::HealthApi;
|
||||
@@ -113,6 +115,7 @@ type ApiTuple = (
|
||||
AgentsApi,
|
||||
SettingsApi,
|
||||
HealthApi,
|
||||
BotCommandApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
AgentsApi { ctx: ctx.clone() },
|
||||
SettingsApi { ctx: ctx.clone() },
|
||||
HealthApi,
|
||||
BotCommandApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
@@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
AgentsApi { ctx: ctx.clone() },
|
||||
SettingsApi { ctx },
|
||||
SettingsApi { ctx: ctx.clone() },
|
||||
HealthApi,
|
||||
BotCommandApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
|
||||
Reference in New Issue
Block a user