//! 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. mod ambient; mod cost; mod git; mod help; mod move_story; mod overview; mod show; mod status; use crate::agents::AgentPool; use std::collections::HashSet; use std::path::Path; use std::sync::{Arc, Mutex}; /// 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. /// /// All identifiers are platform-agnostic strings so this struct works with /// any [`ChatTransport`](crate::transport::ChatTransport) implementation. pub struct CommandDispatch<'a> { /// The bot's display name (e.g., "Timmy"). pub bot_name: &'a str, /// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix). 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 room IDs 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 str, } /// 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 room IDs 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 str, } /// 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: help::handle_help, }, BotCommand { name: "status", description: "Show pipeline status and agent availability", handler: status::handle_status, }, BotCommand { name: "ambient", description: "Toggle ambient mode for this room: `ambient on` or `ambient off`", handler: ambient::handle_ambient, }, BotCommand { name: "git", description: "Show git status: branch, uncommitted changes, and ahead/behind remote", handler: git::handle_git, }, BotCommand { name: "htop", description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)", handler: handle_htop_fallback, }, BotCommand { name: "cost", description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total", handler: cost::handle_cost, }, BotCommand { name: "move", description: "Move a work item to a pipeline stage: `move ` (stages: backlog, current, qa, merge, done)", handler: move_story::handle_move, }, BotCommand { name: "show", description: "Display the full text of a work item: `show `", handler: show::handle_show, }, BotCommand { name: "overview", description: "Show implementation summary for a merged story: `overview `", handler: overview::handle_overview, }, BotCommand { name: "start", description: "Start a coder on a story: `start ` or `start opus`", handler: handle_start_fallback, }, BotCommand { name: "delete", description: "Remove a work item from the pipeline: `delete `", handler: handle_delete_fallback, }, BotCommand { name: "reset", description: "Clear the current Claude Code session and start fresh", handler: handle_reset_fallback, }, ] } /// 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, }; 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), } } /// Fallback handler for the `htop` command when it is not intercepted by the /// async handler in `on_room_message`. In practice this is never called — /// htop is detected and handled before `try_handle_command` is invoked. /// The entry exists in the registry only so `help` lists it. /// /// Returns `None` to prevent the LLM from receiving "htop" as a prompt. fn handle_htop_fallback(_ctx: &CommandContext) -> Option { None } /// Fallback handler for the `start` command when it is not intercepted by /// the async handler in `on_room_message`. In practice this is never called — /// start is detected and handled before `try_handle_command` is invoked. /// The entry exists in the registry only so `help` lists it. /// /// Returns `None` to prevent the LLM from receiving "start" as a prompt. fn handle_start_fallback(_ctx: &CommandContext) -> Option { None } /// Fallback handler for the `delete` command when it is not intercepted by /// the async handler in `on_room_message`. In practice this is never called — /// delete is detected and handled before `try_handle_command` is invoked. /// The entry exists in the registry only so `help` lists it. /// /// Returns `None` to prevent the LLM from receiving "delete" as a prompt. fn handle_delete_fallback(_ctx: &CommandContext) -> Option { None } /// Fallback handler for the `reset` command when it is not intercepted by /// the async handler in `on_room_message`. In practice this is never called — /// reset is detected and handled before `try_handle_command` is invoked. /// The entry exists in the registry only so `help` lists it. /// /// Returns `None` to prevent the LLM from receiving "reset" as a prompt. fn handle_reset_fallback(_ctx: &CommandContext) -> Option { None } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] pub(crate) mod tests { use super::*; use crate::agents::AgentPool; // -- test helpers (shared with submodule tests) ------------------------- pub fn test_ambient_rooms() -> Arc>> { Arc::new(Mutex::new(HashSet::new())) } pub fn test_agents() -> Arc { Arc::new(AgentPool::new_test(3000)) } pub fn try_cmd( bot_name: &str, bot_user_id: &str, message: &str, ambient_rooms: &Arc>>, ) -> Option { let agents = test_agents(); let room_id = "!test:example.com".to_string(); let dispatch = CommandDispatch { bot_name, bot_user_id, project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms, room_id: &room_id, }; try_handle_command(&dispatch, message) } pub 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()) } // Re-export commands() for submodule tests pub use super::commands; // -- 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 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 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" ); } #[test] fn htop_command_falls_through_to_none() { // The htop handler returns None so the message is handled asynchronously // in on_room_message, not here. try_handle_command must return None. let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop"); assert!( result.is_none(), "htop should not produce a sync response (handled async): {result:?}" ); } // -- 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("")); } // -- 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 ); } } }