//! 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. /// 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). pub handler: fn(&CommandContext) -> String, } /// Context passed to 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. #[allow(dead_code)] pub args: &'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: handle_help, }] } /// 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, `None` otherwise (the /// caller should fall through to the LLM). pub fn try_handle_command( bot_name: &str, bot_user_id: &str, message: &str, ) -> Option { let command_text = strip_bot_mention(message, bot_name, 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, args, }; commands() .iter() .find(|c| c.name == cmd_lower) .map(|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), } } // --------------------------------------------------------------------------- // Built-in command handlers // --------------------------------------------------------------------------- fn handle_help(ctx: &CommandContext) -> String { let mut output = format!("**{} Commands**\n\n", ctx.bot_name); for cmd in commands() { output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description)); } output } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // -- 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_handle_command("Timmy", "@timmy:homeserver.local", "@timmy help"); assert!(result.is_some(), "help command should match"); } #[test] fn help_command_case_insensitive() { let result = try_handle_command("Timmy", "@timmy:homeserver.local", "@timmy HELP"); assert!(result.is_some(), "HELP should match case-insensitively"); } #[test] fn unknown_command_returns_none() { let result = try_handle_command( "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_handle_command("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_handle_command("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_handle_command("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_handle_command("Timmy", "@timmy:homeserver.local", "@timmy"); assert!( result.is_none(), "bare mention with no command should fall through to LLM" ); } // -- 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 ); } } }