diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 0663a2a..75562df 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -831,6 +831,23 @@ async fn on_room_message( let user_message = body; slog!("[matrix-bot] Message from {sender}: {user_message}"); + // Check for bot-level commands (e.g. "help") before invoking the LLM. + if let Some(response) = super::commands::try_handle_command( + &ctx.bot_name, + ctx.bot_user_id.as_str(), + &user_message, + ) { + slog!("[matrix-bot] Handled bot command from {sender}"); + let html = markdown_to_html(&response); + if let Ok(resp) = room + .send(RoomMessageEventContent::text_html(response, html)) + .await + { + ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); + } + return; + } + // Spawn a separate task so the Matrix sync loop is not blocked while we // wait for the LLM response (which can take several seconds). tokio::spawn(async move { diff --git a/server/src/matrix/commands.rs b/server/src/matrix/commands.rs new file mode 100644 index 0000000..5f64508 --- /dev/null +++ b/server/src/matrix/commands.rs @@ -0,0 +1,318 @@ +//! 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 + ); + } + } +} diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index 97920e6..f272988 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -16,6 +16,7 @@ //! `bot.toml`. Each room maintains its own independent conversation history. mod bot; +pub mod commands; mod config; pub mod notifications;