2026-03-25 14:43:28 +00:00
|
|
|
//! Bot-level command registry shared by all chat transports.
|
2026-03-22 19:07:07 +00:00
|
|
|
//!
|
|
|
|
|
//! 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 assign;
|
2026-04-12 12:58:51 +00:00
|
|
|
mod backlog;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod cost;
|
2026-04-04 11:46:26 +00:00
|
|
|
mod coverage;
|
2026-04-04 21:43:29 +00:00
|
|
|
mod depends;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod git;
|
|
|
|
|
mod help;
|
2026-03-28 08:56:06 +00:00
|
|
|
pub(crate) mod loc;
|
2026-04-12 14:56:16 +00:00
|
|
|
mod logs;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod move_story;
|
|
|
|
|
mod overview;
|
2026-04-07 14:39:47 +00:00
|
|
|
mod run_tests;
|
2026-03-28 14:21:13 +00:00
|
|
|
mod setup;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod show;
|
|
|
|
|
mod status;
|
2026-03-28 08:59:36 +00:00
|
|
|
mod timer;
|
2026-03-24 11:06:43 +00:00
|
|
|
mod triage;
|
2026-03-28 09:01:09 +00:00
|
|
|
pub(crate) mod unblock;
|
2026-03-24 22:20:19 +00:00
|
|
|
mod unreleased;
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
use crate::agents::AgentPool;
|
2026-03-25 14:43:28 +00:00
|
|
|
use crate::chat::util::strip_bot_mention;
|
2026-03-22 19:07:07 +00:00
|
|
|
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
|
2026-03-24 17:54:51 +00:00
|
|
|
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
|
2026-03-22 19:07:07 +00:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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: "assign",
|
|
|
|
|
description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
|
|
|
|
|
handler: assign::handle_assign,
|
|
|
|
|
},
|
2026-04-12 12:58:51 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "backlog",
|
|
|
|
|
description: "Show all items in the backlog with dependency satisfaction status",
|
|
|
|
|
handler: backlog::handle_backlog,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "help",
|
|
|
|
|
description: "Show this list of available commands",
|
|
|
|
|
handler: help::handle_help,
|
|
|
|
|
},
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "status",
|
2026-04-12 14:56:16 +00:00
|
|
|
description: "Show pipeline status and agent availability; or `status <number>` for pipeline info (stage, ACs, git diff, recent commits)",
|
2026-03-22 19:07:07 +00:00
|
|
|
handler: status::handle_status,
|
|
|
|
|
},
|
2026-04-12 14:56:16 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "logs",
|
|
|
|
|
description: "Show last agent log lines for a story: `logs <number>`",
|
|
|
|
|
handler: logs::handle_logs,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "ambient",
|
|
|
|
|
description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
|
|
|
|
|
handler: ambient::handle_ambient,
|
|
|
|
|
},
|
2026-04-04 21:43:29 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "depends",
|
|
|
|
|
description: "Set story dependencies: `depends <number> [dep1 dep2 ...]` (no deps = clear)",
|
|
|
|
|
handler: depends::handle_depends,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
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,
|
|
|
|
|
},
|
2026-04-04 11:46:26 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "coverage",
|
|
|
|
|
description: "Show test coverage: cached baseline by default, or `coverage run` to rerun the full suite",
|
|
|
|
|
handler: coverage::handle_coverage,
|
|
|
|
|
},
|
2026-04-07 14:39:47 +00:00
|
|
|
BotCommand {
|
2026-04-12 13:06:47 +00:00
|
|
|
name: "run_tests",
|
2026-04-07 14:39:47 +00:00
|
|
|
description: "Run the project's test suite (`script/test`) and show pass/fail with output",
|
|
|
|
|
handler: run_tests::handle_test,
|
|
|
|
|
},
|
2026-03-27 10:49:39 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "loc",
|
2026-03-28 08:56:06 +00:00
|
|
|
description: "Show top source files by line count: `loc` (top 10), `loc <N>`, or `loc <filepath>` for a specific file",
|
2026-03-27 10:49:39 +00:00
|
|
|
handler: loc::handle_loc,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "move",
|
|
|
|
|
description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
|
|
|
|
|
handler: move_story::handle_move,
|
|
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-03-24 15:00:08 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "rmtree",
|
|
|
|
|
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
|
|
|
|
|
handler: handle_rmtree_fallback,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "reset",
|
|
|
|
|
description: "Clear the current Claude Code session and start fresh",
|
|
|
|
|
handler: handle_reset_fallback,
|
|
|
|
|
},
|
|
|
|
|
BotCommand {
|
|
|
|
|
name: "rebuild",
|
|
|
|
|
description: "Rebuild the server binary and restart",
|
|
|
|
|
handler: handle_rebuild_fallback,
|
|
|
|
|
},
|
2026-03-28 08:59:36 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "timer",
|
|
|
|
|
description: "Schedule a deferred agent start: `timer <story_id> <HH:MM>`, `timer list`, `timer cancel <story_id>`",
|
|
|
|
|
handler: timer::handle_timer,
|
|
|
|
|
},
|
2026-03-28 09:01:09 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "unblock",
|
|
|
|
|
description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
|
|
|
|
|
handler: unblock::handle_unblock,
|
|
|
|
|
},
|
2026-03-24 22:20:19 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "unreleased",
|
|
|
|
|
description: "Show stories merged to master since the last release tag",
|
|
|
|
|
handler: unreleased::handle_unreleased,
|
|
|
|
|
},
|
2026-03-28 14:21:13 +00:00
|
|
|
BotCommand {
|
|
|
|
|
name: "setup",
|
2026-03-29 00:42:57 +00:00
|
|
|
description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
2026-03-28 14:21:13 +00:00
|
|
|
handler: setup::handle_setup,
|
|
|
|
|
},
|
2026-03-22 19:07:07 +00:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:55:01 +00:00
|
|
|
/// Like [`try_handle_command`] but returns `(plain_body, html_body)`.
|
|
|
|
|
///
|
|
|
|
|
/// The plain body is unchanged Markdown text suitable for the Matrix `body`
|
|
|
|
|
/// field (non-HTML clients). The HTML body is suitable for `formatted_body`.
|
|
|
|
|
///
|
|
|
|
|
/// The pipeline-status command (no args) injects Matrix `<font data-mx-color>`
|
|
|
|
|
/// tags on the traffic-light dots. All other commands produce HTML by running
|
|
|
|
|
/// the plain body through pulldown-cmark.
|
|
|
|
|
pub fn try_handle_command_with_html(
|
|
|
|
|
dispatch: &CommandDispatch<'_>,
|
|
|
|
|
message: &str,
|
|
|
|
|
) -> Option<(String, String)> {
|
|
|
|
|
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
|
|
|
|
|
let trimmed = command_text.trim();
|
|
|
|
|
if !trimmed.is_empty() {
|
|
|
|
|
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
|
|
|
|
|
Some((c, a)) => (c, a.trim()),
|
|
|
|
|
None => (trimmed, ""),
|
|
|
|
|
};
|
2026-04-07 15:51:09 +00:00
|
|
|
// Status command: emoji indicators render natively in all clients.
|
2026-03-28 13:55:01 +00:00
|
|
|
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
|
|
|
|
|
let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
|
2026-04-07 15:51:09 +00:00
|
|
|
let html = plain_to_html(&body);
|
2026-03-28 13:55:01 +00:00
|
|
|
return Some((body, html));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Generic path: plain text body → Markdown-to-HTML.
|
|
|
|
|
let body = try_handle_command(dispatch, message)?;
|
|
|
|
|
let html = plain_to_html(&body);
|
|
|
|
|
Some((body, html))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a Markdown string to HTML using the same options as the Matrix
|
|
|
|
|
/// transport's `markdown_to_html` helper.
|
|
|
|
|
fn plain_to_html(markdown: &str) -> String {
|
|
|
|
|
use pulldown_cmark::{Options, Parser, html};
|
|
|
|
|
let normalized = crate::chat::util::normalize_line_breaks(markdown);
|
|
|
|
|
let options = Options::ENABLE_TABLES
|
|
|
|
|
| Options::ENABLE_FOOTNOTES
|
|
|
|
|
| Options::ENABLE_STRIKETHROUGH
|
|
|
|
|
| Options::ENABLE_TASKLISTS;
|
|
|
|
|
let parser = Parser::new_ext(&normalized, options);
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
html::push_html(&mut out, parser);
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// Try to match a user message against a registered bot command.
|
|
|
|
|
///
|
2026-03-25 14:43:28 +00:00
|
|
|
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
|
|
|
|
|
/// The bot mention prefix is stripped before matching.
|
2026-03-22 19:07:07 +00:00
|
|
|
///
|
|
|
|
|
/// 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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
commands()
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|c| c.name == cmd_lower)
|
|
|
|
|
.and_then(|c| (c.handler)(&ctx))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:00:08 +00:00
|
|
|
/// Fallback handler for the `rmtree` command when it is not intercepted by
|
|
|
|
|
/// the async handler in `on_room_message`. In practice this is never called —
|
|
|
|
|
/// rmtree 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 "rmtree" as a prompt.
|
|
|
|
|
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<String> {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fallback handler for the `rebuild` command when it is not intercepted by
|
|
|
|
|
/// the async handler in `on_room_message`. In practice this is never called —
|
|
|
|
|
/// rebuild 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 "rebuild" as a prompt.
|
|
|
|
|
fn handle_rebuild_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>>>,
|
|
|
|
|
) -> 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,
|
|
|
|
|
};
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-export commands() for submodule tests
|
|
|
|
|
pub use super::commands;
|
|
|
|
|
|
|
|
|
|
// -- 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:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|