Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chattermax-server/src/hooks/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ pub struct HookFilter {
/// Composite filter logic
#[serde(default)]
pub composite: Option<CompositeFilter>,

/// Match on custom XEP message type (e.g., "tool_call", "thought")
pub custom_message_type: Option<String>,
}

/// Composite filter with AND/OR/NOT logic
Expand Down
175 changes: 171 additions & 4 deletions chattermax-server/src/hooks/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageType> {
// 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<String, String> {
let mut vars = HashMap::new();
Expand Down Expand Up @@ -80,6 +106,39 @@ pub fn matches_filter(message: &Element, filter: &HookFilter) -> Result<bool> {
}
}

// 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)?
Expand Down Expand Up @@ -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());
}
Expand All @@ -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());
}
Expand Down Expand Up @@ -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());
}
}
93 changes: 93 additions & 0 deletions chattermax-server/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Loading