diff --git a/chattermax-server/src/hooks/config.rs b/chattermax-server/src/hooks/config.rs index 16c4069..1a70f76 100644 --- a/chattermax-server/src/hooks/config.rs +++ b/chattermax-server/src/hooks/config.rs @@ -63,6 +63,9 @@ pub struct HookFilter { /// Composite filter logic #[serde(default)] pub composite: Option, + + /// Match on custom XEP message type (e.g., "tool_call", "thought") + pub custom_message_type: Option, } /// Composite filter with AND/OR/NOT logic diff --git a/chattermax-server/src/hooks/filter.rs b/chattermax-server/src/hooks/filter.rs index 1780459..12af148 100644 --- a/chattermax-server/src/hooks/filter.rs +++ b/chattermax-server/src/hooks/filter.rs @@ -4,10 +4,36 @@ use crate::hooks::HookError; use crate::hooks::HookFilter; use crate::hooks::config::CompositeFilter; use crate::hooks::errors::Result; +use chattermax_core::types::MessageType; use minidom::Element; use regex::Regex; use std::collections::HashMap; +/// Extract custom message type from XMPP message element +pub fn extract_custom_message_type(message: &Element) -> Option { + // Check for custom namespace children + for child in message.children() { + let ns = child.ns(); + match ns.as_str() { + "jabber:x:chibi:thought" => return Some(MessageType::Thought), + "jabber:x:chibi:tool_call" => return Some(MessageType::ToolCall), + "jabber:x:chibi:tool_result" => return Some(MessageType::ToolResult), + "jabber:x:chibi:todo" => return Some(MessageType::Todo), + "jabber:x:chibi:code_change" => return Some(MessageType::CodeChange), + "jabber:x:chibi:integration" => return Some(MessageType::Integration), + "jabber:x:chibi:review_comment" => return Some(MessageType::ReviewComment), + "jabber:x:chibi:work_available" => return Some(MessageType::WorkAvailable), + "jabber:x:chibi:question" => return Some(MessageType::Question), + "jabber:x:chibi:answer" => return Some(MessageType::Answer), + "jabber:x:chibi:status_update" => return Some(MessageType::StatusUpdate), + "jabber:x:chibi:feature_complete" => return Some(MessageType::FeatureComplete), + _ => continue, + } + } + + None +} + /// Extract variables from an XMPP message pub fn extract_variables(message: &Element) -> HashMap { let mut vars = HashMap::new(); @@ -80,6 +106,39 @@ pub fn matches_filter(message: &Element, filter: &HookFilter) -> Result { } } + // Check custom message type + if let Some(ref custom_type_str) = filter.custom_message_type { + let expected_type = match custom_type_str.as_str() { + "thought" => MessageType::Thought, + "tool_call" => MessageType::ToolCall, + "tool_result" => MessageType::ToolResult, + "todo" => MessageType::Todo, + "code_change" => MessageType::CodeChange, + "integration" => MessageType::Integration, + "review_comment" => MessageType::ReviewComment, + "work_available" => MessageType::WorkAvailable, + "question" => MessageType::Question, + "answer" => MessageType::Answer, + "status_update" => MessageType::StatusUpdate, + "feature_complete" => MessageType::FeatureComplete, + _ => { + return Err(HookError::FilterError(format!( + "Unknown custom message type: {}", + custom_type_str + ))); + } + }; + + if let Some(actual_type) = extract_custom_message_type(message) { + if actual_type != expected_type { + return Ok(false); + } + } else { + // Message doesn't have a custom type, so it can't match + return Ok(false); + } + } + // Check composite filters if let Some(ref composite) = filter.composite && !matches_composite(message, composite)? @@ -149,8 +208,10 @@ mod tests { .attr("type", "groupchat") .build(); - let mut filter = HookFilter::default(); - filter.room_pattern = Some("^feature/.*".to_string()); + let filter = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; assert!(matches_filter(&msg, &filter).unwrap()); } @@ -162,8 +223,10 @@ mod tests { .attr("type", "groupchat") .build(); - let mut filter = HookFilter::default(); - filter.room_pattern = Some("^feature/.*".to_string()); + let filter = HookFilter { + room_pattern: Some("^feature/.*".to_string()), + ..Default::default() + }; assert!(!matches_filter(&msg, &filter).unwrap()); } @@ -214,4 +277,108 @@ mod tests { assert!(matches_composite(&msg, &composite).unwrap()); } + + #[test] + fn test_extract_custom_message_type_tool_call() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "bob@localhost") + .attr("type", "chat") + .append( + minidom::Element::builder("tool_call", "jabber:x:chibi:tool_call") + .append( + minidom::Element::builder("tool_name", "jabber:x:chibi:tool_call") + .append("run_tests") + .build(), + ) + .build(), + ) + .build(); + + let result = extract_custom_message_type(&message); + assert_eq!(result, Some(MessageType::ToolCall)); + } + + #[test] + fn test_extract_custom_message_type_none() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "bob@localhost") + .attr("type", "chat") + .append( + minidom::Element::builder("body", "jabber:client") + .append("Hello") + .build(), + ) + .build(); + + let result = extract_custom_message_type(&message); + assert_eq!(result, None); + } + + #[test] + fn test_filter_custom_message_type_match() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("type", "groupchat") + .append( + minidom::Element::builder("thought", "jabber:x:chibi:thought") + .append( + minidom::Element::builder("content", "jabber:x:chibi:thought") + .append("Thinking about the problem") + .build(), + ) + .build(), + ) + .build(); + + let filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!(matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_no_match() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("type", "groupchat") + .append(minidom::Element::builder("thought", "jabber:x:chibi:thought").build()) + .build(); + + let filter = HookFilter { + custom_message_type: Some("tool_call".to_string()), + ..Default::default() + }; + + assert!(!matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_thought() { + let message = minidom::Element::builder("message", "jabber:client") + .attr("from", "agent1@localhost") + .append(minidom::Element::builder("thought", "jabber:x:chibi:thought").build()) + .build(); + + let filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!(matches_filter(&message, &filter).unwrap()); + } + + #[test] + fn test_filter_custom_message_type_unknown() { + let message = minidom::Element::builder("message", "jabber:client").build(); + + let filter = HookFilter { + custom_message_type: Some("unknown_type".to_string()), + ..Default::default() + }; + + let result = matches_filter(&message, &filter); + assert!(result.is_err()); + } } diff --git a/chattermax-server/tests/integration.rs b/chattermax-server/tests/integration.rs index d09e900..b85bd46 100644 --- a/chattermax-server/tests/integration.rs +++ b/chattermax-server/tests/integration.rs @@ -585,3 +585,96 @@ async fn test_presence_broadcast() { // No response expected for presence - just verify no crash tokio::time::sleep(Duration::from_millis(100)).await; } + +// ============================================================================ +// Custom Message Type Tests +// ============================================================================ + +#[tokio::test] +async fn test_custom_message_type_hook_filtering() { + use chattermax_server::hooks::HookFilter; + use chattermax_server::hooks::filter::{extract_custom_message_type, matches_filter}; + + // Create a thought message with custom namespace + let thought_message = minidom::Element::builder("message", "jabber:client") + .attr("from", "alice@localhost") + .attr("to", "agents@localhost") + .attr("type", "groupchat") + .append( + minidom::Element::builder("thought", "jabber:x:chibi:thought") + .append( + minidom::Element::builder("content", "jabber:x:chibi:thought") + .append("Analyzing the problem space") + .build(), + ) + .build(), + ) + .build(); + + // Extract should find the thought type + let extracted_type = extract_custom_message_type(&thought_message); + assert_eq!( + extracted_type, + Some(chattermax_core::types::MessageType::Thought), + "Should extract Thought message type" + ); + + // Filter with matching custom type should pass + let thought_filter = HookFilter { + custom_message_type: Some("thought".to_string()), + ..Default::default() + }; + + assert!( + matches_filter(&thought_message, &thought_filter).unwrap(), + "Thought message should match thought filter" + ); + + // Filter with non-matching custom type should fail + let tool_call_filter = HookFilter { + custom_message_type: Some("tool_call".to_string()), + ..Default::default() + }; + + assert!( + !matches_filter(&thought_message, &tool_call_filter).unwrap(), + "Thought message should not match tool_call filter" + ); + + // Create a tool_call message + let tool_call_message = minidom::Element::builder("message", "jabber:client") + .attr("from", "agent1@localhost") + .attr("to", "agents@localhost") + .attr("type", "groupchat") + .append( + minidom::Element::builder("tool_call", "jabber:x:chibi:tool_call") + .append( + minidom::Element::builder("tool_name", "jabber:x:chibi:tool_call") + .append("run_tests") + .build(), + ) + .append(minidom::Element::builder("arguments", "jabber:x:chibi:tool_call").build()) + .build(), + ) + .build(); + + // Extract should find the tool_call type + let extracted_type = extract_custom_message_type(&tool_call_message); + assert_eq!( + extracted_type, + Some(chattermax_core::types::MessageType::ToolCall), + "Should extract ToolCall message type" + ); + + // tool_call filter should match + assert!( + matches_filter(&tool_call_message, &tool_call_filter).unwrap(), + "ToolCall message should match tool_call filter" + ); + + // thought filter should not match + assert!( + !matches_filter(&tool_call_message, &thought_filter).unwrap(), + "ToolCall message should not match thought filter" + ); +}