//! Bot-level command registry for the Matrix bot. //! //! Commands registered here are handled directly by the bot without invoking //! the LLM. The registry is the single source of truth — the `help` command //! iterates it automatically so new commands appear in the help output as soon //! as they are added. use crate::agents::{AgentPool, AgentStatus}; use crate::config::ProjectConfig; use matrix_sdk::ruma::OwnedRoomId; use std::collections::HashSet; use std::path::Path; use std::sync::{Arc, Mutex}; use super::config::save_ambient_rooms; /// A bot-level command that is handled without LLM invocation. pub struct BotCommand { /// The command keyword (e.g., `"help"`). Always lowercase. pub name: &'static str, /// Short description shown in help output. pub description: &'static str, /// Handler that produces the response text (Markdown), or `None` to fall /// through to the LLM (e.g. when a command requires direct addressing but /// the message arrived via ambient mode). pub handler: fn(&CommandContext) -> Option, } /// Dispatch parameters passed to `try_handle_command`. /// /// Groups all the caller-supplied context needed to dispatch and execute bot /// commands. Construct one per incoming message and pass it alongside the raw /// message body. pub struct CommandDispatch<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// The bot's full Matrix user ID (e.g., `"@timmy:homeserver.local"`). pub bot_user_id: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of rooms with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a OwnedRoomId, /// Whether the message directly addressed the bot (mention/reply). /// Some commands (e.g. ambient) only operate when directly addressed. pub is_addressed: bool, } /// Context passed to individual command handlers. pub struct CommandContext<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// Any text after the command keyword, trimmed. pub args: &'a str, /// Project root directory (needed by status, ambient). pub project_root: &'a Path, /// Agent pool (needed by status). pub agents: &'a AgentPool, /// Set of rooms with ambient mode enabled (needed by ambient). pub ambient_rooms: &'a Arc>>, /// The room this message came from (needed by ambient). pub room_id: &'a OwnedRoomId, /// Whether the message directly addressed the bot (mention/reply). /// Some commands (e.g. ambient) only operate when directly addressed. pub is_addressed: bool, } /// Returns the full list of registered bot commands. /// /// Add new commands here — they will automatically appear in `help` output. pub fn commands() -> &'static [BotCommand] { &[ BotCommand { name: "help", description: "Show this list of available commands", handler: handle_help, }, BotCommand { name: "status", description: "Show pipeline status and agent availability", handler: handle_status, }, BotCommand { name: "ambient", description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", handler: handle_ambient, }, ] } /// Try to match a user message against a registered bot command. /// /// The message is expected to be the raw body text from Matrix (e.g., /// `"@timmy help"`). The bot mention prefix is stripped before matching. /// /// Returns `Some(response)` if a command matched and was handled, `None` /// otherwise (the caller should fall through to the LLM). pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option { let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id); let trimmed = command_text.trim(); if trimmed.is_empty() { return None; } let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) { Some((c, a)) => (c, a.trim()), None => (trimmed, ""), }; let cmd_lower = cmd_name.to_ascii_lowercase(); let ctx = CommandContext { bot_name: dispatch.bot_name, args, project_root: dispatch.project_root, agents: dispatch.agents, ambient_rooms: dispatch.ambient_rooms, room_id: dispatch.room_id, is_addressed: dispatch.is_addressed, }; commands() .iter() .find(|c| c.name == cmd_lower) .and_then(|c| (c.handler)(&ctx)) } /// Strip the bot mention prefix from a raw message body. /// /// Handles these forms (case-insensitive where applicable): /// - `@bot_localpart:server.com rest` → `rest` /// - `@bot_localpart rest` → `rest` /// - `DisplayName rest` → `rest` fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); // Try full Matrix user ID (e.g. "@timmy:homeserver.local") if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) { return rest; } // Try @localpart (e.g. "@timmy") if let Some(localpart) = bot_user_id.split(':').next() && let Some(rest) = strip_prefix_ci(trimmed, localpart) { return rest; } // Try display name (e.g. "Timmy") if let Some(rest) = strip_prefix_ci(trimmed, bot_name) { return rest; } trimmed } /// Case-insensitive prefix strip that also requires the match to end at a /// word boundary (whitespace, punctuation, or end-of-string). fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { if text.len() < prefix.len() { return None; } if !text[..prefix.len()].eq_ignore_ascii_case(prefix) { return None; } let rest = &text[prefix.len()..]; // Must be at end or followed by non-alphanumeric match rest.chars().next() { None => Some(rest), // exact match, empty remainder Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None, // not a word boundary _ => Some(rest), } } // --------------------------------------------------------------------------- // Pipeline status helpers (moved from bot.rs) // --------------------------------------------------------------------------- /// Format a short display label for a work item. /// /// Extracts the leading numeric ID from the file stem (e.g. `"293"` from /// `"293_story_register_all_bot_commands"`) and combines it with the human- /// readable name from the front matter when available. /// /// Examples: /// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"` /// - `("293_story_foo", None)` → `"293"` /// - `("no_number_here", None)` → `"no_number_here"` fn story_short_label(stem: &str, name: Option<&str>) -> String { let number = stem .split('_') .next() .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) .unwrap_or(stem); match name { Some(n) => format!("{number} — {n}"), None => number.to_string(), } } /// Read all story IDs and names from a pipeline stage directory. fn read_stage_items( project_root: &std::path::Path, stage_dir: &str, ) -> Vec<(String, Option)> { let dir = project_root .join(".story_kit") .join("work") .join(stage_dir); if !dir.exists() { return Vec::new(); } let mut items = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { let name = std::fs::read_to_string(&path) .ok() .and_then(|contents| { crate::io::story_metadata::parse_front_matter(&contents) .ok() .and_then(|m| m.name) }); items.push((stem.to_string(), name)); } } } items.sort_by(|a, b| a.0.cmp(&b.0)); items } /// Build the full pipeline status text formatted for Matrix (markdown). pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { // Build a map from story_id → active AgentInfo for quick lookup. let active_agents = agents.list_agents().unwrap_or_default(); let active_map: std::collections::HashMap = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| (a.story_id.clone(), a)) .collect(); let config = ProjectConfig::load(project_root).ok(); let mut out = String::from("**Pipeline Status**\n\n"); let stages = [ ("1_backlog", "Backlog"), ("2_current", "In Progress"), ("3_qa", "QA"), ("4_merge", "Merge"), ("5_done", "Done"), ]; for (dir, label) in &stages { let items = read_stage_items(project_root, dir); let count = items.len(); out.push_str(&format!("**{label}** ({count})\n")); if items.is_empty() { out.push_str(" *(none)*\n"); } else { for (story_id, name) in &items { let display = story_short_label(story_id, name.as_deref()); if let Some(agent) = active_map.get(story_id) { let model_str = config .as_ref() .and_then(|cfg| cfg.find_agent(&agent.agent_name)) .and_then(|ac| ac.model.as_deref()) .unwrap_or("?"); out.push_str(&format!( " • {display} — {} ({model_str})\n", agent.agent_name )); } else { out.push_str(&format!(" • {display}\n")); } } } out.push('\n'); } // Free agents: configured agents not currently running or pending. out.push_str("**Free Agents**\n"); if let Some(cfg) = &config { let busy_names: std::collections::HashSet = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| a.agent_name.clone()) .collect(); let free: Vec = cfg .agent .iter() .filter(|a| !busy_names.contains(&a.name)) .map(|a| match &a.model { Some(m) => format!("{} ({})", a.name, m), None => a.name.clone(), }) .collect(); if free.is_empty() { out.push_str(" *(none — all agents busy)*\n"); } else { for name in &free { out.push_str(&format!(" • {name}\n")); } } } else { out.push_str(" *(no agent config found)*\n"); } out } // --------------------------------------------------------------------------- // Built-in command handlers // --------------------------------------------------------------------------- fn handle_help(ctx: &CommandContext) -> Option { let mut output = format!("**{} Commands**\n\n", ctx.bot_name); for cmd in commands() { output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description)); } Some(output) } fn handle_status(ctx: &CommandContext) -> Option { Some(build_pipeline_status(ctx.project_root, ctx.agents)) } /// Toggle ambient mode for this room. /// /// Only acts when the message directly addressed the bot (`is_addressed=true`) /// to prevent accidental toggling via ambient-mode traffic. fn handle_ambient(ctx: &CommandContext) -> Option { if !ctx.is_addressed { return None; } let enable = match ctx.args { "on" => true, "off" => false, _ => return Some("Usage: `ambient on` or `ambient off`".to_string()), }; let room_ids: Vec = { let mut ambient = ctx.ambient_rooms.lock().unwrap(); if enable { ambient.insert(ctx.room_id.clone()); } else { ambient.remove(ctx.room_id); } ambient.iter().map(|r| r.to_string()).collect() }; save_ambient_rooms(ctx.project_root, &room_ids); let msg = if enable { "Ambient mode on. I'll respond to all messages in this room." } else { "Ambient mode off. I'll only respond when mentioned." }; Some(msg.to_string()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; // -- test helpers ------------------------------------------------------- fn make_room_id(s: &str) -> OwnedRoomId { s.parse().unwrap() } fn test_ambient_rooms() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } fn try_cmd( bot_name: &str, bot_user_id: &str, message: &str, ambient_rooms: &Arc>>, is_addressed: bool, ) -> Option { let agents = test_agents(); let room_id = make_room_id("!test:example.com"); let dispatch = CommandDispatch { bot_name, bot_user_id, project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms, room_id: &room_id, is_addressed, }; try_handle_command(&dispatch, message) } fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option { try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms(), true) } // -- strip_bot_mention -------------------------------------------------- #[test] fn strip_mention_full_user_id() { let rest = strip_bot_mention( "@timmy:homeserver.local help", "Timmy", "@timmy:homeserver.local", ); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_localpart() { let rest = strip_bot_mention("@timmy help me", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help me"); } #[test] fn strip_mention_display_name() { let rest = strip_bot_mention("Timmy help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_display_name_case_insensitive() { let rest = strip_bot_mention("timmy help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim(), "help"); } #[test] fn strip_mention_no_match_returns_original() { let rest = strip_bot_mention("hello world", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest, "hello world"); } #[test] fn strip_mention_does_not_match_longer_name() { // "@timmybot" should NOT match "@timmy" let rest = strip_bot_mention("@timmybot help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest, "@timmybot help"); } #[test] fn strip_mention_comma_after_name() { let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local"); assert_eq!(rest.trim().trim_start_matches(',').trim(), "help"); } // -- try_handle_command ------------------------------------------------- #[test] fn help_command_matches() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); assert!(result.is_some(), "help command should match"); } #[test] fn help_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP"); assert!(result.is_some(), "HELP should match case-insensitively"); } #[test] fn unknown_command_returns_none() { let result = try_cmd_addressed( "Timmy", "@timmy:homeserver.local", "@timmy what is the weather?", ); assert!(result.is_none(), "non-command should return None"); } #[test] fn help_output_contains_all_commands() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); for cmd in commands() { assert!( output.contains(cmd.name), "help output must include command '{}'", cmd.name ); assert!( output.contains(cmd.description), "help output must include description for '{}'", cmd.name ); } } #[test] fn help_output_uses_bot_name() { let result = try_cmd_addressed("HAL", "@hal:example.com", "@hal help"); let output = result.unwrap(); assert!( output.contains("HAL Commands"), "help output should use bot name: {output}" ); } #[test] fn help_output_formatted_as_markdown() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!( output.contains("**help**"), "command name should be bold: {output}" ); assert!( output.contains("- **"), "commands should be in a list: {output}" ); } #[test] fn empty_message_after_mention_returns_none() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy"); assert!( result.is_none(), "bare mention with no command should fall through to LLM" ); } // -- status command ----------------------------------------------------- #[test] fn status_command_matches() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); assert!(result.is_some(), "status command should match"); } #[test] fn status_command_returns_pipeline_text() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); let output = result.unwrap(); assert!( output.contains("Pipeline Status"), "status output should contain pipeline info: {output}" ); } #[test] fn status_command_case_insensitive() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); assert!(result.is_some(), "STATUS should match case-insensitively"); } // -- ambient command ---------------------------------------------------- #[test] fn ambient_on_requires_addressed() { let ambient_rooms = test_ambient_rooms(); let room_id = make_room_id("!myroom:example.com"); let result = try_cmd( "Timmy", "@timmy:homeserver.local", "@timmy ambient on", &ambient_rooms, false, // not addressed ); // Should fall through to LLM when not addressed assert!(result.is_none(), "ambient should not fire in non-addressed mode"); assert!( !ambient_rooms.lock().unwrap().contains(&room_id), "ambient_rooms should not be modified when not addressed" ); } #[test] fn ambient_on_enables_ambient_mode() { let ambient_rooms = test_ambient_rooms(); let agents = test_agents(); let room_id = make_room_id("!myroom:example.com"); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let result = try_handle_command(&dispatch, "@timmy ambient on"); assert!(result.is_some(), "ambient on should produce a response"); let output = result.unwrap(); assert!( output.contains("Ambient mode on"), "response should confirm ambient on: {output}" ); assert!( ambient_rooms.lock().unwrap().contains(&room_id), "room should be in ambient_rooms after ambient on" ); } #[test] fn ambient_off_disables_ambient_mode() { let ambient_rooms = test_ambient_rooms(); let agents = test_agents(); let room_id = make_room_id("!myroom:example.com"); // Pre-insert the room ambient_rooms.lock().unwrap().insert(room_id.clone()); let dispatch = CommandDispatch { bot_name: "Timmy", bot_user_id: "@timmy:homeserver.local", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, is_addressed: true, }; let result = try_handle_command(&dispatch, "@timmy ambient off"); assert!(result.is_some(), "ambient off should produce a response"); let output = result.unwrap(); assert!( output.contains("Ambient mode off"), "response should confirm ambient off: {output}" ); assert!( !ambient_rooms.lock().unwrap().contains(&room_id), "room should be removed from ambient_rooms after ambient off" ); } #[test] fn ambient_invalid_args_returns_usage() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient"); let output = result.unwrap(); assert!( output.contains("Usage"), "invalid ambient args should show usage: {output}" ); } // -- help lists status and ambient -------------------------------------- #[test] fn help_output_includes_status() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("status"), "help should list status command: {output}"); } #[test] fn help_output_includes_ambient() { let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help"); let output = result.unwrap(); assert!(output.contains("ambient"), "help should list ambient command: {output}"); } // -- strip_prefix_ci ---------------------------------------------------- #[test] fn strip_prefix_ci_basic() { assert_eq!(strip_prefix_ci("Hello world", "hello"), Some(" world")); } #[test] fn strip_prefix_ci_no_match() { assert_eq!(strip_prefix_ci("goodbye", "hello"), None); } #[test] fn strip_prefix_ci_word_boundary_required() { assert_eq!(strip_prefix_ci("helloworld", "hello"), None); } #[test] fn strip_prefix_ci_exact_match() { assert_eq!(strip_prefix_ci("hello", "hello"), Some("")); } // -- story_short_label -------------------------------------------------- #[test] fn short_label_extracts_number_and_name() { let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands")); assert_eq!(label, "293 — Register all bot commands"); } #[test] fn short_label_number_only_when_no_name() { let label = story_short_label("297_story_improve_bot_status_command_formatting", None); assert_eq!(label, "297"); } #[test] fn short_label_falls_back_to_stem_when_no_numeric_prefix() { let label = story_short_label("no_number_here", None); assert_eq!(label, "no_number_here"); } #[test] fn short_label_does_not_include_underscore_slug() { let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands")); assert!( !label.contains("story_register"), "label should not contain the slug portion: {label}" ); } // -- build_pipeline_status formatting ----------------------------------- #[test] fn status_does_not_show_full_filename_stem() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".story_kit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); // Write a story file with a front-matter name let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( !output.contains("293_story_register_all_bot_commands"), "output must not show full filename stem: {output}" ); assert!( output.contains("293 — Register all bot commands"), "output must show number and title: {output}" ); } // -- commands registry -------------------------------------------------- #[test] fn commands_registry_is_not_empty() { assert!( !commands().is_empty(), "command registry must contain at least one command" ); } #[test] fn all_command_names_are_lowercase() { for cmd in commands() { assert_eq!( cmd.name, cmd.name.to_ascii_lowercase(), "command name '{}' must be lowercase", cmd.name ); } } #[test] fn all_commands_have_descriptions() { for cmd in commands() { assert!( !cmd.description.is_empty(), "command '{}' must have a description", cmd.name ); } } }