diff --git a/crates/claudear-integrations/src/notifier/html.rs b/crates/claudear-integrations/src/notifier/html.rs new file mode 100644 index 0000000..dc98fa9 --- /dev/null +++ b/crates/claudear-integrations/src/notifier/html.rs @@ -0,0 +1,40 @@ +//! Escaping for notifier channels that render HTML. + +/// Escape `text` so an HTML renderer shows it verbatim, inside element text or a +/// double-quoted attribute value. +pub(super) fn escape(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + other => escaped.push(other), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::escape; + + #[test] + fn escapes_markup_characters() { + assert_eq!( + escape("Tom & Jerry"), + "<a href="x">Tom & Jerry</a>" + ); + } + + #[test] + fn escapes_existing_entities_again() { + assert_eq!(escape("&"), "&amp;"); + } + + #[test] + fn leaves_plain_text_unchanged() { + assert_eq!(escape("PROJ-1 'quoted' ünïcode"), "PROJ-1 'quoted' ünïcode"); + } +} diff --git a/crates/claudear-integrations/src/notifier/mod.rs b/crates/claudear-integrations/src/notifier/mod.rs index 4382603..d910ea7 100644 --- a/crates/claudear-integrations/src/notifier/mod.rs +++ b/crates/claudear-integrations/src/notifier/mod.rs @@ -49,6 +49,7 @@ pub mod ask_orchestrator; mod console; mod discord; mod email; +mod html; mod push; mod slack; mod sms; diff --git a/crates/claudear-integrations/src/notifier/telegram.rs b/crates/claudear-integrations/src/notifier/telegram.rs index 50da229..64cbc4c 100644 --- a/crates/claudear-integrations/src/notifier/telegram.rs +++ b/crates/claudear-integrations/src/notifier/telegram.rs @@ -1,5 +1,6 @@ //! Telegram notifier via Telegram Bot API. +use super::html; use super::Notifier; use crate::ask_reply_inbox; use abnegate_http::HttpResponse; @@ -19,6 +20,9 @@ use serde::Deserialize; use std::collections::HashSet; use std::sync::RwLock; +const MAXIMUM_TEXT_LENGTH: usize = 4096; +const TRUNCATION_MARKER: &str = "..."; + /// Trait for HTTP client used by Telegram notifier. #[async_trait] pub trait TelegramHttpClient: Send + Sync { @@ -387,12 +391,13 @@ impl TelegramNotifier { let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token); - // Truncate message to Telegram limit (4096 chars) - let truncated_text = if text.len() > 4096 { - format!("{}...", &text[..text.floor_char_boundary(4093)]) + let truncated_text = if text.len() > MAXIMUM_TEXT_LENGTH { + let kept = text.floor_char_boundary(MAXIMUM_TEXT_LENGTH - TRUNCATION_MARKER.len()); + format!("{}{TRUNCATION_MARKER}", &text[..kept]) } else { text.to_string() }; + let escaped_text = html::escape(&truncated_text); let recipients = self.resolve_recipients(issue); @@ -401,7 +406,7 @@ impl TelegramNotifier { for chat_id in &recipients { let body = serde_json::json!({ "chat_id": chat_id, - "text": truncated_text, + "text": escaped_text, "parse_mode": "HTML" }); @@ -758,8 +763,6 @@ mod tests { } } - // --- Basic trait tests --- - #[test] fn test_name() { let notifier = TelegramNotifier::new(disabled_config(), empty_registry()); @@ -788,8 +791,6 @@ mod tests { ); } - // --- Disabled config tests (no HTTP calls) --- - #[tokio::test] async fn test_notify_start_disabled() { let notifier = TelegramNotifier::new(disabled_config(), empty_registry()); @@ -891,8 +892,6 @@ mod tests { assert!(notifier.is_enabled()); } - // --- Mock-based tests for HTTP-dependent functionality --- - #[tokio::test] async fn test_send_message_success() { let mock = MockTelegramClient::success(); @@ -1525,6 +1524,60 @@ mod tests { assert!(text.contains("Memory leak in worker")); } + #[tokio::test] + async fn test_notify_start_escapes_html_in_title() { + let mock = MockTelegramClient::success(); + let notifier = TelegramNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new( + "1", + "SEN-42", + " & \"co\"", + "https://sentry.io/42", + "sentry", + ); + notifier.notify_start(&issue).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + assert_eq!( + calls[0].1["text"], + "[Claudear] Processing SEN-42 from sentry - <script>alert(1)</script> & "co"" + ); + } + + #[tokio::test] + async fn test_notify_failed_escapes_html_in_error() { + let mock = MockTelegramClient::success(); + let notifier = TelegramNotifier::with_http_client(enabled_config(), mock); + let issue = Issue::new("1", "SEN-42", "Title", "https://sentry.io/42", "sentry"); + notifier + .notify_failed(&issue, "expected `Vec` & got ``") + .await + .unwrap(); + + let calls = notifier.http.get_last_calls(); + assert_eq!( + calls[0].1["text"], + "[Claudear] FAILED SEN-42: expected `Vec<u8>` & got `<none>`" + ); + } + + #[tokio::test] + async fn test_send_message_truncates_before_escaping() { + let mock = MockTelegramClient::success(); + let notifier = TelegramNotifier::with_http_client(enabled_config(), mock); + notifier.notify_status(&"&".repeat(5000)).await.unwrap(); + + let calls = notifier.http.get_last_calls(); + let text = calls[0].1["text"].as_str().unwrap(); + let body = text + .strip_prefix("[Claudear] ") + .and_then(|rest| rest.strip_suffix("...")) + .expect("truncated text keeps its prefix and marker"); + let kept = 4093 - "[Claudear] ".len(); + assert_eq!(body.matches("&").count(), kept); + assert_eq!(body.len(), kept * "&".len()); + } + #[tokio::test] async fn test_notify_failed_short_error_not_truncated() { let mock = MockTelegramClient::success(); @@ -1590,8 +1643,6 @@ mod tests { assert_eq!(calls[0].1["chat_id"], "999999999"); } - // --- Tests for cascade success message --- - #[tokio::test] async fn test_notify_success_cascade_message_format() { let mock = MockTelegramClient::success(); @@ -1612,8 +1663,6 @@ mod tests { assert!(text.contains("https://github.com/downstream/repo/pull/5")); } - // --- Tests for PR update success message --- - #[tokio::test] async fn test_notify_success_pr_update_message_format() { let mock = MockTelegramClient::success(); @@ -1633,8 +1682,6 @@ mod tests { assert!(text.contains("https://github.com/org/repo/pull/77")); } - // --- Tests for regression resolved completed message --- - #[tokio::test] async fn test_notify_completed_regression_resolved_message_format() { let mock = MockTelegramClient::success(); @@ -1651,8 +1698,6 @@ mod tests { assert!(text.contains("no regression")); } - // --- Tests for regression detected failed message --- - #[tokio::test] async fn test_notify_failed_regression_detected_message_format() { let mock = MockTelegramClient::success(); @@ -1672,8 +1717,6 @@ mod tests { assert!(text.contains("Tests failing again")); } - // --- Tests for cascade failed message --- - #[tokio::test] async fn test_notify_failed_cascade_message_format() { let mock = MockTelegramClient::success(); @@ -1691,8 +1734,6 @@ mod tests { assert!(text.contains("Build error")); } - // --- Tests for notify_merged and notify_closed --- - #[tokio::test] async fn test_notify_merged_message_format() { let mock = MockTelegramClient::success(); @@ -1729,8 +1770,6 @@ mod tests { assert!(text.contains("https://github.com/org/repo/pull/43")); } - // --- Test failed cascade with long error truncation --- - #[tokio::test] async fn test_notify_failed_cascade_truncates_long_error() { let mock = MockTelegramClient::success(); @@ -1747,8 +1786,6 @@ mod tests { assert!(text.contains("...")); } - // --- Test regression with long error truncation --- - #[tokio::test] async fn test_notify_failed_regression_truncates_long_error() { let mock = MockTelegramClient::success(); @@ -1765,8 +1802,6 @@ mod tests { assert!(text.contains("...")); } - // --- Test parse_mode is always HTML --- - #[tokio::test] async fn test_all_messages_use_html_parse_mode() { let mock = MockTelegramClient::success(); @@ -1792,8 +1827,6 @@ mod tests { } } - // --- Test multiple recipients get the same text --- - #[tokio::test] async fn test_multiple_recipients_receive_same_text() { let mock = MockTelegramClient::success(); @@ -1813,8 +1846,6 @@ mod tests { assert_eq!(calls[2].1["chat_id"], "333333333"); } - // --- Test config with only to_chat_ids (no primary chat_id) --- - #[tokio::test] async fn test_config_with_only_to_chat_ids() { let mock = MockTelegramClient::success(); @@ -1827,8 +1858,6 @@ mod tests { assert_eq!(calls[0].1["chat_id"], "444444444"); } - // --- Test http_response_fields --- - #[test] fn test_http_response_fields() { let response = HttpResponse::new(201, "Created"); @@ -1836,8 +1865,6 @@ mod tests { assert_eq!(response.body, "Created"); } - // --- Additional coverage tests --- - #[tokio::test] async fn test_notify_merged_disabled() { let notifier = TelegramNotifier::new(disabled_config(), empty_registry());