storkit: merge 430_bug_status_command_traffic_light_dots_not_coloured_in_matrix
This commit is contained in:
@@ -180,6 +180,54 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`try_handle_command`] but returns `(plain_body, html_body)`.
|
||||||
|
///
|
||||||
|
/// The plain body is unchanged Markdown text suitable for the Matrix `body`
|
||||||
|
/// field (non-HTML clients). The HTML body is suitable for `formatted_body`.
|
||||||
|
///
|
||||||
|
/// The pipeline-status command (no args) injects Matrix `<font data-mx-color>`
|
||||||
|
/// tags on the traffic-light dots. All other commands produce HTML by running
|
||||||
|
/// the plain body through pulldown-cmark.
|
||||||
|
pub fn try_handle_command_with_html(
|
||||||
|
dispatch: &CommandDispatch<'_>,
|
||||||
|
message: &str,
|
||||||
|
) -> Option<(String, String)> {
|
||||||
|
let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
|
||||||
|
let trimmed = command_text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
|
||||||
|
Some((c, a)) => (c, a.trim()),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
// Only the no-arg status variant shows the pipeline with traffic-light
|
||||||
|
// dots; `status <number>` is a triage dump that needs no colour tags.
|
||||||
|
if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
|
||||||
|
let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
|
||||||
|
let html = status::build_pipeline_status_html(dispatch.project_root, dispatch.agents);
|
||||||
|
return Some((body, html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Generic path: plain text body → Markdown-to-HTML.
|
||||||
|
let body = try_handle_command(dispatch, message)?;
|
||||||
|
let html = plain_to_html(&body);
|
||||||
|
Some((body, html))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a Markdown string to HTML using the same options as the Matrix
|
||||||
|
/// transport's `markdown_to_html` helper.
|
||||||
|
fn plain_to_html(markdown: &str) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
let normalized = crate::chat::util::normalize_line_breaks(markdown);
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS;
|
||||||
|
let parser = Parser::new_ext(&normalized, options);
|
||||||
|
let mut out = String::new();
|
||||||
|
html::push_html(&mut out, parser);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to match a user message against a registered bot command.
|
/// Try to match a user message against a registered bot command.
|
||||||
///
|
///
|
||||||
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
|
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
|
||||||
|
|||||||
@@ -122,6 +122,34 @@ fn read_stage_items(
|
|||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the HTML `formatted_body` for the pipeline status with Matrix colour
|
||||||
|
/// tags on the traffic-light dots.
|
||||||
|
///
|
||||||
|
/// Converts the plain-text pipeline status (Markdown) to HTML via
|
||||||
|
/// pulldown-cmark and wraps each traffic-light character in a
|
||||||
|
/// `<font data-mx-color="#rrggbb">` tag so Matrix clients display them in
|
||||||
|
/// colour.
|
||||||
|
pub(super) fn build_pipeline_status_html(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
|
||||||
|
let plain = build_pipeline_status(project_root, agents);
|
||||||
|
let normalized = crate::chat::util::normalize_line_breaks(&plain);
|
||||||
|
let options = Options::ENABLE_TABLES
|
||||||
|
| Options::ENABLE_FOOTNOTES
|
||||||
|
| Options::ENABLE_STRIKETHROUGH
|
||||||
|
| Options::ENABLE_TASKLISTS;
|
||||||
|
let parser = Parser::new_ext(&normalized, options);
|
||||||
|
let mut html_out = String::new();
|
||||||
|
html::push_html(&mut html_out, parser);
|
||||||
|
|
||||||
|
// Wrap each traffic-light character with a Matrix colour tag.
|
||||||
|
html_out
|
||||||
|
.replace('\u{2717}', "<font data-mx-color=\"#cc0000\">\u{2717}</font>") // ✗ blocked
|
||||||
|
.replace('\u{25D1}', "<font data-mx-color=\"#ffaa00\">\u{25D1}</font>") // ◑ throttled
|
||||||
|
.replace('\u{25CF}', "<font data-mx-color=\"#00cc00\">\u{25CF}</font>") // ● running
|
||||||
|
.replace('\u{25CB}', "<font data-mx-color=\"#888888\">\u{25CB}</font>") // ○ idle
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the full pipeline status text formatted for Matrix (markdown).
|
/// Build the full pipeline status text formatted for Matrix (markdown).
|
||||||
pub(super) fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String {
|
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.
|
// Build a map from story_id → active AgentInfo for quick lookup.
|
||||||
@@ -444,6 +472,81 @@ mod tests {
|
|||||||
|
|
||||||
// -- traffic_light_dot --------------------------------------------------
|
// -- traffic_light_dot --------------------------------------------------
|
||||||
|
|
||||||
|
// -- build_pipeline_status_html (colored dots) --------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_colors_idle_dot_grey() {
|
||||||
|
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("42_story_idle.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("<font data-mx-color=\"#888888\">\u{25CB}</font>"),
|
||||||
|
"idle dot should be grey (#888888): {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_colors_blocked_dot_red() {
|
||||||
|
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("42_story_blocked.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let html = build_pipeline_status_html(tmp.path(), &agents);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
html.contains("<font data-mx-color=\"#cc0000\">\u{2717}</font>"),
|
||||||
|
"blocked dot should be red (#cc0000): {html}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_status_plain_text_body_unchanged() {
|
||||||
|
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("42_story_idle.md");
|
||||||
|
let mut f = std::fs::File::create(&story_path).unwrap();
|
||||||
|
writeln!(f, "---\nname: Idle Story\n---\n").unwrap();
|
||||||
|
|
||||||
|
let agents = AgentPool::new_test(3000);
|
||||||
|
let plain = build_pipeline_status(tmp.path(), &agents);
|
||||||
|
|
||||||
|
// Plain text must still use bare Unicode dots (no HTML tags).
|
||||||
|
assert!(
|
||||||
|
plain.contains('\u{25CB}'),
|
||||||
|
"plain text should have bare Unicode idle dot: {plain}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!plain.contains("data-mx-color"),
|
||||||
|
"plain text must not contain HTML colour attributes: {plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- traffic_light_dot --------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dot_idle_when_no_agent() {
|
fn dot_idle_when_no_agent() {
|
||||||
assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○
|
assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○
|
||||||
|
|||||||
@@ -186,10 +186,9 @@ pub(super) async fn on_room_message(
|
|||||||
ambient_rooms: &ctx.ambient_rooms,
|
ambient_rooms: &ctx.ambient_rooms,
|
||||||
room_id: &room_id_str,
|
room_id: &room_id_str,
|
||||||
};
|
};
|
||||||
if let Some(response) = super::super::commands::try_handle_command(&dispatch, &user_message) {
|
if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) {
|
||||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||||
let html = markdown_to_html(&response);
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await
|
||||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
|
||||||
&& let Ok(event_id) = msg_id.parse()
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
{
|
{
|
||||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
|||||||
Reference in New Issue
Block a user