story-kit: merge 285_story_matrix_bot_help_command_lists_available_bot_commands
This commit is contained in:
@@ -831,6 +831,23 @@ async fn on_room_message(
|
|||||||
let user_message = body;
|
let user_message = body;
|
||||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
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
|
// Spawn a separate task so the Matrix sync loop is not blocked while we
|
||||||
// wait for the LLM response (which can take several seconds).
|
// wait for the LLM response (which can take several seconds).
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
318
server/src/matrix/commands.rs
Normal file
318
server/src/matrix/commands.rs
Normal file
@@ -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<String> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
//! `bot.toml`. Each room maintains its own independent conversation history.
|
//! `bot.toml`. Each room maintains its own independent conversation history.
|
||||||
|
|
||||||
mod bot;
|
mod bot;
|
||||||
|
pub mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user