storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
@@ -1,470 +0,0 @@
|
||||
//! 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 assign;
|
||||
mod cost;
|
||||
mod git;
|
||||
mod help;
|
||||
mod move_story;
|
||||
mod overview;
|
||||
mod show;
|
||||
mod status;
|
||||
mod whatsup;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
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: "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: "whatsup",
|
||||
description: "Show in-progress triage dump for a story: `whatsup <number>`",
|
||||
handler: whatsup::handle_whatsup,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
// -- 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user