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
4 changes: 2 additions & 2 deletions .claude/ralph-loop.local.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
active: true
iteration: 1
iteration: 2
max_iterations: 0
completion_promise: "THUFIR_APPROVED"
started_at: "2026-02-02T06:56:04Z"
started_at: "2026-02-02T07:52:34Z"
---

Repeat this loop: First use the ask-thufir skill to request the next implementation phase for Chattermax making sure to follow the agent identity protocol. Second implement all tasks for that phase. Always ask Thufir for detailed implementation plans and ask questions for anything unclear. Treat Thufirs implementation plan as the truth, even if it conflicts what you know. If you try and fail to implement something three 3 times, DO NOT CONTINUE and instead use the ask-thufir skill to explain the issue and get clarification. Third commit your changes with a descriptive message. Fourth create a pull request using gh pr create. Fifth wait for CI to pass by watching PR checks with gh. Sixth merge the PR to main using gh pr merge. Seventh use the update-thufir skill to update Thufir on your progress REMIND THUFIR UPDATE MAHDI, making sure to follow the agent identity protocol while giving a detailed report of changes made and any issues encountered. Then repeat from the first step. Continue looping until Mahdi confirms all phases are complete and the project is done. When that happens output THUFIR_APPROVED as the completion promise.
55 changes: 55 additions & 0 deletions chattermax-client/src/builder.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//! Client builder for ergonomic configuration

use crate::client::Client;
use crate::error::Result;

/// Builder for constructing a configured XMPP client
#[derive(Debug, Clone)]
pub struct ClientBuilder {
server: String,
port: u16,
username: String,
password: String,
use_tls: bool,
}

impl ClientBuilder {
/// Create a new client builder with required parameters
pub fn new(
server: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
server: server.into(),
port: 5222, // Default XMPP port
username: username.into(),
password: password.into(),
use_tls: false, // Default to plain connection (dev environments)
}
}

/// Set the server port (default: 5222)
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}

/// Enable TLS for connection (default: false)
pub fn use_tls(mut self, use_tls: bool) -> Self {
self.use_tls = use_tls;
self
}

/// Build and connect the client
pub async fn connect(self) -> Result<Client> {
Client::connect(
&self.server,
self.port,
&self.username,
&self.password,
self.use_tls,
)
.await
}
}
140 changes: 140 additions & 0 deletions chattermax-client/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! High-level XMPP client API

use crate::connection::Connection;
use crate::error::{ClientError, Result};
use chattermax_core::stream::ns;
use chattermax_core::types::Message;
use minidom::Element;

/// High-level XMPP client for sending and receiving messages
pub struct Client {
connection: Connection,
}

impl Client {
/// Connect to an XMPP server and authenticate
///
/// For most use cases, prefer using `ClientBuilder` instead:
/// ```rust,no_run
/// # use chattermax_client::ClientBuilder;
/// # async fn example() -> chattermax_client::error::Result<()> {
/// let client = ClientBuilder::new("localhost", "user", "pass")
/// .port(5222)
/// .connect()
/// .await?;
/// # Ok(())
/// # }
/// ```
pub(crate) async fn connect(
server: &str,
port: u16,
username: &str,
password: &str,
use_tls: bool,
) -> Result<Self> {
let connection = Connection::connect(server, port, username, password, use_tls).await?;
Ok(Self { connection })
}

/// Send a plain text message to a JID
pub async fn send_message(&mut self, to: &str, body: &str) -> Result<()> {
self.connection.send_message(to, body).await
}

/// Send a custom XEP message (one of the 12 structured types)
///
/// # Example
/// ```rust,no_run
/// # use chattermax_client::{ClientBuilder, Message};
/// # use chattermax_core::types::{ToolCall, Metadata};
/// # async fn example() -> chattermax_client::error::Result<()> {
/// let mut client = ClientBuilder::new("localhost", "user", "pass").connect().await?;
///
/// let tool_call = ToolCall {
/// tool_name: "run_tests".to_string(),
/// arguments: vec![("commit".to_string(), "abc123".to_string())],
/// metadata: Metadata::default(),
/// };
///
/// client.send_custom_message("room@conference.localhost", &Message::ToolCall(tool_call)).await?;
/// # Ok(())
/// # }
/// ```
pub async fn send_custom_message(&mut self, to: &str, message: &Message) -> Result<()> {
// Serialize message to XML element
let custom_elem = chattermax_core::types::to_xml(message)?;

// Create XMPP message stanza with custom type embedded
let msg_id = uuid::Uuid::new_v4().to_string();
let msg_elem = Element::builder("message", ns::CLIENT)
.attr("to", to)
.attr("type", "groupchat")
.attr("id", &msg_id)
.append(
Element::builder("body", ns::CLIENT)
.append(format!(
"[{}] {}",
message.message_type().as_str(),
self.message_summary(message)
))
.build(),
)
.append(custom_elem)
.build();

// Send the stanza
let mut buf = Vec::new();
msg_elem.write_to(&mut buf)?;
let msg_xml = String::from_utf8(buf)
.map_err(|e| ClientError::ProtocolError(format!("Invalid UTF-8 in message: {}", e)))?;

self.connection.send_raw(&msg_xml).await?;

Ok(())
}

/// Helper to create human-readable summary for message body (backward compatibility)
fn message_summary(&self, message: &Message) -> String {
match message {
Message::Thought(t) => t.content.chars().take(60).collect(),
Message::ToolCall(t) => format!("Tool call: {}", t.tool_name),
Message::ToolResult(t) => format!(
"Tool result: {} ({})",
t.tool_name,
if t.success { "success" } else { "failed" }
),
Message::Todo(t) => format!("Todo: {}", t.title),
Message::CodeChange(c) => format!("Code change: {}", c.file_path),
Message::Integration(i) => {
format!("Integration: {} - {}", i.integration_name, i.event_type)
}
Message::ReviewComment(r) => format!(
"Review: {} - {}",
r.file_path,
r.comment.chars().take(40).collect::<String>()
),
Message::WorkAvailable(w) => format!("Work available: {}", w.work_type),
Message::Question(q) => q.question.chars().take(60).collect(),
Message::Answer(a) => a.answer.chars().take(60).collect(),
Message::StatusUpdate(s) => s.status.clone(),
Message::FeatureComplete(f) => format!("Feature complete: {}", f.feature_name),
}
}

/// Join a multi-user chat room
pub async fn join_room(&mut self, room_jid: &str, nickname: &str) -> Result<()> {
self.connection.send_join_presence(room_jid, nickname).await
}

/// Leave a multi-user chat room
pub async fn leave_room(&mut self, room_jid: &str, nickname: &str) -> Result<()> {
self.connection
.send_leave_presence(room_jid, nickname)
.await
}

/// Close the connection gracefully
pub async fn close(mut self) -> Result<()> {
self.connection.close().await
}
}
27 changes: 20 additions & 7 deletions chattermax-client/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
//! Handles establishing and maintaining connections to XMPP servers,
//! including stream negotiation, SASL authentication, and TLS support.

use anyhow::{Result, anyhow};
use crate::error::{ClientError, Result};
use base64::{Engine, engine::general_purpose};
use chattermax_core::stream::ns;
use minidom::Element;
Expand All @@ -29,16 +29,16 @@ pub enum ConnectionState {
}

/// XMPP client connection
pub struct Connection {
socket: TcpStream,
pub(crate) struct Connection {
pub(crate) socket: TcpStream,
state: ConnectionState,
jid: Option<String>,
xml_buffer: String,
}

impl Connection {
/// Create a new connection to an XMPP server
pub async fn connect(
pub(crate) async fn connect(
server: &str,
port: u16,
username: &str,
Expand Down Expand Up @@ -70,7 +70,10 @@ impl Connection {
// Wait for success
let response = conn.read_element().await?;
if response.name() != "success" {
return Err(anyhow!("Authentication failed: {:?}", response));
return Err(ClientError::AuthenticationFailed(format!(
"SASL authentication rejected: {:?}",
response
)));
}
info!("Authentication successful");
conn.state = ConnectionState::Authenticated;
Expand Down Expand Up @@ -156,7 +159,9 @@ impl Connection {
// Read bind response
let response = self.read_element().await?;
if response.attr("type") != Some("result") {
return Err(anyhow!("Resource binding failed"));
return Err(ClientError::ProtocolError(
"Resource binding failed".to_string(),
));
}

// Extract and store JID
Expand All @@ -181,7 +186,7 @@ impl Connection {
// Need more data
let n = self.socket.read(&mut buf).await?;
if n == 0 {
return Err(anyhow!("Connection closed by server"));
return Err(ClientError::ConnectionClosed);
}

self.xml_buffer
Expand Down Expand Up @@ -320,6 +325,14 @@ impl Connection {
Ok(())
}

/// Send raw XML data to the server
#[allow(dead_code)]
pub(crate) async fn send_raw(&mut self, data: &str) -> Result<()> {
self.socket.write_all(data.as_bytes()).await?;
self.socket.flush().await?;
Ok(())
}

/// Close the connection gracefully
pub async fn close(&mut self) -> Result<()> {
let close_stream = "</stream:stream>";
Expand Down
48 changes: 31 additions & 17 deletions chattermax-client/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,31 +1,45 @@
//! Error types for Chattermax CLI
//! Client library error types

use std::io;
use std::string::FromUtf8Error;
use thiserror::Error;

#[allow(dead_code)]
#[derive(Error, Debug)]
/// Errors that can occur when using the chattermax client library
#[derive(Debug, Error)]
pub enum ClientError {
#[error("connection failed: {0}")]
ConnectionFailed(String),
/// Connection to XMPP server failed
#[error("Connection failed: {0}")]
ConnectionFailed(#[from] io::Error),

#[error("authentication failed: {0}")]
/// Authentication with XMPP server failed
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),

#[error("server error: {0}")]
ServerError(String),
/// XML parsing error
#[error("XML parsing error: {0}")]
XmlError(#[from] minidom::Error),

#[error("invalid JID: {0}")]
InvalidJid(String),
/// XMPP protocol error
#[error("XMPP protocol error: {0}")]
ProtocolError(String),

#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
/// Message serialization/deserialization error
#[error("Message serialization error: {0}")]
SerializationError(#[from] chattermax_core::error::Error),

#[error("protocol error: {0}")]
ProtocolError(String),
/// Invalid configuration provided
#[error("Invalid configuration: {0}")]
#[allow(dead_code)]
InvalidConfig(String),

/// Connection already closed
#[error("Connection is closed")]
ConnectionClosed,

#[error("unexpected response: {0}")]
UnexpectedResponse(String),
/// UTF-8 encoding error
#[error("UTF-8 encoding error: {0}")]
Utf8Error(#[from] FromUtf8Error),
}

#[allow(dead_code)]
/// Result type alias for client operations
pub type Result<T> = std::result::Result<T, ClientError>;
Loading
Loading