storkit: create 365_story_surface_api_rate_limit_warnings_in_chat
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,171 +0,0 @@
|
||||
//! Handler for the `ambient` command.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::matrix::config::save_ambient_rooms;
|
||||
|
||||
/// Toggle ambient mode for this room.
|
||||
///
|
||||
/// Works whether or not the message directly addressed the bot — the user can
|
||||
/// say "timmy ambient on", "@timmy ambient on", or just "ambient on" in an
|
||||
/// ambient-mode room. The command is specific enough (must be the first word
|
||||
/// after any bot-mention prefix) that accidental triggering is very unlikely.
|
||||
pub(super) fn handle_ambient(ctx: &CommandContext) -> Option<String> {
|
||||
let enable = match ctx.args {
|
||||
"on" => true,
|
||||
"off" => false,
|
||||
_ => return Some("Usage: `ambient on` or `ambient off`".to_string()),
|
||||
};
|
||||
let room_ids: Vec<String> = {
|
||||
let mut ambient = ctx.ambient_rooms.lock().unwrap();
|
||||
if enable {
|
||||
ambient.insert(ctx.room_id.to_string());
|
||||
} else {
|
||||
ambient.remove(ctx.room_id);
|
||||
}
|
||||
ambient.iter().cloned().collect()
|
||||
};
|
||||
save_ambient_rooms(ctx.project_root, &room_ids);
|
||||
let msg = if enable {
|
||||
"Ambient mode on. I'll respond to all messages in this room."
|
||||
} else {
|
||||
"Ambient mode off. I'll only respond when mentioned."
|
||||
};
|
||||
Some(msg.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
|
||||
Arc::new(Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn test_agents() -> Arc<AgentPool> {
|
||||
Arc::new(AgentPool::new_test(3000))
|
||||
}
|
||||
|
||||
// Bug 352: ambient commands were being forwarded to LLM after refactors
|
||||
// 328/330 because handle_ambient required is_addressed=true, but
|
||||
// mentions_bot() only matches @-prefixed mentions, not bare bot names.
|
||||
// "timmy ambient off" sets is_addressed=false even though it names the bot.
|
||||
#[test]
|
||||
fn ambient_on_works_when_unaddressed() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
let agents = test_agents();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
|
||||
// is_addressed is false; strip_bot_mention still strips "timmy ".
|
||||
let result = try_handle_command(&dispatch, "timmy ambient on");
|
||||
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
|
||||
assert!(
|
||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be in ambient_rooms after ambient on"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_off_works_bare_in_ambient_room() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
ambient_rooms.lock().unwrap().insert(room_id.clone());
|
||||
let agents = test_agents();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
// Bare "ambient off" in an ambient room (is_addressed=false).
|
||||
let result = try_handle_command(&dispatch, "ambient off");
|
||||
assert!(result.is_some(), "bare ambient off should be handled without LLM");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode off"),
|
||||
"response should confirm ambient off: {output}"
|
||||
);
|
||||
assert!(
|
||||
!ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be removed from ambient_rooms after ambient off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_on_enables_ambient_mode() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let agents = test_agents();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy ambient on");
|
||||
assert!(result.is_some(), "ambient on should produce a response");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode on"),
|
||||
"response should confirm ambient on: {output}"
|
||||
);
|
||||
assert!(
|
||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be in ambient_rooms after ambient on"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_off_disables_ambient_mode() {
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let agents = test_agents();
|
||||
let room_id = "!myroom:example.com".to_string();
|
||||
// Pre-insert the room
|
||||
ambient_rooms.lock().unwrap().insert(room_id.clone());
|
||||
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: std::path::Path::new("/tmp"),
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy ambient off");
|
||||
assert!(result.is_some(), "ambient off should produce a response");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode off"),
|
||||
"response should confirm ambient off: {output}"
|
||||
);
|
||||
assert!(
|
||||
!ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be removed from ambient_rooms after ambient off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_invalid_args_returns_usage() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"invalid ambient args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
//! Handler for the `assign` command.
|
||||
//!
|
||||
//! `assign <number> <model>` pre-assigns a coder model (e.g. `opus`, `sonnet`)
|
||||
//! to a story before it starts. The assignment persists in the story file's
|
||||
//! front matter as `agent: coder-<model>` so that when the pipeline picks up
|
||||
//! the story — either via auto-assign or the `start` command — it uses the
|
||||
//! assigned model instead of the default.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
||||
|
||||
/// All pipeline stage directories to search when finding a work item by number.
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
|
||||
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
|
||||
/// it is returned unchanged to prevent double-prefixing.
|
||||
fn resolve_agent_name(model: &str) -> String {
|
||||
if model.starts_with("coder-") {
|
||||
model.to_string()
|
||||
} else {
|
||||
format!("coder-{model}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_assign(ctx: &CommandContext) -> Option<String> {
|
||||
let args = ctx.args.trim();
|
||||
|
||||
// Parse `<number> <model>` from args.
|
||||
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
|
||||
Some((n, m)) => (n.trim(), m.trim()),
|
||||
None => {
|
||||
return Some(format!(
|
||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number `{number_str}`. Usage: `{} assign <number> <model>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
if model_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||
'outer: for stage in STAGES {
|
||||
let dir = ctx.project_root.join(".storkit").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == number_str {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story, bug, or spike with number **{number_str}** found."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
})
|
||||
.unwrap_or_else(|| story_id.clone());
|
||||
|
||||
let agent_name = resolve_agent_name(model_str);
|
||||
|
||||
// Write `agent: <agent_name>` into the story's front matter.
|
||||
let result = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read story file: {e}"))
|
||||
.and_then(|contents| {
|
||||
let updated = set_front_matter_field(&contents, "agent", &agent_name);
|
||||
std::fs::write(&path, &updated)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(()) => Some(format!(
|
||||
"Assigned **{agent_name}** to **{story_name}** (story {number_str}). \
|
||||
The model will be used when the story starts."
|
||||
)),
|
||||
Err(e) => Some(format!(
|
||||
"Failed to assign model to **{story_name}**: {e}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn assign_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy assign {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
// -- registration / help ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn assign_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "assign");
|
||||
assert!(found, "assign command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("assign"),
|
||||
"help should list assign command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- argument validation ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn assign_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = assign_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_missing_model_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = assign_cmd_with_root(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"missing model should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_non_numeric_number_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = assign_cmd_with_root(tmp.path(), "abc opus").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid story number"),
|
||||
"non-numeric number should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- story not found ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn assign_unknown_story_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Create stage dirs but no matching story.
|
||||
for stage in &["1_backlog", "2_current"] {
|
||||
std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap();
|
||||
}
|
||||
let output = assign_cmd_with_root(tmp.path(), "999 opus").unwrap();
|
||||
assert!(
|
||||
output.contains("999") && output.contains("found"),
|
||||
"not-found message should include number and 'found': {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- successful assignment ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn assign_writes_agent_field_to_front_matter() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"42_story_test_feature.md",
|
||||
"---\nname: Test Feature\n---\n\n# Story 42\n",
|
||||
);
|
||||
|
||||
let output = assign_cmd_with_root(tmp.path(), "42 opus").unwrap();
|
||||
assert!(
|
||||
output.contains("coder-opus"),
|
||||
"confirmation should include resolved agent name: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Test Feature"),
|
||||
"confirmation should include story name: {output}"
|
||||
);
|
||||
|
||||
// Verify the file was updated.
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path()
|
||||
.join(".storkit/work/1_backlog/42_story_test_feature.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("agent: coder-opus"),
|
||||
"front matter should contain agent field: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_with_sonnet_writes_coder_sonnet() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"10_story_current.md",
|
||||
"---\nname: Current Story\n---\n",
|
||||
);
|
||||
|
||||
assign_cmd_with_root(tmp.path(), "10 sonnet").unwrap();
|
||||
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path()
|
||||
.join(".storkit/work/2_current/10_story_current.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("agent: coder-sonnet"),
|
||||
"front matter should contain agent: coder-sonnet: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_with_already_prefixed_name_does_not_double_prefix() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"7_story_small.md",
|
||||
"---\nname: Small Story\n---\n",
|
||||
);
|
||||
|
||||
let output = assign_cmd_with_root(tmp.path(), "7 coder-opus").unwrap();
|
||||
assert!(
|
||||
output.contains("coder-opus"),
|
||||
"should not double-prefix: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("coder-coder-opus"),
|
||||
"must not double-prefix: {output}"
|
||||
);
|
||||
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".storkit/work/1_backlog/7_story_small.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("agent: coder-opus"),
|
||||
"must write coder-opus, not coder-coder-opus: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_overwrites_existing_agent_field() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"5_story_existing.md",
|
||||
"---\nname: Existing\nagent: coder-sonnet\n---\n",
|
||||
);
|
||||
|
||||
assign_cmd_with_root(tmp.path(), "5 opus").unwrap();
|
||||
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path()
|
||||
.join(".storkit/work/1_backlog/5_story_existing.md"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("agent: coder-opus"),
|
||||
"should overwrite old agent with new: {contents}"
|
||||
);
|
||||
assert!(
|
||||
!contents.contains("coder-sonnet"),
|
||||
"old agent should no longer appear: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_finds_story_in_any_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Story is in 3_qa/, not backlog.
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"3_qa",
|
||||
"99_story_in_qa.md",
|
||||
"---\nname: In QA\n---\n",
|
||||
);
|
||||
|
||||
let output = assign_cmd_with_root(tmp.path(), "99 opus").unwrap();
|
||||
assert!(
|
||||
output.contains("coder-opus"),
|
||||
"should find story in qa stage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- resolve_agent_name unit tests --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_name_prefixes_bare_model() {
|
||||
assert_eq!(super::resolve_agent_name("opus"), "coder-opus");
|
||||
assert_eq!(super::resolve_agent_name("sonnet"), "coder-sonnet");
|
||||
assert_eq!(super::resolve_agent_name("haiku"), "coder-haiku");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_name_does_not_double_prefix() {
|
||||
assert_eq!(super::resolve_agent_name("coder-opus"), "coder-opus");
|
||||
assert_eq!(super::resolve_agent_name("coder-sonnet"), "coder-sonnet");
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
//! Handler for the `cost` command.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::status::story_short_label;
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
||||
/// all-time total.
|
||||
pub(super) fn handle_cost(ctx: &CommandContext) -> Option<String> {
|
||||
let records = match crate::agents::token_usage::read_all(ctx.project_root) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Some(format!("Failed to read token usage: {e}")),
|
||||
};
|
||||
|
||||
if records.is_empty() {
|
||||
return Some("**Token Spend**\n\nNo usage records found.".to_string());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let cutoff = now - chrono::Duration::hours(24);
|
||||
|
||||
// Partition into 24h window and all-time
|
||||
let mut recent = Vec::new();
|
||||
let mut all_time_cost = 0.0;
|
||||
for r in &records {
|
||||
all_time_cost += r.usage.total_cost_usd;
|
||||
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&r.timestamp)
|
||||
&& ts >= cutoff
|
||||
{
|
||||
recent.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// 24h total
|
||||
let recent_cost: f64 = recent.iter().map(|r| r.usage.total_cost_usd).sum();
|
||||
|
||||
let mut out = String::from("**Token Spend**\n\n");
|
||||
out.push_str(&format!("**Last 24h:** ${:.2}\n", recent_cost));
|
||||
out.push_str(&format!("**All-time:** ${:.2}\n\n", all_time_cost));
|
||||
|
||||
// Top 5 most expensive stories (last 24h)
|
||||
let mut story_costs: HashMap<&str, f64> = HashMap::new();
|
||||
for r in &recent {
|
||||
*story_costs.entry(r.story_id.as_str()).or_default() += r.usage.total_cost_usd;
|
||||
}
|
||||
let mut story_list: Vec<(&str, f64)> = story_costs.into_iter().collect();
|
||||
story_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
story_list.truncate(5);
|
||||
|
||||
out.push_str("**Top Stories (24h)**\n");
|
||||
if story_list.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (story_id, cost) in &story_list {
|
||||
let label = story_short_label(story_id, None);
|
||||
out.push_str(&format!(" • {label} — ${cost:.2}\n"));
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
|
||||
// Breakdown by agent type (last 24h)
|
||||
// Agent names follow pattern "coder-1", "qa-1", "mergemaster" — extract
|
||||
// the type as everything before the last '-' digit, or the full name.
|
||||
let mut type_costs: HashMap<String, f64> = HashMap::new();
|
||||
for r in &recent {
|
||||
let agent_type = extract_agent_type(&r.agent_name);
|
||||
*type_costs.entry(agent_type).or_default() += r.usage.total_cost_usd;
|
||||
}
|
||||
let mut type_list: Vec<(String, f64)> = type_costs.into_iter().collect();
|
||||
type_list.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
out.push_str("**By Agent Type (24h)**\n");
|
||||
if type_list.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (agent_type, cost) in &type_list {
|
||||
out.push_str(&format!(" • {agent_type} — ${cost:.2}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Extract the agent type from an agent name.
|
||||
///
|
||||
/// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder",
|
||||
/// "qa", "mergemaster". If the name ends with `-<digits>`, strip the suffix.
|
||||
pub(super) fn extract_agent_type(agent_name: &str) -> String {
|
||||
if let Some(pos) = agent_name.rfind('-') {
|
||||
let suffix = &agent_name[pos + 1..];
|
||||
if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return agent_name[..pos].to_string();
|
||||
}
|
||||
}
|
||||
agent_name.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
|
||||
for r in records {
|
||||
crate::agents::token_usage::append_record(root, r).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn make_usage(cost: f64) -> crate::agents::TokenUsage {
|
||||
crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: cost,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
||||
crate::agents::token_usage::TokenUsageRecord {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
timestamp: ts,
|
||||
model: None,
|
||||
usage: make_usage(cost),
|
||||
}
|
||||
}
|
||||
|
||||
fn cost_cmd_with_root(root: &std::path::Path) -> Option<String> {
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, "@timmy cost")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "cost");
|
||||
assert!(found, "cost command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("cost"), "help should list cost command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_no_records() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("No usage records found"), "should show empty message: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_24h_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_excludes_old_from_24h() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
|
||||
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_top_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
|
||||
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
||||
let pos_42 = output.find("42").unwrap();
|
||||
let pos_43 = output.find("43").unwrap();
|
||||
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_limits_to_5_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut records = Vec::new();
|
||||
for i in 1..=7 {
|
||||
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
|
||||
}
|
||||
write_token_records(tmp.path(), &records);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
||||
let top_section = output.split("**By Agent Type").next().unwrap();
|
||||
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
|
||||
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_agent_type_breakdown() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
|
||||
assert!(output.contains("coder"), "should show coder type: {output}");
|
||||
assert!(output.contains("qa"), "should show qa type: {output}");
|
||||
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_all_time_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
]);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
|
||||
assert!(result.is_some(), "COST should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- extract_agent_type -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_agent_type_strips_numeric_suffix() {
|
||||
assert_eq!(extract_agent_type("coder-1"), "coder");
|
||||
assert_eq!(extract_agent_type("qa-2"), "qa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_agent_type_keeps_non_numeric_suffix() {
|
||||
assert_eq!(extract_agent_type("mergemaster"), "mergemaster");
|
||||
assert_eq!(extract_agent_type("coder-alpha"), "coder-alpha");
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
//! Handler for the `git` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show compact git status: branch, uncommitted files, ahead/behind remote.
|
||||
pub(super) fn handle_git(ctx: &CommandContext) -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Current branch
|
||||
let branch = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Porcelain status for staged + unstaged changes
|
||||
let status_output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let changed_files: Vec<&str> = status_output.lines().filter(|l| !l.is_empty()).collect();
|
||||
let change_count = changed_files.len();
|
||||
|
||||
// Ahead/behind: --left-right gives "N\tM" (ahead\tbehind)
|
||||
let ahead_behind = Command::new("git")
|
||||
.args(["rev-list", "--count", "--left-right", "HEAD...@{u}"])
|
||||
.current_dir(ctx.project_root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| {
|
||||
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
let mut parts = s.split_whitespace();
|
||||
let ahead: u32 = parts.next()?.parse().ok()?;
|
||||
let behind: u32 = parts.next()?.parse().ok()?;
|
||||
Some((ahead, behind))
|
||||
});
|
||||
|
||||
let mut out = format!("**Branch:** `{branch}`\n");
|
||||
|
||||
if change_count == 0 {
|
||||
out.push_str("**Changes:** clean\n");
|
||||
} else {
|
||||
out.push_str(&format!("**Changes:** {change_count} file(s)\n"));
|
||||
for line in &changed_files {
|
||||
// Porcelain format: "XY filename" (2-char status + space + path)
|
||||
if line.len() > 3 {
|
||||
let codes = &line[..2];
|
||||
let name = line[3..].trim();
|
||||
out.push_str(&format!(" • `{codes}` {name}\n"));
|
||||
} else {
|
||||
out.push_str(&format!(" • {line}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match ahead_behind {
|
||||
Some((0, 0)) => out.push_str("**Remote:** up to date\n"),
|
||||
Some((ahead, 0)) => out.push_str(&format!("**Remote:** ↑{ahead} ahead\n")),
|
||||
Some((0, behind)) => out.push_str(&format!("**Remote:** ↓{behind} behind\n")),
|
||||
Some((ahead, behind)) => {
|
||||
out.push_str(&format!("**Remote:** ↑{ahead} ahead, ↓{behind} behind\n"));
|
||||
}
|
||||
None => out.push_str("**Remote:** no tracking branch\n"),
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
|
||||
Arc::new(Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn test_agents() -> Arc<AgentPool> {
|
||||
Arc::new(AgentPool::new_test(3000))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "git");
|
||||
assert!(found, "git command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("git"), "help should list git command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_returns_some() {
|
||||
// Run from the actual repo root so git commands have a real repo to query.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let result = try_handle_command(&dispatch, "@timmy git");
|
||||
assert!(result.is_some(), "git command should always return Some");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_branch() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Branch:**"),
|
||||
"git output should contain branch info: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_changes() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Changes:**"),
|
||||
"git output should contain changes section: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_output_contains_remote() {
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let agents = test_agents();
|
||||
let ambient_rooms = test_ambient_rooms();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: repo_root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
let output = try_handle_command(&dispatch, "@timmy git").unwrap();
|
||||
assert!(
|
||||
output.contains("**Remote:**"),
|
||||
"git output should contain remote section: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
||||
assert!(result.is_some(), "GIT should match case-insensitively");
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//! Handler for the `help` command.
|
||||
|
||||
use super::{commands, CommandContext};
|
||||
|
||||
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
||||
let mut sorted: Vec<_> = commands().iter().collect();
|
||||
sorted.sort_by_key(|c| c.name);
|
||||
for cmd in sorted {
|
||||
output.push_str(&format!("- **{}** — {}\n", cmd.name, cmd.description));
|
||||
}
|
||||
Some(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{try_cmd_addressed, commands};
|
||||
|
||||
#[test]
|
||||
fn help_command_matches() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
assert!(result.is_some(), "help command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_command_case_insensitive() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy HELP");
|
||||
assert!(result.is_some(), "HELP should match case-insensitively");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_contains_all_commands() {
|
||||
let result = try_cmd_addressed("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_cmd_addressed("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_cmd_addressed("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 help_output_includes_status() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("status"), "help should list status command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_is_alphabetical() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
// Search for **name** (bold markdown) to avoid substring matches in descriptions.
|
||||
let mut positions: Vec<(usize, &str)> = commands()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let marker = format!("**{}**", c.name);
|
||||
let pos = output.find(&marker).expect("command must appear in help as **name**");
|
||||
(pos, c.name)
|
||||
})
|
||||
.collect();
|
||||
positions.sort_by_key(|(pos, _)| *pos);
|
||||
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
|
||||
let mut sorted = names_in_order.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_ambient() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("ambient"), "help should list ambient command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_htop() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("htop"), "help should list htop command: {output}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
//! Handler for the `move` command.
|
||||
//!
|
||||
//! `{bot_name} move {number} {stage}` finds the work item by number across all
|
||||
//! pipeline stages, moves it to the specified stage, and returns a confirmation
|
||||
//! with the story title, old stage, and new stage.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::agents::move_story_to_stage;
|
||||
|
||||
/// Valid stage names accepted by the move command.
|
||||
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
|
||||
|
||||
/// All pipeline stage directories to search when finding a work item by number.
|
||||
const SEARCH_DIRS: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
/// Handle the `move` command.
|
||||
///
|
||||
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
|
||||
/// numeric prefix, moves it to the target stage using the shared lifecycle
|
||||
/// function, and returns a Markdown confirmation string.
|
||||
pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
||||
let args = ctx.args.trim();
|
||||
|
||||
// Parse `number stage` from args.
|
||||
let (num_str, stage_raw) = match args.split_once(char::is_whitespace) {
|
||||
Some((n, s)) => (n.trim(), s.trim()),
|
||||
None => {
|
||||
return Some(format!(
|
||||
"Usage: `{} move <number> <stage>`\n\nValid stages: {}",
|
||||
ctx.bot_name,
|
||||
VALID_STAGES.join(", ")
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} move <number> <stage>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let target_stage = stage_raw.to_ascii_lowercase();
|
||||
if !VALID_STAGES.contains(&target_stage.as_str()) {
|
||||
return Some(format!(
|
||||
"Invalid stage: `{stage_raw}`. Valid stages: {}",
|
||||
VALID_STAGES.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
// Find the story file across all pipeline stages by numeric prefix.
|
||||
let mut found_story_id: Option<String> = None;
|
||||
let mut found_name: Option<String> = None;
|
||||
|
||||
'outer: for stage_dir in SEARCH_DIRS {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join(stage_dir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
found_story_id = Some(stem.to_string());
|
||||
found_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let story_id = match found_story_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let display_name = found_name.as_deref().unwrap_or(&story_id);
|
||||
|
||||
match move_story_to_stage(ctx.project_root, &story_id, &target_stage) {
|
||||
Ok((from_stage, to_stage)) => Some(format!(
|
||||
"Moved **{display_name}** from **{from_stage}** to **{to_stage}**."
|
||||
)),
|
||||
Err(e) => Some(format!("Failed to move story {num_str}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn move_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy move {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "move");
|
||||
assert!(found, "move command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("move"),
|
||||
"help should list move command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = move_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_missing_stage_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = move_cmd_with_root(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"missing stage should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_invalid_stage_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = move_cmd_with_root(tmp.path(), "42 invalid_stage").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid stage"),
|
||||
"invalid stage should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_non_numeric_number_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = move_cmd_with_root(tmp.path(), "abc current").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid story number"),
|
||||
"non-numeric number should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_not_found_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = move_cmd_with_root(tmp.path(), "999 current").unwrap();
|
||||
assert!(
|
||||
output.contains("999") && output.contains("found"),
|
||||
"not-found message should include number and 'found': {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_moves_story_and_confirms() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"42_story_some_feature.md",
|
||||
"---\nname: Some Feature\n---\n\n# Story 42\n",
|
||||
);
|
||||
|
||||
let output = move_cmd_with_root(tmp.path(), "42 current").unwrap();
|
||||
assert!(
|
||||
output.contains("Some Feature"),
|
||||
"confirmation should include story name: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("backlog"),
|
||||
"confirmation should include old stage: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("current"),
|
||||
"confirmation should include new stage: {output}"
|
||||
);
|
||||
|
||||
// Verify the file was actually moved.
|
||||
let new_path = tmp
|
||||
.path()
|
||||
.join(".storkit/work/2_current/42_story_some_feature.md");
|
||||
assert!(new_path.exists(), "story file should be in 2_current/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_case_insensitive_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"10_story_test.md",
|
||||
"---\nname: Test\n---\n",
|
||||
);
|
||||
let output = move_cmd_with_root(tmp.path(), "10 BACKLOG").unwrap();
|
||||
assert!(
|
||||
output.contains("Test") && output.contains("backlog"),
|
||||
"stage matching should be case-insensitive: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_idempotent_when_already_in_target() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"5_story_already_current.md",
|
||||
"---\nname: Already Current\n---\n",
|
||||
);
|
||||
// Moving to the stage it's already in should return a success message.
|
||||
let output = move_cmd_with_root(tmp.path(), "5 current").unwrap();
|
||||
assert!(
|
||||
output.contains("Moved") || output.contains("current"),
|
||||
"idempotent move should succeed: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_command_case_insensitive_command() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy MOVE 1 backlog",
|
||||
);
|
||||
// Returns Some (the registry matched, regardless of result content)
|
||||
assert!(result.is_some(), "MOVE should match case-insensitively");
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
//! Handler for the `overview` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Show implementation summary for a story identified by its number.
|
||||
///
|
||||
/// Finds the `storkit: merge {story_id}` commit on master, displays the
|
||||
/// git diff --stat (files changed with line counts), and extracts key
|
||||
/// function/struct/type names added or modified in the implementation.
|
||||
/// Returns a friendly message when no merge commit is found.
|
||||
pub(super) fn handle_overview(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} overview <number>`\n\nShows the implementation summary for a story.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} overview <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let commit_hash = match find_story_merge_commit(ctx.project_root, num_str) {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return Some(format!(
|
||||
"No implementation found for story **{num_str}**. \
|
||||
It may still be in the backlog or was never merged."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let stat_output = get_commit_stat(ctx.project_root, &commit_hash);
|
||||
let symbols = extract_diff_symbols(ctx.project_root, &commit_hash);
|
||||
let story_name = find_story_name(ctx.project_root, num_str);
|
||||
|
||||
let short_hash = &commit_hash[..commit_hash.len().min(8)];
|
||||
let mut out = match story_name {
|
||||
Some(name) => format!("**Overview: Story {num_str} — {name}**\n\n"),
|
||||
None => format!("**Overview: Story {num_str}**\n\n"),
|
||||
};
|
||||
out.push_str(&format!("Commit: `{short_hash}`\n\n"));
|
||||
|
||||
// Parse stat output: collect per-file lines and the summary line.
|
||||
let mut file_lines: Vec<String> = Vec::new();
|
||||
let mut summary_line = String::new();
|
||||
for line in stat_output.lines() {
|
||||
if line.contains("changed") && (line.contains("insertion") || line.contains("deletion")) {
|
||||
summary_line = line.trim().to_string();
|
||||
} else if !line.trim().is_empty() && line.contains('|') {
|
||||
file_lines.push(line.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !summary_line.is_empty() {
|
||||
out.push_str(&format!("**Changes:** {summary_line}\n"));
|
||||
}
|
||||
|
||||
if !file_lines.is_empty() {
|
||||
out.push_str("**Files:**\n");
|
||||
for f in file_lines.iter().take(8) {
|
||||
out.push_str(&format!(" • `{f}`\n"));
|
||||
}
|
||||
if file_lines.len() > 8 {
|
||||
out.push_str(&format!(" … and {} more\n", file_lines.len() - 8));
|
||||
}
|
||||
}
|
||||
|
||||
if !symbols.is_empty() {
|
||||
out.push_str("\n**Key symbols:**\n");
|
||||
for sym in &symbols {
|
||||
out.push_str(&format!(" • {sym}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Find the merge commit hash for a story by its numeric ID.
|
||||
///
|
||||
/// Searches git log for a commit whose subject matches
|
||||
/// `storkit: merge {num}_*` or the legacy `story-kit: merge {num}_*`.
|
||||
fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
use std::process::Command;
|
||||
// Match both the current prefix and the legacy one from before the rename.
|
||||
let grep_pattern = format!("(storkit|story-kit): merge {num_str}_");
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H",
|
||||
"--all",
|
||||
"--extended-regexp",
|
||||
"--grep",
|
||||
&grep_pattern,
|
||||
])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let hash = text.lines().next()?.trim().to_string();
|
||||
if hash.is_empty() { None } else { Some(hash) }
|
||||
}
|
||||
|
||||
/// Find the human-readable name of a story by searching all pipeline stages.
|
||||
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in &stages {
|
||||
let dir = root.join(".storkit").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return std::fs::read_to_string(&path).ok().and_then(|c| {
|
||||
crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Return the `git show --stat` output for a commit.
|
||||
fn get_commit_stat(root: &std::path::Path, hash: &str) -> String {
|
||||
use std::process::Command;
|
||||
Command::new("git")
|
||||
.args(["show", "--stat", hash])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract up to 12 unique top-level symbol definitions from a commit diff.
|
||||
///
|
||||
/// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`,
|
||||
/// and `impl` declarations and returns them formatted as `` `Name` (kind) ``.
|
||||
fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new("git")
|
||||
.args(["show", hash])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut symbols: Vec<String> = Vec::new();
|
||||
for line in output.lines() {
|
||||
if !line.starts_with('+') || line.starts_with("+++") {
|
||||
continue;
|
||||
}
|
||||
if let Some(sym) = parse_symbol_definition(&line[1..]) {
|
||||
if !symbols.contains(&sym) {
|
||||
symbols.push(sym);
|
||||
}
|
||||
if symbols.len() >= 12 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
symbols
|
||||
}
|
||||
|
||||
/// Parse a single line of code and return a formatted symbol if it opens a
|
||||
/// top-level Rust definition (`fn`, `struct`, `enum`, `type`, `trait`, `impl`).
|
||||
fn parse_symbol_definition(code: &str) -> Option<String> {
|
||||
let t = code.trim();
|
||||
let patterns: &[(&str, &str)] = &[
|
||||
("pub async fn ", "fn"),
|
||||
("async fn ", "fn"),
|
||||
("pub fn ", "fn"),
|
||||
("fn ", "fn"),
|
||||
("pub struct ", "struct"),
|
||||
("struct ", "struct"),
|
||||
("pub enum ", "enum"),
|
||||
("enum ", "enum"),
|
||||
("pub type ", "type"),
|
||||
("type ", "type"),
|
||||
("pub trait ", "trait"),
|
||||
("trait ", "trait"),
|
||||
("impl ", "impl"),
|
||||
];
|
||||
for (prefix, kind) in patterns {
|
||||
if let Some(rest) = t.strip_prefix(prefix) {
|
||||
let name: String = rest
|
||||
.chars()
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||
.collect();
|
||||
if !name.is_empty() {
|
||||
return Some(format!("`{name}` ({kind})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn overview_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy overview {args}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "overview");
|
||||
assert!(found, "overview command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("overview"),
|
||||
"help should list overview command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = overview_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_non_numeric_arg_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = overview_cmd_with_root(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_not_found_returns_friendly_message() {
|
||||
// Use the real repo root but a story number that was never merged.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let output = overview_cmd_with_root(repo_root, "99999").unwrap();
|
||||
assert!(
|
||||
output.contains("99999"),
|
||||
"not-found message should include the story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("backlog") || output.contains("No implementation"),
|
||||
"not-found message should explain why: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_found_shows_commit_and_stat() {
|
||||
// Story 324 has a real merge commit in master.
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let output = overview_cmd_with_root(repo_root, "324").unwrap();
|
||||
assert!(
|
||||
output.contains("**Overview: Story 324"),
|
||||
"output should show story header: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Commit:"),
|
||||
"output should show commit hash: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("**Changes:**") || output.contains("**Files:**"),
|
||||
"output should show file changes: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy OVERVIEW 1",
|
||||
);
|
||||
assert!(result.is_some(), "OVERVIEW should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- parse_symbol_definition --------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_fn() {
|
||||
let result =
|
||||
parse_symbol_definition("pub fn handle_foo(ctx: &Context) -> Option<String> {");
|
||||
assert_eq!(result, Some("`handle_foo` (fn)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_struct() {
|
||||
let result = parse_symbol_definition("pub struct SlackTransport {");
|
||||
assert_eq!(result, Some("`SlackTransport` (struct)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_impl() {
|
||||
let result = parse_symbol_definition("impl ChatTransport for SlackTransport {");
|
||||
assert_eq!(result, Some("`ChatTransport` (impl)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_no_match() {
|
||||
let result = parse_symbol_definition(" let x = 42;");
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_enum() {
|
||||
let result = parse_symbol_definition("pub enum QaMode {");
|
||||
assert_eq!(result, Some("`QaMode` (enum)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_pub_type() {
|
||||
let result = parse_symbol_definition(
|
||||
"pub type SlackHistory = Arc<Mutex<HashMap<String, Vec<u8>>>>;",
|
||||
);
|
||||
assert_eq!(result, Some("`SlackHistory` (type)".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! Handler for the `show` command.
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
/// Display the full markdown text of a work item identified by its numeric ID.
|
||||
///
|
||||
/// Searches all pipeline stages in order and returns the raw file contents of
|
||||
/// the first matching story, bug, or spike. Returns a friendly message when
|
||||
/// no match is found.
|
||||
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} show <number>`\n\nDisplays the full text of a story, bug, or spike.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} show <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
for stage in &stages {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => Some(contents),
|
||||
Err(e) => Some(format!("Failed to read story {num_str}: {e}")),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"No story, bug, or spike with number **{num_str}** found."
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn show_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy show {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "show");
|
||||
assert!(found, "show command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("show"), "help should list show command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage hint: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_non_numeric_args_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_not_found_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = show_cmd_with_root(tmp.path(), "999").unwrap();
|
||||
assert!(
|
||||
output.contains("999"),
|
||||
"not-found message should include the queried number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("found"),
|
||||
"not-found message should say not found: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_story_in_backlog() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"305_story_show_command.md",
|
||||
"---\nname: Show command\n---\n\n# Story 305\n\nFull story text here.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "305").unwrap();
|
||||
assert!(
|
||||
output.contains("Full story text here."),
|
||||
"show should return full story content: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_story_in_current() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"42_story_do_something.md",
|
||||
"---\nname: Do something\n---\n\n# Story 42\n\nIn progress.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("In progress."),
|
||||
"show should return story from current stage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_finds_bug() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"7_bug_crash_on_login.md",
|
||||
"---\nname: Crash on login\n---\n\n## Symptom\n\nCrashes.",
|
||||
);
|
||||
let output = show_cmd_with_root(tmp.path(), "7").unwrap();
|
||||
assert!(
|
||||
output.contains("Symptom"),
|
||||
"show should return bug content: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
|
||||
assert!(result.is_some(), "SHOW should match case-insensitively");
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
//! Handler for the `status` command and pipeline status helpers.
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::config::ProjectConfig;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::CommandContext;
|
||||
|
||||
pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
||||
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
||||
}
|
||||
|
||||
/// Format a short display label for a work item.
|
||||
///
|
||||
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
|
||||
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
|
||||
/// readable name from the front matter when available.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
|
||||
/// - `("293_story_foo", None)` → `"293"`
|
||||
/// - `("no_number_here", None)` → `"no_number_here"`
|
||||
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
||||
let number = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or(stem);
|
||||
match name {
|
||||
Some(n) => format!("{number} — {n}"),
|
||||
None => number.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read all story IDs and names from a pipeline stage directory.
|
||||
fn read_stage_items(
|
||||
project_root: &std::path::Path,
|
||||
stage_dir: &str,
|
||||
) -> Vec<(String, Option<String>)> {
|
||||
let dir = project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join(stage_dir);
|
||||
if !dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
});
|
||||
items.push((stem.to_string(), name));
|
||||
}
|
||||
}
|
||||
}
|
||||
items.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
items
|
||||
}
|
||||
|
||||
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||
let active_agents = agents.list_agents().unwrap_or_default();
|
||||
let active_map: HashMap<String, &crate::agents::AgentInfo> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| (a.story_id.clone(), a))
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> =
|
||||
crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
let mut out = String::from("**Pipeline Status**\n\n");
|
||||
|
||||
let stages = [
|
||||
("1_backlog", "Backlog"),
|
||||
("2_current", "In Progress"),
|
||||
("3_qa", "QA"),
|
||||
("4_merge", "Merge"),
|
||||
("5_done", "Done"),
|
||||
];
|
||||
|
||||
for (dir, label) in &stages {
|
||||
let items = read_stage_items(project_root, dir);
|
||||
let count = items.len();
|
||||
out.push_str(&format!("**{label}** ({count})\n"));
|
||||
if items.is_empty() {
|
||||
out.push_str(" *(none)*\n");
|
||||
} else {
|
||||
for (story_id, name) in &items {
|
||||
let display = story_short_label(story_id, name.as_deref());
|
||||
let cost_suffix = cost_by_story
|
||||
.get(story_id)
|
||||
.filter(|&&c| c > 0.0)
|
||||
.map(|c| format!(" — ${c:.2}"))
|
||||
.unwrap_or_default();
|
||||
if let Some(agent) = active_map.get(story_id) {
|
||||
let model_str = config
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||
.and_then(|ac| ac.model.as_deref())
|
||||
.unwrap_or("?");
|
||||
out.push_str(&format!(
|
||||
" • {display}{cost_suffix} — {} ({model_str})\n",
|
||||
agent.agent_name
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(" • {display}{cost_suffix}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Free agents: configured agents not currently running or pending.
|
||||
out.push_str("**Free Agents**\n");
|
||||
if let Some(cfg) = &config {
|
||||
let busy_names: HashSet<String> = active_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.map(|a| a.agent_name.clone())
|
||||
.collect();
|
||||
|
||||
let free: Vec<String> = cfg
|
||||
.agent
|
||||
.iter()
|
||||
.filter(|a| !busy_names.contains(&a.name))
|
||||
.map(|a| match &a.model {
|
||||
Some(m) => format!("{} ({})", a.name, m),
|
||||
None => a.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if free.is_empty() {
|
||||
out.push_str(" *(none — all agents busy)*\n");
|
||||
} else {
|
||||
for name in &free {
|
||||
out.push_str(&format!(" • {name}\n"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push_str(" *(no agent config found)*\n");
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
|
||||
#[test]
|
||||
fn status_command_matches() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
assert!(result.is_some(), "status command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_returns_pipeline_text() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
"status output should contain pipeline info: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||
}
|
||||
|
||||
// -- story_short_label --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn short_label_extracts_number_and_name() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
||||
assert_eq!(label, "293 — Register all bot commands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_number_only_when_no_name() {
|
||||
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
||||
assert_eq!(label, "297");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_falls_back_to_stem_when_no_numeric_prefix() {
|
||||
let label = story_short_label("no_number_here", None);
|
||||
assert_eq!(label, "no_number_here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_does_not_include_underscore_slug() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
|
||||
assert!(
|
||||
!label.contains("story_register"),
|
||||
"label should not contain the slug portion: {label}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- build_pipeline_status formatting -----------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_does_not_show_full_filename_stem() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
// Write a story file with a front-matter name
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
!output.contains("293_story_register_all_bot_commands"),
|
||||
"output must not show full filename stem: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands"),
|
||||
"output must show number and title: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- token cost in status output ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn status_shows_cost_when_token_usage_exists() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// Write token usage for this story.
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.29,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands — $0.29"),
|
||||
"output must show cost next to story: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_no_cost_when_no_usage() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// No token usage written.
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
!output.contains("$"),
|
||||
"output must not show cost when no usage exists: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_aggregates_multiple_records_per_story() {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let stage_dir = tmp.path().join(".storkit/work/2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
|
||||
let story_path = stage_dir.join("293_story_register_all_bot_commands.md");
|
||||
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||
writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap();
|
||||
|
||||
// Write two records for the same story — costs should be summed.
|
||||
for cost in [0.10, 0.19] {
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: cost,
|
||||
};
|
||||
let record = crate::agents::token_usage::build_record(
|
||||
"293_story_register_all_bot_commands",
|
||||
"coder-1",
|
||||
None,
|
||||
usage,
|
||||
);
|
||||
crate::agents::token_usage::append_record(tmp.path(), &record).unwrap();
|
||||
}
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
let output = build_pipeline_status(tmp.path(), &agents);
|
||||
|
||||
assert!(
|
||||
output.contains("293 — Register all bot commands — $0.29"),
|
||||
"output must show aggregated cost: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
//! Handler for the `whatsup` command.
|
||||
//!
|
||||
//! Produces a triage dump for a story that is currently in-progress
|
||||
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
|
||||
//! git diff, recent commits, and the tail of the agent log.
|
||||
//!
|
||||
//! The command is handled entirely at the bot level — no LLM invocation.
|
||||
|
||||
use super::CommandContext;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Handle `{bot_name} whatsup {number}`.
|
||||
pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option<String> {
|
||||
let num_str = ctx.args.trim();
|
||||
if num_str.is_empty() {
|
||||
return Some(format!(
|
||||
"Usage: `{} whatsup <number>`\n\nShows a triage dump for a story currently in progress.",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
if !num_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(format!(
|
||||
"Invalid story number: `{num_str}`. Usage: `{} whatsup <number>`",
|
||||
ctx.bot_name
|
||||
));
|
||||
}
|
||||
|
||||
let current_dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
|
||||
match find_story_in_dir(¤t_dir, num_str) {
|
||||
Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)),
|
||||
None => Some(format!(
|
||||
"Story **{num_str}** is not currently in progress (not found in `work/2_current/`)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`.
|
||||
///
|
||||
/// Returns `(path, file_stem)` for the first match.
|
||||
fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> {
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("");
|
||||
if file_num == num_str {
|
||||
return Some((path.clone(), stem.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the full triage dump for a story.
|
||||
fn build_triage_dump(
|
||||
ctx: &CommandContext,
|
||||
story_path: &Path,
|
||||
story_id: &str,
|
||||
num_str: &str,
|
||||
) -> String {
|
||||
let contents = match std::fs::read_to_string(story_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("Failed to read story {num_str}: {e}"),
|
||||
};
|
||||
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
||||
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
// ---- Header ----
|
||||
out.push_str(&format!("## Story {num_str} — {name}\n"));
|
||||
out.push_str("**Stage:** In Progress (`2_current`)\n\n");
|
||||
|
||||
// ---- Front matter fields ----
|
||||
if let Some(ref m) = meta {
|
||||
let mut fields: Vec<String> = Vec::new();
|
||||
if let Some(true) = m.blocked {
|
||||
fields.push("**blocked:** true".to_string());
|
||||
}
|
||||
if let Some(ref agent) = m.agent {
|
||||
fields.push(format!("**agent:** {agent}"));
|
||||
}
|
||||
if let Some(ref qa) = m.qa {
|
||||
fields.push(format!("**qa:** {qa}"));
|
||||
}
|
||||
if let Some(true) = m.review_hold {
|
||||
fields.push("**review_hold:** true".to_string());
|
||||
}
|
||||
if let Some(rc) = m.retry_count {
|
||||
if rc > 0 {
|
||||
fields.push(format!("**retry_count:** {rc}"));
|
||||
}
|
||||
}
|
||||
if let Some(ref cb) = m.coverage_baseline {
|
||||
fields.push(format!("**coverage_baseline:** {cb}"));
|
||||
}
|
||||
if let Some(ref mf) = m.merge_failure {
|
||||
fields.push(format!("**merge_failure:** {mf}"));
|
||||
}
|
||||
if !fields.is_empty() {
|
||||
out.push_str("**Front matter:**\n");
|
||||
for f in &fields {
|
||||
out.push_str(&format!(" • {f}\n"));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Acceptance criteria ----
|
||||
let criteria = parse_acceptance_criteria(&contents);
|
||||
if !criteria.is_empty() {
|
||||
out.push_str("**Acceptance Criteria:**\n");
|
||||
for (checked, text) in &criteria {
|
||||
let mark = if *checked { "✅" } else { "⬜" };
|
||||
out.push_str(&format!(" {mark} {text}\n"));
|
||||
}
|
||||
let total = criteria.len();
|
||||
let done = criteria.iter().filter(|(c, _)| *c).count();
|
||||
out.push_str(&format!(" *{done}/{total} complete*\n"));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// ---- Worktree and branch ----
|
||||
let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id);
|
||||
let branch = format!("feature/story-{story_id}");
|
||||
if wt_path.is_dir() {
|
||||
out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display()));
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
||||
|
||||
// ---- git diff --stat ----
|
||||
let diff_stat = run_git(
|
||||
&wt_path,
|
||||
&["diff", "--stat", "master...HEAD"],
|
||||
);
|
||||
if !diff_stat.is_empty() {
|
||||
out.push_str("**Diff stat (vs master):**\n```\n");
|
||||
out.push_str(&diff_stat);
|
||||
out.push_str("```\n\n");
|
||||
} else {
|
||||
out.push_str("**Diff stat (vs master):** *(no changes)*\n\n");
|
||||
}
|
||||
|
||||
// ---- Last 5 commits on feature branch ----
|
||||
let log = run_git(
|
||||
&wt_path,
|
||||
&[
|
||||
"log",
|
||||
"master..HEAD",
|
||||
"--pretty=format:%h %s",
|
||||
"-5",
|
||||
],
|
||||
);
|
||||
if !log.is_empty() {
|
||||
out.push_str("**Recent commits (branch only):**\n```\n");
|
||||
out.push_str(&log);
|
||||
out.push_str("\n```\n\n");
|
||||
} else {
|
||||
out.push_str("**Recent commits (branch only):** *(none yet)*\n\n");
|
||||
}
|
||||
} else {
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n"));
|
||||
out.push_str("**Worktree:** *(not yet created)*\n\n");
|
||||
}
|
||||
|
||||
// ---- Agent log tail ----
|
||||
let log_dir = ctx
|
||||
.project_root
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join(story_id);
|
||||
match latest_log_file(&log_dir) {
|
||||
Some(log_path) => {
|
||||
let tail = read_log_tail(&log_path, 20);
|
||||
let filename = log_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("agent.log");
|
||||
if tail.is_empty() {
|
||||
out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n"));
|
||||
} else {
|
||||
out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n"));
|
||||
out.push_str(&tail);
|
||||
out.push_str("\n```\n");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str("**Agent log:** *(no log found)*\n");
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse acceptance criteria from story markdown.
|
||||
///
|
||||
/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line.
|
||||
fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
|
||||
contents
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
|
||||
Some((true, text.to_string()))
|
||||
} else if let Some(text) = trimmed.strip_prefix("- [ ] ") {
|
||||
Some((false, text.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory, returning trimmed stdout (or empty on error).
|
||||
fn run_git(dir: &Path, args: &[&str]) -> String {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find the most recently modified `.log` file in the given directory,
|
||||
/// regardless of agent name.
|
||||
fn latest_log_file(log_dir: &Path) -> Option<PathBuf> {
|
||||
if !log_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
for entry in std::fs::read_dir(log_dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read the last `n` non-empty lines from a file as a single string.
|
||||
fn read_log_tail(path: &Path, n: usize) -> String {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
let start = lines.len().saturating_sub(n);
|
||||
lines[start..].join("\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::super::{CommandDispatch, try_handle_command};
|
||||
|
||||
fn whatsup_cmd(root: &Path, args: &str) -> Option<String> {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let room_id = "!test:example.com".to_string();
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: "Timmy",
|
||||
bot_user_id: "@timmy:homeserver.local",
|
||||
project_root: root,
|
||||
agents: &agents,
|
||||
ambient_rooms: &ambient_rooms,
|
||||
room_id: &room_id,
|
||||
};
|
||||
try_handle_command(&dispatch, &format!("@timmy whatsup {args}"))
|
||||
}
|
||||
|
||||
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
|
||||
// -- registration -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_command_is_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
||||
assert!(found, "whatsup command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("whatsup"),
|
||||
"help should list whatsup command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- input validation ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = whatsup_cmd(tmp.path(), "").unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_non_numeric_returns_error() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = whatsup_cmd(tmp.path(), "abc").unwrap();
|
||||
assert!(
|
||||
output.contains("Invalid"),
|
||||
"non-numeric arg should return error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- not found ----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_story_not_in_current_returns_friendly_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Create the directory but put the story in backlog, not current
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"42_story_not_in_current.md",
|
||||
"---\nname: Not in current\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "42").unwrap();
|
||||
assert!(
|
||||
output.contains("42"),
|
||||
"message should include story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("not") || output.contains("Not"),
|
||||
"message should say not found/in progress: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- found in 2_current -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_story_name_and_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"99_story_my_feature.md",
|
||||
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
||||
assert!(output.contains("99"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("My Feature"),
|
||||
"should show story name: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("In Progress") || output.contains("2_current"),
|
||||
"should show pipeline stage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_acceptance_criteria() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"99_story_criteria_test.md",
|
||||
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
||||
assert!(
|
||||
output.contains("First thing"),
|
||||
"should show unchecked criterion: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Done thing"),
|
||||
"should show checked criterion: {output}"
|
||||
);
|
||||
// 1 of 3 done
|
||||
assert!(
|
||||
output.contains("1/3"),
|
||||
"should show checked/total count: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_blocked_field() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"55_story_blocked_story.md",
|
||||
"---\nname: Blocked Story\nblocked: true\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
||||
assert!(
|
||||
output.contains("blocked"),
|
||||
"should show blocked field: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_shows_agent_field() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"55_story_agent_story.md",
|
||||
"---\nname: Agent Story\nagent: coder-1\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
||||
assert!(
|
||||
output.contains("coder-1"),
|
||||
"should show agent field: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_worktree_shows_not_created() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"77_story_no_worktree.md",
|
||||
"---\nname: No Worktree\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
||||
// Branch name should still appear
|
||||
assert!(
|
||||
output.contains("feature/story-77"),
|
||||
"should show branch name: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsup_no_log_shows_no_log_message() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"77_story_no_log.md",
|
||||
"---\nname: No Log\n---\n",
|
||||
);
|
||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
||||
assert!(
|
||||
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
|
||||
"should indicate no log exists: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_acceptance_criteria ------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_criteria_mixed() {
|
||||
let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n";
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert_eq!(result.len(), 4);
|
||||
assert_eq!(result[0], (false, "First".to_string()));
|
||||
assert_eq!(result[1], (true, "Done".to_string()));
|
||||
assert_eq!(result[2], (true, "Also done".to_string()));
|
||||
assert_eq!(result[3], (false, "Last".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_criteria_empty() {
|
||||
let input = "# Story\nNo checkboxes here.\n";
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// -- read_log_tail -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_log_tail_returns_last_n_lines() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("test.log");
|
||||
let content = (1..=30).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
std::fs::write(&path, &content).unwrap();
|
||||
let tail = read_log_tail(&path, 5);
|
||||
let lines: Vec<&str> = tail.lines().collect();
|
||||
assert_eq!(lines.len(), 5);
|
||||
assert_eq!(lines[0], "line 26");
|
||||
assert_eq!(lines[4], "line 30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_log_tail_fewer_lines_than_n() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let path = tmp.path().join("short.log");
|
||||
std::fs::write(&path, "line A\nline B\n").unwrap();
|
||||
let tail = read_log_tail(&path, 20);
|
||||
assert!(tail.contains("line A"));
|
||||
assert!(tail.contains("line B"));
|
||||
}
|
||||
|
||||
// -- latest_log_file ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn latest_log_file_returns_none_for_missing_dir() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let result = latest_log_file(&tmp.path().join("nonexistent"));
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_log_file_finds_log() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let log_path = tmp.path().join("coder-1-sess-abc.log");
|
||||
std::fs::write(&log_path, "some log content\n").unwrap();
|
||||
let result = latest_log_file(tmp.path());
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap(), log_path);
|
||||
}
|
||||
}
|
||||
@@ -1,815 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
fn default_history_size() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_permission_timeout_secs() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
/// Configuration for the Matrix bot, read from `.storkit/bot.toml`.
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct BotConfig {
|
||||
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
|
||||
pub homeserver: String,
|
||||
/// Bot user ID, e.g. `@storykit:example.com`
|
||||
pub username: String,
|
||||
/// Bot password
|
||||
pub password: String,
|
||||
/// Matrix room IDs to join, e.g. `["!roomid:example.com"]`.
|
||||
/// Use an array for multiple rooms; a single string is accepted via the
|
||||
/// deprecated `room_id` key for backwards compatibility.
|
||||
#[serde(default)]
|
||||
pub room_ids: Vec<String>,
|
||||
/// Deprecated: use `room_ids` (list) instead. Still accepted so existing
|
||||
/// `bot.toml` files continue to work without modification.
|
||||
#[serde(default)]
|
||||
pub room_id: Option<String>,
|
||||
/// Set to `true` to enable the bot (default: false)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Matrix user IDs allowed to interact with the bot.
|
||||
/// If empty or omitted, the bot ignores ALL messages (fail-closed).
|
||||
#[serde(default)]
|
||||
pub allowed_users: Vec<String>,
|
||||
/// Maximum number of conversation turns (user + assistant pairs) to keep
|
||||
/// per room. When the history exceeds this limit the oldest messages are
|
||||
/// dropped. Defaults to 20.
|
||||
#[serde(default = "default_history_size")]
|
||||
pub history_size: usize,
|
||||
/// Timeout in seconds for permission prompts surfaced to the Matrix room.
|
||||
/// If the user does not respond within this window the permission is denied
|
||||
/// (fail-closed). Defaults to 120 seconds.
|
||||
#[serde(default = "default_permission_timeout_secs")]
|
||||
pub permission_timeout_secs: u64,
|
||||
/// Previously used to select an Anthropic model. Now ignored — the bot
|
||||
/// uses Claude Code which manages its own model selection. Kept for
|
||||
/// backwards compatibility so existing bot.toml files still parse.
|
||||
#[allow(dead_code)]
|
||||
pub model: Option<String>,
|
||||
/// Display name the bot uses to identify itself in conversations.
|
||||
/// If unset, the bot falls back to "Assistant".
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// Room IDs where ambient mode is active (bot responds to all messages).
|
||||
/// Updated at runtime when the user toggles ambient mode — do not edit
|
||||
/// manually while the bot is running.
|
||||
#[serde(default)]
|
||||
pub ambient_rooms: Vec<String>,
|
||||
/// Chat transport to use: `"matrix"` (default) or `"whatsapp"`.
|
||||
///
|
||||
/// Selects which [`ChatTransport`] implementation the bot uses for
|
||||
/// sending and editing messages. Currently only read during bot
|
||||
/// startup to select the transport; the field is kept for config
|
||||
/// round-tripping.
|
||||
#[serde(default = "default_transport")]
|
||||
pub transport: String,
|
||||
|
||||
// ── WhatsApp Business API fields ─────────────────────────────────
|
||||
// These are only required when `transport = "whatsapp"`.
|
||||
|
||||
/// WhatsApp Business phone number ID from the Meta dashboard.
|
||||
#[serde(default)]
|
||||
pub whatsapp_phone_number_id: Option<String>,
|
||||
/// Long-lived access token for the WhatsApp Business API.
|
||||
#[serde(default)]
|
||||
pub whatsapp_access_token: Option<String>,
|
||||
/// Verify token used in the webhook handshake (you choose this value
|
||||
/// and configure it in the Meta webhook settings).
|
||||
#[serde(default)]
|
||||
pub whatsapp_verify_token: Option<String>,
|
||||
/// Name of the approved Meta message template used for pipeline
|
||||
/// notifications when the 24-hour messaging window has expired.
|
||||
///
|
||||
/// The template must be registered in the Meta Business Manager before
|
||||
/// use. Defaults to `"pipeline_notification"`.
|
||||
#[serde(default)]
|
||||
pub whatsapp_notification_template: Option<String>,
|
||||
|
||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||
// These are only required when `transport = "slack"`.
|
||||
|
||||
/// Slack Bot User OAuth Token (starts with `xoxb-`).
|
||||
#[serde(default)]
|
||||
pub slack_bot_token: Option<String>,
|
||||
/// Slack Signing Secret used to verify incoming webhook requests.
|
||||
#[serde(default)]
|
||||
pub slack_signing_secret: Option<String>,
|
||||
/// Slack channel IDs the bot should listen in.
|
||||
#[serde(default)]
|
||||
pub slack_channel_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_transport() -> String {
|
||||
"matrix".to_string()
|
||||
}
|
||||
|
||||
impl BotConfig {
|
||||
/// Load bot configuration from `.storkit/bot.toml`.
|
||||
///
|
||||
/// Returns `None` if the file does not exist, fails to parse, has
|
||||
/// `enabled = false`, or specifies no room IDs.
|
||||
pub fn load(project_root: &Path) -> Option<Self> {
|
||||
let path = project_root.join(".storkit").join("bot.toml");
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}"))
|
||||
.ok()?;
|
||||
let mut config: BotConfig = toml::from_str(&content)
|
||||
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
|
||||
.ok()?;
|
||||
if !config.enabled {
|
||||
return None;
|
||||
}
|
||||
// Merge deprecated `room_id` (single string) into `room_ids` (list).
|
||||
if let Some(single) = config.room_id.take()
|
||||
&& !config.room_ids.contains(&single)
|
||||
{
|
||||
config.room_ids.push(single);
|
||||
}
|
||||
|
||||
if config.transport == "whatsapp" {
|
||||
// Validate WhatsApp-specific fields.
|
||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_phone_number_id"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_access_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_verify_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
} else if config.transport == "slack" {
|
||||
// Validate Slack-specific fields.
|
||||
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
slack_bot_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
slack_signing_secret"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.slack_channel_ids.is_empty() {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
at least one slack_channel_ids entry"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
} else if config.room_ids.is_empty() {
|
||||
eprintln!(
|
||||
"[matrix-bot] bot.toml has no room_ids configured — \
|
||||
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(config)
|
||||
}
|
||||
|
||||
/// Returns all configured room IDs as a flat list. Combines `room_ids`
|
||||
/// and (after loading) any merged `room_id` value.
|
||||
pub fn effective_room_ids(&self) -> &[String] {
|
||||
&self.room_ids
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the current set of ambient room IDs back to `bot.toml`.
|
||||
///
|
||||
/// Reads the existing file as a TOML document, updates the `ambient_rooms`
|
||||
/// array, and writes the result back. Errors are logged but not propagated
|
||||
/// so a persistence failure never interrupts the bot's message handling.
|
||||
pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) {
|
||||
let path = project_root.join(".storkit").join("bot.toml");
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut doc: toml::Value = match toml::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let toml::Value::Table(ref mut t) = doc {
|
||||
let arr = toml::Value::Array(
|
||||
room_ids
|
||||
.iter()
|
||||
.map(|s| toml::Value::String(s.clone()))
|
||||
.collect(),
|
||||
);
|
||||
t.insert("ambient_rooms".to_string(), arr);
|
||||
}
|
||||
match toml::to_string_pretty(&doc) {
|
||||
Ok(new_content) => {
|
||||
if let Err(e) = std::fs::write(&path, new_content) {
|
||||
eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_config_when_enabled_with_room_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com", "!def:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_some());
|
||||
let config = result.unwrap();
|
||||
assert_eq!(config.homeserver, "https://matrix.example.com");
|
||||
assert_eq!(config.username, "@bot:example.com");
|
||||
assert_eq!(
|
||||
config.effective_room_ids(),
|
||||
&["!abc:example.com", "!def:example.com"]
|
||||
);
|
||||
assert!(config.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_merges_deprecated_room_id_into_room_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
// Old-style single room_id key — should still work.
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_id = "!abc:example.com"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.effective_room_ids(), &["!abc:example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_no_room_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_when_toml_invalid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(sk.join("bot.toml"), "not valid toml {{{").unwrap();
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_respects_optional_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
model = "claude-sonnet-4-6"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_uses_default_history_size() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.history_size, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_respects_custom_history_size() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
history_size = 50
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.history_size, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_display_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
display_name = "Timmy"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.display_name.as_deref(), Some("Timmy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_display_name_defaults_to_none_when_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert!(config.display_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_uses_default_permission_timeout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.permission_timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_respects_custom_permission_timeout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
permission_timeout_secs = 60
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.permission_timeout_secs, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_ignores_legacy_require_verified_devices_key() {
|
||||
// Old bot.toml files that still have `require_verified_devices = true`
|
||||
// must parse successfully — the field is simply ignored now that
|
||||
// verification is always enforced unconditionally.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
require_verified_devices = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Should still load successfully despite the unknown field.
|
||||
let config = BotConfig::load(tmp.path());
|
||||
assert!(
|
||||
config.is_some(),
|
||||
"bot.toml with legacy require_verified_devices key must still load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_ambient_rooms() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
ambient_rooms = ["!abc:example.com"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_ambient_rooms_defaults_to_empty_when_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert!(config.ambient_rooms.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_ambient_rooms_persists_to_bot_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
save_ambient_rooms(tmp.path(), &["!abc:example.com".to_string()]);
|
||||
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.ambient_rooms, vec!["!abc:example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_ambient_rooms_clears_when_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
ambient_rooms = ["!abc:example.com"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
save_ambient_rooms(tmp.path(), &[]);
|
||||
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert!(config.ambient_rooms.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_transport_defaults_to_matrix() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_transport_reads_custom_value() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
room_ids = ["!abc:example.com"]
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_phone_number_id = "123456"
|
||||
whatsapp_access_token = "EAAtoken"
|
||||
whatsapp_verify_token = "my-verify"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "whatsapp");
|
||||
assert_eq!(
|
||||
config.whatsapp_phone_number_id.as_deref(),
|
||||
Some("123456")
|
||||
);
|
||||
assert_eq!(
|
||||
config.whatsapp_access_token.as_deref(),
|
||||
Some("EAAtoken")
|
||||
);
|
||||
assert_eq!(
|
||||
config.whatsapp_verify_token.as_deref(),
|
||||
Some("my-verify")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_whatsapp_returns_none_when_missing_phone_number_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_access_token = "EAAtoken"
|
||||
whatsapp_verify_token = "my-verify"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_whatsapp_returns_none_when_missing_access_token() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_phone_number_id = "123456"
|
||||
whatsapp_verify_token = "my-verify"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_whatsapp_returns_none_when_missing_verify_token() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "whatsapp"
|
||||
whatsapp_phone_number_id = "123456"
|
||||
whatsapp_access_token = "EAAtoken"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
// ── Slack config tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_slack_transport_reads_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_signing_secret = "secret123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "slack");
|
||||
assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123"));
|
||||
assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123"));
|
||||
assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_bot_token() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_signing_secret = "secret123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_signing_secret() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_channel_ids = ["C01ABCDEF"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_slack_returns_none_when_missing_channel_ids() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
fs::create_dir_all(&sk).unwrap();
|
||||
fs::write(
|
||||
sk.join("bot.toml"),
|
||||
r#"
|
||||
homeserver = "https://matrix.example.com"
|
||||
username = "@bot:example.com"
|
||||
password = "secret"
|
||||
enabled = true
|
||||
transport = "slack"
|
||||
slack_bot_token = "xoxb-123"
|
||||
slack_signing_secret = "secret123"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(BotConfig::load(tmp.path()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
//! Delete command: remove a story/bug/spike from the pipeline.
|
||||
//!
|
||||
//! `{bot_name} delete {number}` finds the work item by number across all pipeline
|
||||
//! stages, stops any running agent, removes the worktree, deletes the file, and
|
||||
//! commits the change to git.
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use std::path::Path;
|
||||
|
||||
/// A parsed delete command from a Matrix message body.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum DeleteCommand {
|
||||
/// Delete the story with this number (digits only, e.g. `"42"`).
|
||||
Delete { story_number: String },
|
||||
/// The user typed `delete` but without a valid numeric argument.
|
||||
BadArgs,
|
||||
}
|
||||
|
||||
/// Parse a delete command from a raw Matrix message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the first word is `delete`.
|
||||
/// Returns `None` when the message is not a delete command at all.
|
||||
pub fn extract_delete_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<DeleteCommand> {
|
||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("delete") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) {
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: args.to_string(),
|
||||
})
|
||||
} else {
|
||||
Some(DeleteCommand::BadArgs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a delete command asynchronously.
|
||||
///
|
||||
/// Finds the work item by `story_number` across all pipeline stages, stops any
|
||||
/// running agent, removes the worktree, deletes the file, and commits to git.
|
||||
/// Returns a markdown-formatted response string.
|
||||
pub async fn handle_delete(
|
||||
bot_name: &str,
|
||||
story_number: &str,
|
||||
project_root: &Path,
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
let mut found: Option<(std::path::PathBuf, &str, String)> = None; // (path, stage, story_id)
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".storkit").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stage, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, stage, story_id) = match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the confirmation message.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
})
|
||||
.unwrap_or_else(|| story_id.clone());
|
||||
|
||||
// Stop any running or pending agents for this story.
|
||||
let running_agents: Vec<(String, String)> = agents
|
||||
.list_agents()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
a.story_id == story_id
|
||||
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||
})
|
||||
.map(|a| (a.story_id.clone(), a.agent_name.clone()))
|
||||
.collect();
|
||||
|
||||
let mut stopped_agents: Vec<String> = Vec::new();
|
||||
for (sid, agent_name) in &running_agents {
|
||||
if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await {
|
||||
return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}");
|
||||
}
|
||||
stopped_agents.push(agent_name.clone());
|
||||
}
|
||||
|
||||
// Remove the worktree if one exists (best-effort; ignore errors).
|
||||
let _ = crate::worktree::prune_worktree_sync(project_root, &story_id);
|
||||
|
||||
// Delete the story file.
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
return format!("Failed to delete story {story_number}: {e}");
|
||||
}
|
||||
|
||||
// Commit the deletion to git.
|
||||
let commit_msg = format!("storkit: delete {story_id}");
|
||||
let work_rel = std::path::PathBuf::from(".storkit").join("work");
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.arg(&work_rel)
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["commit", "-m", &commit_msg])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// Build the response.
|
||||
let stage_label = stage_display_name(stage);
|
||||
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
|
||||
if !stopped_agents.is_empty() {
|
||||
let agent_list = stopped_agents.join(", ");
|
||||
response.push_str(&format!(" Stopped agent(s): {agent_list}."));
|
||||
}
|
||||
|
||||
crate::slog!("[matrix-bot] delete command: removed {story_id} from {stage} (bot={bot_name})");
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Human-readable label for a pipeline stage directory name.
|
||||
fn stage_display_name(stage: &str) -> &str {
|
||||
match stage {
|
||||
"1_backlog" => "backlog",
|
||||
"2_current" => "in-progress",
|
||||
"3_qa" => "QA",
|
||||
"4_merge" => "merge",
|
||||
"5_done" => "done",
|
||||
"6_archived" => "archived",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||
///
|
||||
/// Mirrors the logic in `commands::strip_bot_mention` and `htop::strip_mention`
|
||||
/// so delete detection works without depending on private symbols.
|
||||
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
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()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- extract_delete_command ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd =
|
||||
extract_delete_command("@timmy:home.local delete 42", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: "42".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_display_name() {
|
||||
let cmd = extract_delete_command("Timmy delete 310", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: "310".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_localpart() {
|
||||
let cmd = extract_delete_command("@timmy delete 7", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: "7".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive_command() {
|
||||
let cmd = extract_delete_command("Timmy DELETE 99", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: "99".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_args_is_bad_args() {
|
||||
let cmd = extract_delete_command("Timmy delete", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(DeleteCommand::BadArgs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_numeric_arg_is_bad_args() {
|
||||
let cmd = extract_delete_command("Timmy delete foo", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(DeleteCommand::BadArgs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_delete_command_returns_none() {
|
||||
let cmd = extract_delete_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_bot_prefix_returns_none() {
|
||||
let cmd = extract_delete_command("delete 42", "Timmy", "@timmy:home.local");
|
||||
// Without mention prefix the raw text is "delete 42" — cmd is "delete", args "42"
|
||||
// strip_mention returns the full trimmed text when no prefix matches,
|
||||
// so this is a valid delete command addressed to no-one (ambient mode).
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(DeleteCommand::Delete {
|
||||
story_number: "42".to_string()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// -- handle_delete (integration-style, uses temp filesystem) -----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_delete_returns_not_found_for_unknown_number() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path();
|
||||
// Create the pipeline directories.
|
||||
for stage in &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
] {
|
||||
std::fs::create_dir_all(project_root.join(".storkit").join("work").join(stage))
|
||||
.unwrap();
|
||||
}
|
||||
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let response = handle_delete("Timmy", "999", project_root, &agents).await;
|
||||
assert!(
|
||||
response.contains("No story") && response.contains("999"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_delete_removes_story_file_and_confirms() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path();
|
||||
|
||||
// Init a bare git repo so the commit step doesn't fail fatally.
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let backlog_dir = project_root.join(".storkit").join("work").join("1_backlog");
|
||||
std::fs::create_dir_all(&backlog_dir).unwrap();
|
||||
let story_path = backlog_dir.join("42_story_some_feature.md");
|
||||
std::fs::write(&story_path, "---\nname: Some Feature\n---\n\n# Story 42\n").unwrap();
|
||||
|
||||
// Initial commit so git doesn't complain about no commits.
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let response = handle_delete("Timmy", "42", project_root, &agents).await;
|
||||
|
||||
assert!(
|
||||
response.contains("Some Feature") && response.contains("backlog"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
assert!(!story_path.exists(), "story file should have been deleted");
|
||||
}
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
//! htop command: live-updating system and agent process dashboard.
|
||||
//!
|
||||
//! Sends an initial message to a Matrix room showing load average and
|
||||
//! per-agent process info, then edits it in-place every 5 seconds using
|
||||
//! Matrix replacement events. A single htop session per room is enforced;
|
||||
//! a new `htop` invocation stops any existing session and starts a fresh one.
|
||||
//! Sessions auto-stop after the configured duration (default 5 minutes).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{Mutex as TokioMutex, watch};
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::slog;
|
||||
use crate::transport::ChatTransport;
|
||||
|
||||
use super::bot::markdown_to_html;
|
||||
|
||||
/// A parsed htop command from a Matrix message body.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum HtopCommand {
|
||||
/// Start (or restart) monitoring. `duration_secs` is the auto-stop
|
||||
/// timeout; defaults to 300 (5 minutes).
|
||||
Start { duration_secs: u64 },
|
||||
/// Stop any active monitoring session for the room.
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Per-room htop session: holds the stop-signal sender so callers can cancel.
|
||||
pub struct HtopSession {
|
||||
/// Send `true` to request a graceful stop of the background loop.
|
||||
pub stop_tx: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
/// Per-room htop session map type alias.
|
||||
///
|
||||
/// Keys are platform-agnostic room ID strings (e.g. `"!abc:example.com"` on
|
||||
/// Matrix) so this type works with any [`ChatTransport`] implementation.
|
||||
pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
|
||||
|
||||
/// Parse an htop command from a raw Matrix message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the first word is `htop`.
|
||||
/// Returns `None` when the message is not an htop command.
|
||||
///
|
||||
/// Recognised forms (after stripping the bot mention):
|
||||
/// - `htop` → `Start { duration_secs: 300 }`
|
||||
/// - `htop stop` → `Stop`
|
||||
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
||||
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
||||
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
|
||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped.trim();
|
||||
|
||||
// Strip leading punctuation (e.g. the comma in "@timmy, htop")
|
||||
let trimmed = trimmed.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("htop") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if args.eq_ignore_ascii_case("stop") {
|
||||
return Some(HtopCommand::Stop);
|
||||
}
|
||||
|
||||
let duration_secs = parse_duration(args).unwrap_or(300);
|
||||
Some(HtopCommand::Start { duration_secs })
|
||||
}
|
||||
|
||||
/// Parse an optional duration argument.
|
||||
///
|
||||
/// Accepts `""` (empty → `None`), `"5m"` / `"10M"` (minutes), or a bare
|
||||
/// integer interpreted as seconds.
|
||||
fn parse_duration(s: &str) -> Option<u64> {
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(mins_str) = s.strip_suffix('m').or_else(|| s.strip_suffix('M')) {
|
||||
return mins_str.parse::<u64>().ok().map(|m| m * 60);
|
||||
}
|
||||
s.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||
///
|
||||
/// Mirrors the logic in `commands::strip_bot_mention` so htop detection works
|
||||
/// without depending on private symbols in that module.
|
||||
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
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()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Read the system load average using the `uptime` command.
|
||||
///
|
||||
/// Returns a short string like `"load average: 1.23, 0.98, 0.75"` on success,
|
||||
/// or `"load: unknown"` on failure.
|
||||
fn get_load_average() -> String {
|
||||
let output = std::process::Command::new("uptime")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// uptime output typically contains "load average: X, Y, Z" (Linux/macOS)
|
||||
// or "load averages: X Y Z" (some BSD variants).
|
||||
if let Some(idx) = output.find("load average") {
|
||||
output[idx..].trim().trim_end_matches('\n').to_string()
|
||||
} else {
|
||||
"load: unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Process stats for a single agent, gathered from `ps`.
|
||||
#[derive(Debug, Default)]
|
||||
struct AgentProcessStats {
|
||||
cpu_pct: f64,
|
||||
mem_pct: f64,
|
||||
num_procs: usize,
|
||||
}
|
||||
|
||||
/// Gather CPU% and MEM% for processes whose command line contains `worktree_path`.
|
||||
///
|
||||
/// Runs `ps aux` and sums all matching lines. Returns `None` when no
|
||||
/// matching process is found.
|
||||
fn gather_process_stats(worktree_path: &str) -> Option<AgentProcessStats> {
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())?;
|
||||
|
||||
let mut stats = AgentProcessStats::default();
|
||||
|
||||
for line in output.lines().skip(1) {
|
||||
// Avoid matching against our own status display (the ps command itself)
|
||||
if !line.contains(worktree_path) {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// ps aux columns: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND...
|
||||
if parts.len() >= 4
|
||||
&& let (Ok(cpu), Ok(mem)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
|
||||
{
|
||||
stats.cpu_pct += cpu;
|
||||
stats.mem_pct += mem;
|
||||
stats.num_procs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if stats.num_procs > 0 {
|
||||
Some(stats)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the Markdown text for the htop dashboard.
|
||||
///
|
||||
/// `tick` is the number of updates sent so far (0 = initial).
|
||||
/// `total_duration_secs` is the configured auto-stop timeout.
|
||||
///
|
||||
/// Output uses a compact single-line format per agent so it renders
|
||||
/// without wrapping on narrow screens (~40 chars), such as mobile
|
||||
/// Matrix clients.
|
||||
pub fn build_htop_message(agents: &AgentPool, tick: u32, total_duration_secs: u64) -> String {
|
||||
let elapsed_secs = (tick as u64) * 5;
|
||||
let remaining_secs = total_duration_secs.saturating_sub(elapsed_secs);
|
||||
let remaining_mins = remaining_secs / 60;
|
||||
let remaining_secs_rem = remaining_secs % 60;
|
||||
|
||||
let load = get_load_average();
|
||||
|
||||
let mut lines = vec![
|
||||
format!(
|
||||
"**htop** · auto-stops in {}m{}s",
|
||||
remaining_mins, remaining_secs_rem
|
||||
),
|
||||
load,
|
||||
String::new(),
|
||||
];
|
||||
|
||||
let all_agents = agents.list_agents().unwrap_or_default();
|
||||
let active: Vec<_> = all_agents
|
||||
.iter()
|
||||
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
|
||||
.collect();
|
||||
|
||||
if active.is_empty() {
|
||||
lines.push("*No agents currently running.*".to_string());
|
||||
} else {
|
||||
for agent in &active {
|
||||
let story_label = agent
|
||||
.story_id
|
||||
.split('_')
|
||||
.next()
|
||||
.unwrap_or(&agent.story_id)
|
||||
.to_string();
|
||||
let stats = agent
|
||||
.worktree_path
|
||||
.as_deref()
|
||||
.and_then(gather_process_stats)
|
||||
.unwrap_or_default();
|
||||
lines.push(format!(
|
||||
"**{}** #{} cpu:{:.1}% mem:{:.1}%",
|
||||
agent.agent_name, story_label, stats.cpu_pct, stats.mem_pct,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background monitoring loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the htop background loop: update the message every 5 seconds until
|
||||
/// the stop signal is received or the timeout expires.
|
||||
///
|
||||
/// Uses the [`ChatTransport`] abstraction so the loop works with any chat
|
||||
/// platform, not just Matrix.
|
||||
pub async fn run_htop_loop(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
room_id: String,
|
||||
initial_message_id: String,
|
||||
agents: Arc<AgentPool>,
|
||||
mut stop_rx: watch::Receiver<bool>,
|
||||
duration_secs: u64,
|
||||
) {
|
||||
let interval_secs: u64 = 5;
|
||||
let max_ticks = (duration_secs / interval_secs).max(1);
|
||||
|
||||
for tick in 1..=max_ticks {
|
||||
// Wait for the interval or a stop signal.
|
||||
let sleep = tokio::time::sleep(Duration::from_secs(interval_secs));
|
||||
tokio::pin!(sleep);
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut sleep => {}
|
||||
Ok(()) = stop_rx.changed() => {
|
||||
if *stop_rx.borrow() {
|
||||
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check after waking — the sender might have signalled while we slept.
|
||||
if *stop_rx.borrow() {
|
||||
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let text = build_htop_message(&agents, tick as u32, duration_secs);
|
||||
let html = markdown_to_html(&text);
|
||||
|
||||
if let Err(e) = transport.edit_message(&room_id, &initial_message_id, &text, &html).await {
|
||||
slog!("[htop] Failed to update message: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop: timeout reached.
|
||||
send_stopped_message(&*transport, &room_id, &initial_message_id).await;
|
||||
}
|
||||
|
||||
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
|
||||
let text = "**htop** — monitoring stopped.";
|
||||
let html = markdown_to_html(text);
|
||||
if let Err(e) = transport.edit_message(room_id, message_id, text, &html).await {
|
||||
slog!("[htop] Failed to send stop message: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public command handlers (called from on_room_message in bot.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start a new htop monitoring session for `room_id`.
|
||||
///
|
||||
/// Stops any existing session for the room, sends the initial dashboard
|
||||
/// message, and spawns a background task that edits it every 5 seconds.
|
||||
///
|
||||
/// Uses the [`ChatTransport`] abstraction so htop works with any platform.
|
||||
pub async fn handle_htop_start(
|
||||
transport: &Arc<dyn ChatTransport>,
|
||||
room_id: &str,
|
||||
htop_sessions: &HtopSessions,
|
||||
agents: Arc<AgentPool>,
|
||||
duration_secs: u64,
|
||||
) {
|
||||
// Stop any existing session (best-effort; ignore errors if already done).
|
||||
stop_existing_session(htop_sessions, room_id).await;
|
||||
|
||||
// Send the initial message.
|
||||
let initial_text = build_htop_message(&agents, 0, duration_secs);
|
||||
let initial_html = markdown_to_html(&initial_text);
|
||||
let message_id = match transport.send_message(room_id, &initial_text, &initial_html).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
slog!("[htop] Failed to send initial message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create the stop channel and register the session.
|
||||
let (stop_tx, stop_rx) = watch::channel(false);
|
||||
{
|
||||
let mut sessions = htop_sessions.lock().await;
|
||||
sessions.insert(room_id.to_string(), HtopSession { stop_tx });
|
||||
}
|
||||
|
||||
// Spawn the background update loop.
|
||||
let transport_clone = Arc::clone(transport);
|
||||
let sessions_clone = Arc::clone(htop_sessions);
|
||||
let room_id_owned = room_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
run_htop_loop(
|
||||
transport_clone,
|
||||
room_id_owned.clone(),
|
||||
message_id,
|
||||
agents,
|
||||
stop_rx,
|
||||
duration_secs,
|
||||
)
|
||||
.await;
|
||||
// Clean up the session entry when the loop exits naturally.
|
||||
let mut sessions = sessions_clone.lock().await;
|
||||
sessions.remove(&room_id_owned);
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop the active htop session for `room_id`, if any.
|
||||
///
|
||||
/// When there is no active session, sends a "no active session" reply
|
||||
/// to the room so the user knows the command was received.
|
||||
pub async fn handle_htop_stop(
|
||||
transport: &dyn ChatTransport,
|
||||
room_id: &str,
|
||||
htop_sessions: &HtopSessions,
|
||||
) {
|
||||
let had_session = stop_existing_session(htop_sessions, room_id).await;
|
||||
if !had_session {
|
||||
let msg = "No active htop session in this room.";
|
||||
let html = markdown_to_html(msg);
|
||||
if let Err(e) = transport.send_message(room_id, msg, &html).await {
|
||||
slog!("[htop] Failed to send no-session reply: {e}");
|
||||
}
|
||||
}
|
||||
// When a session was active, the background task handles the final edit.
|
||||
}
|
||||
|
||||
/// Signal and remove the existing session for `room_id`.
|
||||
///
|
||||
/// Returns `true` if a session was found and stopped.
|
||||
async fn stop_existing_session(htop_sessions: &HtopSessions, room_id: &str) -> bool {
|
||||
let mut sessions = htop_sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(room_id) {
|
||||
// Signal the background task to stop (ignore error — task may be done).
|
||||
let _ = session.stop_tx.send(true);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- extract_htop_command -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn htop_bare_command() {
|
||||
let cmd = extract_htop_command("@timmy htop", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_with_display_name() {
|
||||
let cmd = extract_htop_command("Timmy htop", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_stop() {
|
||||
let cmd = extract_htop_command("@timmy htop stop", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Stop));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_duration_minutes() {
|
||||
let cmd = extract_htop_command("@timmy htop 10m", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 600 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_duration_uppercase_m() {
|
||||
let cmd = extract_htop_command("@timmy htop 2M", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 120 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_duration_seconds() {
|
||||
let cmd = extract_htop_command("@timmy htop 90", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 90 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_htop_command_returns_none() {
|
||||
let cmd = extract_htop_command("@timmy status", "Timmy", "@timmy:homeserver.local");
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_message_returns_none() {
|
||||
let cmd = extract_htop_command("hello world", "Timmy", "@timmy:homeserver.local");
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_case_insensitive() {
|
||||
let cmd = extract_htop_command("@timmy HTOP", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_full_user_id() {
|
||||
let cmd = extract_htop_command(
|
||||
"@timmy:homeserver.local htop",
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htop_with_comma_after_mention() {
|
||||
// Some Matrix clients format mentions as "@timmy, htop"
|
||||
let cmd = extract_htop_command("@timmy, htop", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(cmd, Some(HtopCommand::Start { duration_secs: 300 }));
|
||||
}
|
||||
|
||||
// -- parse_duration -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_duration_empty_returns_none() {
|
||||
assert_eq!(parse_duration(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_minutes() {
|
||||
assert_eq!(parse_duration("5m"), Some(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_seconds() {
|
||||
assert_eq!(parse_duration("120"), Some(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duration_invalid_returns_none() {
|
||||
assert_eq!(parse_duration("abc"), None);
|
||||
}
|
||||
|
||||
// -- build_htop_message -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_no_agents() {
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
assert!(text.contains("htop"), "should mention htop: {text}");
|
||||
assert!(
|
||||
text.contains("No agents currently running"),
|
||||
"should note no agents: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_contains_load() {
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
// Load average is gathered via `uptime`; it should appear in some form.
|
||||
assert!(
|
||||
text.contains("load"),
|
||||
"message should contain load info: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_shows_remaining_time() {
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
assert!(
|
||||
text.contains("auto-stops in"),
|
||||
"should show remaining time: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_load_on_own_line() {
|
||||
// Load average must be on its own line, not combined with the htop header.
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let header_line = lines.first().expect("should have a header line");
|
||||
// Header line must NOT contain "load" — load is on the second line.
|
||||
assert!(
|
||||
!header_line.contains("load"),
|
||||
"load should be on its own line, not the header: {header_line}"
|
||||
);
|
||||
// Second line must contain "load".
|
||||
let load_line = lines.get(1).expect("should have a load line");
|
||||
assert!(
|
||||
load_line.contains("load"),
|
||||
"second line should contain load info: {load_line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_no_table_syntax() {
|
||||
// Must not use Markdown table format (pipes/separators) — those are too
|
||||
// wide for narrow mobile screens.
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
assert!(
|
||||
!text.contains("|----"),
|
||||
"output must not contain table separator rows: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("| Agent"),
|
||||
"output must not contain table header row: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_htop_message_header_fits_40_chars() {
|
||||
// The header line (htop + remaining time) must fit in ~40 rendered chars.
|
||||
let pool = Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let text = build_htop_message(&pool, 0, 300);
|
||||
let header = text.lines().next().expect("should have a header line");
|
||||
// Strip markdown bold markers (**) for length calculation.
|
||||
let rendered = header.replace("**", "");
|
||||
assert!(
|
||||
rendered.len() <= 40,
|
||||
"header line too wide for mobile ({} chars): {rendered}",
|
||||
rendered.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Matrix bot integration for Story Kit.
|
||||
//!
|
||||
//! When a `.storkit/bot.toml` file is present with `enabled = true`, the
|
||||
//! server spawns a Matrix bot that:
|
||||
//!
|
||||
//! 1. Connects to the configured homeserver and joins the configured room.
|
||||
//! 2. Listens for messages from other users in the room.
|
||||
//! 3. Passes each message to Claude Code (the same provider as the web UI),
|
||||
//! which has native access to Story Kit MCP tools.
|
||||
//! 4. Posts Claude Code's response back to the room.
|
||||
//!
|
||||
//! The bot is optional — if `bot.toml` is missing or `enabled = false`, the
|
||||
//! server starts normally with no Matrix connection.
|
||||
//!
|
||||
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
|
||||
//! `bot.toml`. Each room maintains its own independent conversation history.
|
||||
|
||||
mod bot;
|
||||
pub mod commands;
|
||||
mod config;
|
||||
pub mod delete;
|
||||
pub mod htop;
|
||||
pub mod rebuild;
|
||||
pub mod reset;
|
||||
pub mod start;
|
||||
pub mod notifications;
|
||||
pub mod transport_impl;
|
||||
|
||||
pub use bot::{ConversationEntry, ConversationRole, RoomConversation, drain_complete_paragraphs};
|
||||
pub use config::BotConfig;
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::http::context::PermissionForward;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
|
||||
|
||||
/// Attempt to start the Matrix bot.
|
||||
///
|
||||
/// Reads the bot configuration from `.storkit/bot.toml`. If the file is
|
||||
/// absent or `enabled = false`, this function returns immediately without
|
||||
/// spawning anything — the server continues normally.
|
||||
///
|
||||
/// When the bot is enabled, a notification listener is also spawned that
|
||||
/// posts stage-transition messages to all configured rooms whenever a work
|
||||
/// item moves between pipeline stages.
|
||||
///
|
||||
/// `perm_rx` is the permission-request receiver shared with the MCP
|
||||
/// `prompt_permission` tool. The bot locks it during active chat sessions
|
||||
/// to surface permission prompts to the Matrix room and relay user decisions.
|
||||
///
|
||||
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
||||
pub fn spawn_bot(
|
||||
project_root: &Path,
|
||||
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
agents: Arc<AgentPool>,
|
||||
) {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
crate::slog!("[matrix-bot] bot.toml absent or disabled; Matrix integration skipped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// WhatsApp and Slack transports are handled via HTTP webhooks, not the Matrix sync loop.
|
||||
if config.transport == "whatsapp" || config.transport == "slack" {
|
||||
crate::slog!(
|
||||
"[bot] transport={} — skipping Matrix bot; webhooks handle this transport",
|
||||
config.transport
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
crate::slog!(
|
||||
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
|
||||
config.homeserver,
|
||||
config.effective_room_ids()
|
||||
);
|
||||
|
||||
let root = project_root.to_path_buf();
|
||||
let watcher_rx = watcher_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents).await {
|
||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
//! Stage transition notifications for Matrix rooms.
|
||||
//!
|
||||
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
|
||||
//! configured Matrix rooms whenever a work item moves between pipeline stages.
|
||||
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use crate::transport::ChatTransport;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Human-readable display name for a pipeline stage directory.
|
||||
pub fn stage_display_name(stage: &str) -> &'static str {
|
||||
match stage {
|
||||
"1_backlog" => "Backlog",
|
||||
"2_current" => "Current",
|
||||
"3_qa" => "QA",
|
||||
"4_merge" => "Merge",
|
||||
"5_done" => "Done",
|
||||
"6_archived" => "Archived",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer the previous pipeline stage for a given destination stage.
|
||||
///
|
||||
/// Returns `None` for `1_backlog` since items are created there (not
|
||||
/// transitioned from another stage).
|
||||
pub fn inferred_from_stage(to_stage: &str) -> Option<&'static str> {
|
||||
match to_stage {
|
||||
"2_current" => Some("Backlog"),
|
||||
"3_qa" => Some("Current"),
|
||||
"4_merge" => Some("QA"),
|
||||
"5_done" => Some("Merge"),
|
||||
"6_archived" => Some("Done"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the numeric story number from an item ID like `"261_story_slug"`.
|
||||
pub fn extract_story_number(item_id: &str) -> Option<&str> {
|
||||
item_id
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// Read the story name from the work item file's YAML front matter.
|
||||
///
|
||||
/// Returns `None` if the file doesn't exist or has no parseable name.
|
||||
pub fn read_story_name(project_root: &Path, stage: &str, item_id: &str) -> Option<String> {
|
||||
let path = project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join(stage)
|
||||
.join(format!("{item_id}.md"));
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let meta = parse_front_matter(&contents).ok()?;
|
||||
meta.name
|
||||
}
|
||||
|
||||
/// Format a stage transition notification message.
|
||||
///
|
||||
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
|
||||
pub fn format_stage_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
from_stage: &str,
|
||||
to_stage: &str,
|
||||
) -> (String, String) {
|
||||
let number = extract_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let prefix = if to_stage == "Done" { "\u{1f389} " } else { "" };
|
||||
let plain = format!("{prefix}#{number} {name} \u{2014} {from_stage} \u{2192} {to_stage}");
|
||||
let html = format!(
|
||||
"{prefix}<strong>#{number}</strong> <em>{name}</em> \u{2014} {from_stage} \u{2192} {to_stage}"
|
||||
);
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
/// Format an error notification message for a story failure.
|
||||
///
|
||||
/// Returns `(plain_text, html)` suitable for `RoomMessageEventContent::text_html`.
|
||||
pub fn format_error_notification(
|
||||
item_id: &str,
|
||||
story_name: Option<&str>,
|
||||
reason: &str,
|
||||
) -> (String, String) {
|
||||
let number = extract_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
|
||||
let html = format!(
|
||||
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
|
||||
);
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
/// Spawn a background task that listens for watcher events and posts
|
||||
/// stage-transition notifications to all configured rooms via the
|
||||
/// [`ChatTransport`] abstraction.
|
||||
pub fn spawn_notification_listener(
|
||||
transport: Arc<dyn ChatTransport>,
|
||||
room_ids: Vec<String>,
|
||||
watcher_rx: broadcast::Receiver<WatcherEvent>,
|
||||
project_root: PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = watcher_rx;
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(WatcherEvent::WorkItem {
|
||||
ref stage,
|
||||
ref item_id,
|
||||
..
|
||||
}) => {
|
||||
// Only notify on stage transitions, not creations.
|
||||
let Some(from_display) = inferred_from_stage(stage) else {
|
||||
continue;
|
||||
};
|
||||
let to_display = stage_display_name(stage);
|
||||
|
||||
let story_name = read_story_name(&project_root, stage, item_id);
|
||||
let (plain, html) = format_stage_notification(
|
||||
item_id,
|
||||
story_name.as_deref(),
|
||||
from_display,
|
||||
to_display,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending stage notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send notification to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(WatcherEvent::MergeFailure {
|
||||
ref story_id,
|
||||
ref reason,
|
||||
}) => {
|
||||
let story_name =
|
||||
read_story_name(&project_root, "4_merge", story_id);
|
||||
let (plain, html) = format_error_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
reason,
|
||||
);
|
||||
|
||||
slog!("[matrix-bot] Sending error notification: {plain}");
|
||||
|
||||
for room_id in &room_ids {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[matrix-bot] Failed to send error notification to {room_id}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Ignore non-work-item events
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog!(
|
||||
"[matrix-bot] Notification listener lagged, skipped {n} events"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
slog!(
|
||||
"[matrix-bot] Watcher channel closed, stopping notification listener"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── stage_display_name ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stage_display_name_maps_all_known_stages() {
|
||||
assert_eq!(stage_display_name("1_backlog"), "Backlog");
|
||||
assert_eq!(stage_display_name("2_current"), "Current");
|
||||
assert_eq!(stage_display_name("3_qa"), "QA");
|
||||
assert_eq!(stage_display_name("4_merge"), "Merge");
|
||||
assert_eq!(stage_display_name("5_done"), "Done");
|
||||
assert_eq!(stage_display_name("6_archived"), "Archived");
|
||||
assert_eq!(stage_display_name("unknown"), "Unknown");
|
||||
}
|
||||
|
||||
// ── inferred_from_stage ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn inferred_from_stage_returns_previous_stage() {
|
||||
assert_eq!(inferred_from_stage("2_current"), Some("Backlog"));
|
||||
assert_eq!(inferred_from_stage("3_qa"), Some("Current"));
|
||||
assert_eq!(inferred_from_stage("4_merge"), Some("QA"));
|
||||
assert_eq!(inferred_from_stage("5_done"), Some("Merge"));
|
||||
assert_eq!(inferred_from_stage("6_archived"), Some("Done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_from_stage_returns_none_for_backlog() {
|
||||
assert_eq!(inferred_from_stage("1_backlog"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_from_stage_returns_none_for_unknown() {
|
||||
assert_eq!(inferred_from_stage("9_unknown"), None);
|
||||
}
|
||||
|
||||
// ── extract_story_number ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_story_number_parses_numeric_prefix() {
|
||||
assert_eq!(
|
||||
extract_story_number("261_story_bot_notifications"),
|
||||
Some("261")
|
||||
);
|
||||
assert_eq!(extract_story_number("42_bug_fix_thing"), Some("42"));
|
||||
assert_eq!(extract_story_number("1_spike_research"), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_story_number_returns_none_for_non_numeric() {
|
||||
assert_eq!(extract_story_number("abc_story_thing"), None);
|
||||
assert_eq!(extract_story_number(""), None);
|
||||
}
|
||||
|
||||
// ── read_story_name ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_story_name_reads_from_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("42_story_my_feature.md"),
|
||||
"---\nname: My Cool Feature\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name = read_story_name(tmp.path(), "2_current", "42_story_my_feature");
|
||||
assert_eq!(name.as_deref(), Some("My Cool Feature"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_story_name_returns_none_for_missing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = read_story_name(tmp.path(), "2_current", "99_story_missing");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_story_name_returns_none_for_missing_name_field() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("42_story_no_name.md"),
|
||||
"---\ncoverage_baseline: 50%\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name = read_story_name(tmp.path(), "2_current", "42_story_no_name");
|
||||
assert_eq!(name, None);
|
||||
}
|
||||
|
||||
// ── format_error_notification ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_with_story_name() {
|
||||
let (plain, html) =
|
||||
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
|
||||
);
|
||||
assert_eq!(
|
||||
html,
|
||||
"\u{274c} <strong>#262</strong> <em>Bot error notifications</em> \u{2014} merge conflict in src/main.rs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_without_story_name_falls_back_to_item_id() {
|
||||
let (plain, _html) =
|
||||
format_error_notification("42_bug_fix_thing", None, "tests failed");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_non_numeric_id_uses_full_id() {
|
||||
let (plain, _html) =
|
||||
format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #abc_story_thing Some Story \u{2014} clippy errors"
|
||||
);
|
||||
}
|
||||
|
||||
// ── format_stage_notification ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn format_notification_done_stage_includes_party_emoji() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"353_story_done",
|
||||
Some("Done Story"),
|
||||
"Merge",
|
||||
"Done",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
||||
);
|
||||
assert_eq!(
|
||||
html,
|
||||
"\u{1f389} <strong>#353</strong> <em>Done Story</em> \u{2014} Merge \u{2192} Done"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_non_done_stage_has_no_emoji() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"42_story_thing",
|
||||
Some("Some Story"),
|
||||
"Backlog",
|
||||
"Current",
|
||||
);
|
||||
assert!(!plain.contains("\u{1f389}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_with_story_name() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"261_story_bot_notifications",
|
||||
Some("Bot notifications"),
|
||||
"Upcoming",
|
||||
"Current",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#261 Bot notifications \u{2014} Upcoming \u{2192} Current"
|
||||
);
|
||||
assert_eq!(
|
||||
html,
|
||||
"<strong>#261</strong> <em>Bot notifications</em> \u{2014} Upcoming \u{2192} Current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_without_story_name_falls_back_to_item_id() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"42_bug_fix_thing",
|
||||
None,
|
||||
"Current",
|
||||
"QA",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_non_numeric_id_uses_full_id() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"abc_story_thing",
|
||||
Some("Some Story"),
|
||||
"QA",
|
||||
"Merge",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Rebuild command: trigger a server rebuild and restart.
|
||||
//!
|
||||
//! `{bot_name} rebuild` stops all running agents, rebuilds the server binary
|
||||
//! with `cargo build`, and re-execs the process with the new binary. If the
|
||||
//! build fails the error is reported back to the room and the server keeps
|
||||
//! running.
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A parsed rebuild command.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct RebuildCommand;
|
||||
|
||||
/// Parse a rebuild command from a raw message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the command word is
|
||||
/// `rebuild`. Returns `None` when the message is not a rebuild command.
|
||||
pub fn extract_rebuild_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<RebuildCommand> {
|
||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let cmd = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, _)) => c,
|
||||
None => trimmed,
|
||||
};
|
||||
|
||||
if cmd.eq_ignore_ascii_case("rebuild") {
|
||||
Some(RebuildCommand)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a rebuild command: trigger server rebuild and restart.
|
||||
///
|
||||
/// Returns a string describing the outcome. On build failure the error
|
||||
/// message is returned so it can be posted to the room; the server keeps
|
||||
/// running. On success this function never returns (the process re-execs).
|
||||
pub async fn handle_rebuild(
|
||||
bot_name: &str,
|
||||
project_root: &Path,
|
||||
agents: &Arc<AgentPool>,
|
||||
) -> String {
|
||||
crate::slog!("[matrix-bot] rebuild command received (bot={bot_name})");
|
||||
match crate::rebuild::rebuild_and_restart(agents, project_root).await {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("Rebuild failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
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()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_with_display_name() {
|
||||
let cmd = extract_rebuild_command("Timmy rebuild", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd = extract_rebuild_command(
|
||||
"@timmy:home.local rebuild",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_localpart() {
|
||||
let cmd = extract_rebuild_command("@timmy rebuild", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive() {
|
||||
let cmd = extract_rebuild_command("Timmy REBUILD", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_rebuild_returns_none() {
|
||||
let cmd = extract_rebuild_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ignores_extra_args() {
|
||||
// "rebuild" with trailing text is still a rebuild command
|
||||
let cmd = extract_rebuild_command("Timmy rebuild now", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_match_returns_none() {
|
||||
let cmd = extract_rebuild_command("Timmy status", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
//! Reset command: clear the current Claude Code session for a room.
|
||||
//!
|
||||
//! `{bot_name} reset` drops the stored session ID and conversation history for
|
||||
//! the current room so the next message starts a brand-new Claude Code session
|
||||
//! with clean context. File-system memories (auto-memory directory) are not
|
||||
//! affected — only the in-memory/persisted conversation state is cleared.
|
||||
|
||||
use crate::matrix::bot::{ConversationHistory, RoomConversation};
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use std::path::Path;
|
||||
|
||||
/// A parsed reset command.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ResetCommand;
|
||||
|
||||
/// Parse a reset command from a raw message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the command word is
|
||||
/// `reset`. Returns `None` when the message is not a reset command at all.
|
||||
pub fn extract_reset_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<ResetCommand> {
|
||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let cmd = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, _)) => c,
|
||||
None => trimmed,
|
||||
};
|
||||
|
||||
if cmd.eq_ignore_ascii_case("reset") {
|
||||
Some(ResetCommand)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a reset command: clear the session ID and conversation entries for
|
||||
/// the given room, persist the updated history, and return a confirmation.
|
||||
pub async fn handle_reset(
|
||||
bot_name: &str,
|
||||
room_id: &OwnedRoomId,
|
||||
history: &ConversationHistory,
|
||||
project_root: &Path,
|
||||
) -> String {
|
||||
{
|
||||
let mut guard = history.lock().await;
|
||||
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
crate::matrix::bot::save_history(project_root, &guard);
|
||||
}
|
||||
crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})");
|
||||
"Session reset. Starting fresh — previous context has been cleared.".to_string()
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
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()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_with_display_name() {
|
||||
let cmd = extract_reset_command("Timmy reset", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd =
|
||||
extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_localpart() {
|
||||
let cmd = extract_reset_command("@timmy reset", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive() {
|
||||
let cmd = extract_reset_command("Timmy RESET", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_reset_returns_none() {
|
||||
let cmd = extract_reset_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ignores_extra_args() {
|
||||
// "reset" with trailing text is still a reset command
|
||||
let cmd = extract_reset_command("Timmy reset everything", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_reset_clears_session_and_entries() {
|
||||
use crate::matrix::bot::{ConversationEntry, ConversationRole};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
|
||||
let history: ConversationHistory = Arc::new(TokioMutex::new({
|
||||
let mut m = HashMap::new();
|
||||
m.insert(room_id.clone(), RoomConversation {
|
||||
session_id: Some("old-session-id".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: "@alice:example.com".to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
});
|
||||
m
|
||||
}));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
|
||||
|
||||
assert!(response.contains("reset"), "response should mention reset: {response}");
|
||||
|
||||
let guard = history.lock().await;
|
||||
let conv = guard.get(&room_id).unwrap();
|
||||
assert!(conv.session_id.is_none(), "session_id should be cleared");
|
||||
assert!(conv.entries.is_empty(), "entries should be cleared");
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
//! Start command: start a coder agent on a story.
|
||||
//!
|
||||
//! `{bot_name} start {number}` finds the story by number, selects the default
|
||||
//! coder agent, and starts it.
|
||||
//!
|
||||
//! `{bot_name} start {number} opus` starts `coder-opus` (or any agent whose
|
||||
//! name ends with the supplied hint, e.g. `coder-{hint}`).
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use std::path::Path;
|
||||
|
||||
/// A parsed start command from a Matrix message body.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum StartCommand {
|
||||
/// Start the story with this number using the (optional) agent hint.
|
||||
Start {
|
||||
story_number: String,
|
||||
/// Optional agent name hint (e.g. `"opus"` → resolved to `"coder-opus"`).
|
||||
agent_hint: Option<String>,
|
||||
},
|
||||
/// The user typed `start` but without a valid numeric argument.
|
||||
BadArgs,
|
||||
}
|
||||
|
||||
/// Parse a start command from a raw Matrix message body.
|
||||
///
|
||||
/// Strips the bot mention prefix and checks whether the first word is `start`.
|
||||
/// Returns `None` when the message is not a start command at all.
|
||||
pub fn extract_start_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<StartCommand> {
|
||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, a)) => (c, a.trim()),
|
||||
None => (trimmed, ""),
|
||||
};
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("start") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Split args into story number and optional agent hint.
|
||||
let (number_str, hint_str) = match args.split_once(char::is_whitespace) {
|
||||
Some((n, h)) => (n.trim(), h.trim()),
|
||||
None => (args, ""),
|
||||
};
|
||||
|
||||
if !number_str.is_empty() && number_str.chars().all(|c| c.is_ascii_digit()) {
|
||||
let agent_hint = if hint_str.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(hint_str.to_string())
|
||||
};
|
||||
Some(StartCommand::Start {
|
||||
story_number: number_str.to_string(),
|
||||
agent_hint,
|
||||
})
|
||||
} else {
|
||||
Some(StartCommand::BadArgs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a start command asynchronously.
|
||||
///
|
||||
/// Finds the work item by `story_number` across all pipeline stages, resolves
|
||||
/// the agent name from `agent_hint`, and calls `agents.start_agent`.
|
||||
/// Returns a markdown-formatted response string.
|
||||
pub async fn handle_start(
|
||||
bot_name: &str,
|
||||
story_number: &str,
|
||||
agent_hint: Option<&str>,
|
||||
project_root: &Path,
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id)
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".storkit").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
if let Some(stem) = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (path, story_id) = match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name)
|
||||
})
|
||||
.unwrap_or_else(|| story_id.clone());
|
||||
|
||||
// Resolve agent name: try "coder-{hint}" first, then the hint as-is.
|
||||
let resolved_agent: Option<String> = agent_hint.map(|hint| {
|
||||
let with_prefix = format!("coder-{hint}");
|
||||
// We'll pass the prefixed form; start_agent validates against config.
|
||||
// If coder- prefix is already there, don't double-prefix.
|
||||
if hint.starts_with("coder-") {
|
||||
hint.to_string()
|
||||
} else {
|
||||
with_prefix
|
||||
}
|
||||
});
|
||||
|
||||
crate::slog!(
|
||||
"[matrix-bot] start command: starting story {story_id} with agent={resolved_agent:?} (bot={bot_name})"
|
||||
);
|
||||
|
||||
match agents
|
||||
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
format!(
|
||||
"Started **{story_name}** with agent **{}**.",
|
||||
info.agent_name
|
||||
)
|
||||
}
|
||||
Err(e) if e.contains("All coder agents are busy") => {
|
||||
format!(
|
||||
"**{story_name}** has been queued in `work/2_current/` and will start \
|
||||
automatically when a coder becomes available."
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
format!("Failed to start **{story_name}**: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||
///
|
||||
/// Mirrors the logic in `commands::strip_bot_mention` and `delete::strip_mention`.
|
||||
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
}
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
}
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
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()..];
|
||||
match rest.chars().next() {
|
||||
None => Some(rest),
|
||||
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||
_ => Some(rest),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- extract_start_command -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd =
|
||||
extract_start_command("@timmy:home.local start 331", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(StartCommand::Start {
|
||||
story_number: "331".to_string(),
|
||||
agent_hint: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_display_name() {
|
||||
let cmd = extract_start_command("Timmy start 42", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(StartCommand::Start {
|
||||
story_number: "42".to_string(),
|
||||
agent_hint: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_localpart() {
|
||||
let cmd = extract_start_command("@timmy start 7", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(StartCommand::Start {
|
||||
story_number: "7".to_string(),
|
||||
agent_hint: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_with_agent_hint() {
|
||||
let cmd = extract_start_command("Timmy start 331 opus", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(StartCommand::Start {
|
||||
story_number: "331".to_string(),
|
||||
agent_hint: Some("opus".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_case_insensitive_command() {
|
||||
let cmd = extract_start_command("Timmy START 99", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(StartCommand::Start {
|
||||
story_number: "99".to_string(),
|
||||
agent_hint: None
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_no_args_is_bad_args() {
|
||||
let cmd = extract_start_command("Timmy start", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(StartCommand::BadArgs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_numeric_arg_is_bad_args() {
|
||||
let cmd = extract_start_command("Timmy start foo", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(StartCommand::BadArgs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_non_start_command_returns_none() {
|
||||
let cmd = extract_start_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
// -- handle_start (integration-style, uses temp filesystem) --------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_start_returns_not_found_for_unknown_number() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path();
|
||||
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(project_root.join(".storkit").join("work").join(stage))
|
||||
.unwrap();
|
||||
}
|
||||
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||
let response = handle_start("Timmy", "999", None, project_root, &agents).await;
|
||||
assert!(
|
||||
response.contains("No story") && response.contains("999"),
|
||||
"unexpected response: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_start_says_queued_not_error_when_all_coders_busy() {
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use std::sync::Arc;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path();
|
||||
let sk = project_root.join(".storkit");
|
||||
let backlog = sk.join("work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
backlog.join("356_story_test.md"),
|
||||
"---\nname: Test Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
agents.inject_test_agent("other-story", "coder-1", AgentStatus::Running);
|
||||
|
||||
let response = handle_start("Timmy", "356", None, project_root, &agents).await;
|
||||
|
||||
assert!(
|
||||
!response.contains("Failed"),
|
||||
"response must not say 'Failed' when coders are busy: {response}"
|
||||
);
|
||||
assert!(
|
||||
response.to_lowercase().contains("queue") || response.to_lowercase().contains("available"),
|
||||
"response must mention queued/available state: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_command_is_registered() {
|
||||
use crate::matrix::commands::commands;
|
||||
let found = commands().iter().any(|c| c.name == "start");
|
||||
assert!(found, "start command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_command_appears_in_help() {
|
||||
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("start"),
|
||||
"help should list start command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_command_falls_through_to_none_in_registry() {
|
||||
// The start handler in the registry returns None (handled async in bot.rs).
|
||||
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy start 42",
|
||||
);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"start should not produce a sync response (handled async): {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Matrix implementation of [`ChatTransport`].
|
||||
//!
|
||||
//! Wraps a [`matrix_sdk::Client`] and delegates message sending / editing
|
||||
//! to the Matrix SDK.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use matrix_sdk::Client;
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use matrix_sdk::ruma::events::room::message::{
|
||||
ReplacementMetadata, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||
};
|
||||
|
||||
use crate::transport::{ChatTransport, MessageId};
|
||||
|
||||
/// Matrix-backed [`ChatTransport`] implementation.
|
||||
///
|
||||
/// Holds a [`Client`] and resolves room IDs at send time.
|
||||
pub struct MatrixTransport {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl MatrixTransport {
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatTransport for MatrixTransport {
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
let room_id: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
|
||||
let room = self
|
||||
.client
|
||||
.get_room(&room_id)
|
||||
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
|
||||
|
||||
let content = RoomMessageEventContent::text_html(plain.to_string(), html.to_string());
|
||||
let resp = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| format!("Matrix send error: {e}"))?;
|
||||
|
||||
Ok(resp.event_id.to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
original_message_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<(), String> {
|
||||
let room_id: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
|
||||
let room = self
|
||||
.client
|
||||
.get_room(&room_id)
|
||||
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
|
||||
|
||||
let original_event_id = original_message_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid event ID '{original_message_id}': {e}"))?;
|
||||
|
||||
let new_content =
|
||||
RoomMessageEventContentWithoutRelation::text_html(plain.to_string(), html.to_string());
|
||||
let metadata = ReplacementMetadata::new(original_event_id, None);
|
||||
let content = new_content.make_replacement(metadata);
|
||||
|
||||
room.send(content)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Matrix edit error: {e}"))
|
||||
}
|
||||
|
||||
async fn send_typing(&self, room_id: &str, typing: bool) -> Result<(), String> {
|
||||
let room_id: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid room ID '{room_id}': {e}"))?;
|
||||
let room = self
|
||||
.client
|
||||
.get_room(&room_id)
|
||||
.ok_or_else(|| format!("Room {room_id} not found in client state"))?;
|
||||
|
||||
room.typing_notice(typing)
|
||||
.await
|
||||
.map_err(|e| format!("Matrix typing indicator error: {e}"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user