Files
storkit/server/src/matrix/commands/mod.rs

432 lines
14 KiB
Rust
Raw Normal View History

//! 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 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<String>,
}
/// 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<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
/// 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 room IDs with ambient mode enabled (needed by ambient).
pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
/// The room this message came from (needed by ambient).
pub room_id: &'a str,
/// 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: 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: "show",
description: "Display the full text of a work item: `show <number>`",
handler: show::handle_show,
},
BotCommand {
name: "overview",
description: "Show implementation summary for a merged story: `overview <number>`",
handler: overview::handle_overview,
},
BotCommand {
name: "start",
description: "Start a coder on a story: `start <number>` or `start <number> opus`",
handler: handle_start_fallback,
},
BotCommand {
name: "delete",
description: "Remove a work item from the pipeline: `delete <number>`",
handler: handle_delete_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<String> {
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),
}
}
/// 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<String> {
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<String> {
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<String> {
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<Mutex<HashSet<String>>> {
Arc::new(Mutex::new(HashSet::new()))
}
pub fn test_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
pub fn try_cmd(
bot_name: &str,
bot_user_id: &str,
message: &str,
ambient_rooms: &Arc<Mutex<HashSet<String>>>,
is_addressed: bool,
) -> Option<String> {
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,
is_addressed,
};
try_handle_command(&dispatch, message)
}
pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms(), true)
}
// 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
);
}
}
}