From b208cc940e324c8b439c2728f75bd75e8bd969bb Mon Sep 17 00:00:00 2001 From: Argenis Date: Sun, 15 Feb 2026 10:00:15 -0500 Subject: [PATCH] feat: add IRC channel support Add comprehensive IRC over TLS channel implementation with: - TLS support with optional certificate verification - SASL PLAIN authentication (IRCv3) - NickServ IDENTIFY authentication - Server password support (for bouncers like ZNC) - Channel and private message (DM) support - Message splitting for IRC 512-byte line limit - UTF-8 safe splitting at character boundaries - Case-insensitive nickname allowlist - IRC style prefix for LLM responses (plain text only) - Configurable via TOML or onboard wizard All 959 tests passing. Co-Authored-By: Claude Opus 4.6 --- src/channels/irc.rs | 1002 +++++++++++++++++++++++++++++++++++++++++ src/channels/mod.rs | 36 ++ src/config/schema.rs | 36 ++ src/onboard/wizard.rs | 154 ++++++- 4 files changed, 1226 insertions(+), 2 deletions(-) create mode 100644 src/channels/irc.rs diff --git a/src/channels/irc.rs b/src/channels/irc.rs new file mode 100644 index 0000000..d53ca25 --- /dev/null +++ b/src/channels/irc.rs @@ -0,0 +1,1002 @@ +use crate::channels::traits::{Channel, ChannelMessage}; +use async_trait::async_trait; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, Mutex}; + +// Use tokio_rustls's re-export of rustls types +use tokio_rustls::rustls; + +/// Read timeout for IRC — if no data arrives within this duration, the +/// connection is considered dead. IRC servers typically PING every 60-120s. +const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +/// Monotonic counter to ensure unique message IDs under burst traffic. +static MSG_SEQ: AtomicU64 = AtomicU64::new(0); + +/// IRC over TLS channel. +/// +/// Connects to an IRC server using TLS, joins configured channels, +/// and forwards PRIVMSG messages to the `ZeroClaw` message bus. +/// Supports both channel messages and private messages (DMs). +pub struct IrcChannel { + server: String, + port: u16, + nickname: String, + username: String, + channels: Vec, + allowed_users: Vec, + server_password: Option, + nickserv_password: Option, + sasl_password: Option, + verify_tls: bool, + /// Shared write half of the TLS stream for sending messages. + writer: Arc>>, +} + +type WriteHalf = tokio::io::WriteHalf>; + +/// Style instruction prepended to every IRC message before it reaches the LLM. +/// IRC clients render plain text only — no markdown, no HTML, no XML. +const IRC_STYLE_PREFIX: &str = "\ +[context: you are responding over IRC. \ +Plain text only. No markdown, no tables, no XML/HTML tags. \ +Never use triple backtick code fences. Use a single blank line to separate blocks instead. \ +Be terse and concise. \ +Use short lines. Avoid walls of text.]\n"; + +/// Reserved bytes for the server-prepended sender prefix (`:nick!user@host `). +const SENDER_PREFIX_RESERVE: usize = 64; + +/// A parsed IRC message. +#[derive(Debug, Clone, PartialEq, Eq)] +struct IrcMessage { + prefix: Option, + command: String, + params: Vec, +} + +impl IrcMessage { + /// Parse a raw IRC line into an `IrcMessage`. + /// + /// IRC format: `[:] [] [:]` + fn parse(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return None; + } + + let (prefix, rest) = if let Some(stripped) = line.strip_prefix(':') { + let space = stripped.find(' ')?; + (Some(stripped[..space].to_string()), &stripped[space + 1..]) + } else { + (None, line) + }; + + // Split at trailing (first `:` after command/params) + let (params_part, trailing) = if let Some(colon_pos) = rest.find(" :") { + (&rest[..colon_pos], Some(&rest[colon_pos + 2..])) + } else { + (rest, None) + }; + + let mut parts: Vec<&str> = params_part.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let command = parts.remove(0).to_uppercase(); + let mut params: Vec = parts.iter().map(std::string::ToString::to_string).collect(); + if let Some(t) = trailing { + params.push(t.to_string()); + } + + Some(IrcMessage { + prefix, + command, + params, + }) + } + + /// Extract the nickname from the prefix (nick!user@host → nick). + fn nick(&self) -> Option<&str> { + self.prefix.as_ref().and_then(|p| { + let end = p.find('!').unwrap_or(p.len()); + let nick = &p[..end]; + if nick.is_empty() { + None + } else { + Some(nick) + } + }) + } +} + +/// Encode SASL PLAIN credentials: base64(\0nick\0password). +fn encode_sasl_plain(nick: &str, password: &str) -> String { + // Simple base64 encoder — avoids adding a base64 crate dependency. + // The project's Discord channel uses a similar inline approach. + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let input = format!("\0{nick}\0{password}"); + let bytes = input.as_bytes(); + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + + for chunk in bytes.chunks(3) { + let b0 = u32::from(chunk[0]); + let b1 = u32::from(chunk.get(1).copied().unwrap_or(0)); + let b2 = u32::from(chunk.get(2).copied().unwrap_or(0)); + let triple = (b0 << 16) | (b1 << 8) | b2; + + out.push(CHARS[(triple >> 18 & 0x3F) as usize] as char); + out.push(CHARS[(triple >> 12 & 0x3F) as usize] as char); + + if chunk.len() > 1 { + out.push(CHARS[(triple >> 6 & 0x3F) as usize] as char); + } else { + out.push('='); + } + + if chunk.len() > 2 { + out.push(CHARS[(triple & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + + out +} + +/// Split a message into lines safe for IRC transmission. +/// +/// IRC is a line-based protocol — `\r\n` terminates each command, so any +/// newline inside a PRIVMSG payload would truncate the message and turn the +/// remainder into garbled/invalid IRC commands. +/// +/// This function: +/// 1. Splits on `\n` (and strips `\r`) so each logical line becomes its own PRIVMSG. +/// 2. Splits any line that exceeds `max_bytes` at a safe UTF-8 boundary. +/// 3. Skips empty lines to avoid sending blank PRIVMSGs. +fn split_message(message: &str, max_bytes: usize) -> Vec { + let mut chunks = Vec::new(); + + // Guard against max_bytes == 0 to prevent infinite loop + if max_bytes == 0 { + let full: String = message + .lines() + .map(|l| l.trim_end_matches('\r')) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" "); + if full.is_empty() { + chunks.push(String::new()); + } else { + chunks.push(full); + } + return chunks; + } + + for line in message.split('\n') { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + + if line.len() <= max_bytes { + chunks.push(line.to_string()); + continue; + } + + // Line exceeds max_bytes — split at safe UTF-8 boundaries + let mut remaining = line; + while !remaining.is_empty() { + if remaining.len() <= max_bytes { + chunks.push(remaining.to_string()); + break; + } + + let mut split_at = max_bytes; + while split_at > 0 && !remaining.is_char_boundary(split_at) { + split_at -= 1; + } + if split_at == 0 { + // No valid boundary found going backward — advance forward instead + split_at = max_bytes; + while split_at < remaining.len() && !remaining.is_char_boundary(split_at) { + split_at += 1; + } + } + + chunks.push(remaining[..split_at].to_string()); + remaining = &remaining[split_at..]; + } + } + + if chunks.is_empty() { + chunks.push(String::new()); + } + + chunks +} + +impl IrcChannel { + #[allow(clippy::too_many_arguments)] + pub fn new( + server: String, + port: u16, + nickname: String, + username: Option, + channels: Vec, + allowed_users: Vec, + server_password: Option, + nickserv_password: Option, + sasl_password: Option, + verify_tls: bool, + ) -> Self { + let username = username.unwrap_or_else(|| nickname.clone()); + Self { + server, + port, + nickname, + username, + channels, + allowed_users, + server_password, + nickserv_password, + sasl_password, + verify_tls, + writer: Arc::new(Mutex::new(None)), + } + } + + fn is_user_allowed(&self, nick: &str) -> bool { + if self.allowed_users.iter().any(|u| u == "*") { + return true; + } + self.allowed_users + .iter() + .any(|u| u.eq_ignore_ascii_case(nick)) + } + + /// Create a TLS connection to the IRC server. + async fn connect( + &self, + ) -> anyhow::Result> { + let addr = format!("{}:{}", self.server, self.port); + let tcp = tokio::net::TcpStream::connect(&addr).await?; + + let tls_config = if self.verify_tls { + let root_store: rustls::RootCertStore = + webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + } else { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth() + }; + + let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); + let domain = rustls::pki_types::ServerName::try_from(self.server.clone())?; + let tls = connector.connect(domain, tcp).await?; + + Ok(tls) + } + + /// Send a raw IRC line (appends \r\n). + async fn send_raw(writer: &mut WriteHalf, line: &str) -> anyhow::Result<()> { + let data = format!("{line}\r\n"); + writer.write_all(data.as_bytes()).await?; + writer.flush().await?; + Ok(()) + } +} + +/// Certificate verifier that accepts any certificate (for `verify_tls=false`). +#[derive(Debug)] +struct NoVerify; + +impl rustls::client::danger::ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[async_trait] +#[allow(clippy::too_many_lines)] +impl Channel for IrcChannel { + fn name(&self) -> &str { + "irc" + } + + async fn send(&self, message: &str, recipient: &str) -> anyhow::Result<()> { + let mut guard = self.writer.lock().await; + let writer = guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("IRC not connected"))?; + + // Calculate safe payload size: + // 512 - sender prefix (~64 bytes for :nick!user@host) - "PRIVMSG " - target - " :" - "\r\n" + let overhead = SENDER_PREFIX_RESERVE + 10 + recipient.len() + 2; + let max_payload = 512_usize.saturating_sub(overhead); + let chunks = split_message(message, max_payload); + + for chunk in chunks { + Self::send_raw(writer, &format!("PRIVMSG {recipient} :{chunk}")).await?; + } + + Ok(()) + } + + async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { + let mut current_nick = self.nickname.clone(); + tracing::info!( + "IRC channel connecting to {}:{} as {}...", + self.server, + self.port, + current_nick + ); + + let tls = self.connect().await?; + let (reader, mut writer) = tokio::io::split(tls); + + // --- SASL negotiation --- + if self.sasl_password.is_some() { + Self::send_raw(&mut writer, "CAP REQ :sasl").await?; + } + + // --- Server password --- + if let Some(ref pass) = self.server_password { + Self::send_raw(&mut writer, &format!("PASS {pass}")).await?; + } + + // --- Nick/User registration --- + Self::send_raw(&mut writer, &format!("NICK {current_nick}")).await?; + Self::send_raw( + &mut writer, + &format!("USER {} 0 * :ZeroClaw", self.username), + ) + .await?; + + // Store writer for send() + { + let mut guard = self.writer.lock().await; + *guard = Some(writer); + } + + let mut buf_reader = BufReader::new(reader); + let mut line = String::new(); + let mut registered = false; + let mut sasl_pending = self.sasl_password.is_some(); + + loop { + line.clear(); + let n = tokio::time::timeout(READ_TIMEOUT, buf_reader.read_line(&mut line)) + .await + .map_err(|_| { + anyhow::anyhow!("IRC read timed out (no data for {READ_TIMEOUT:?})") + })??; + if n == 0 { + anyhow::bail!("IRC connection closed by server"); + } + + let Some(msg) = IrcMessage::parse(&line) else { + continue; + }; + + match msg.command.as_str() { + "PING" => { + let token = msg.params.first().map_or("", String::as_str); + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, &format!("PONG :{token}")).await?; + } + } + + // CAP responses for SASL + "CAP" => { + if sasl_pending && msg.params.iter().any(|p| p.contains("sasl")) { + if msg.params.iter().any(|p| p.contains("ACK")) { + // CAP * ACK :sasl — server accepted, start SASL auth + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, "AUTHENTICATE PLAIN").await?; + } + } else if msg.params.iter().any(|p| p.contains("NAK")) { + // CAP * NAK :sasl — server rejected SASL, proceed without it + tracing::warn!( + "IRC server does not support SASL, continuing without it" + ); + sasl_pending = false; + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, "CAP END").await?; + } + } + } + } + + "AUTHENTICATE" => { + // Server sends "AUTHENTICATE +" to request credentials + if sasl_pending && msg.params.first().is_some_and(|p| p == "+") { + let encoded = encode_sasl_plain( + ¤t_nick, + self.sasl_password.as_deref().unwrap_or(""), + ); + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, &format!("AUTHENTICATE {encoded}")).await?; + } + } + } + + // RPL_SASLSUCCESS (903) — SASL done, end CAP + "903" => { + sasl_pending = false; + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, "CAP END").await?; + } + } + + // SASL failure (904, 905, 906, 907) + "904" | "905" | "906" | "907" => { + tracing::warn!("IRC SASL authentication failed ({})", msg.command); + sasl_pending = false; + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, "CAP END").await?; + } + } + + // RPL_WELCOME — registration complete + "001" => { + registered = true; + tracing::info!("IRC registered as {}", current_nick); + + // NickServ authentication + if let Some(ref pass) = self.nickserv_password { + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, &format!("PRIVMSG NickServ :IDENTIFY {pass}")) + .await?; + } + } + + // Join channels + for chan in &self.channels { + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, &format!("JOIN {chan}")).await?; + } + } + } + + // ERR_NICKNAMEINUSE (433) + "433" => { + let alt = format!("{current_nick}_"); + tracing::warn!("IRC nickname {current_nick} is in use, trying {alt}"); + let mut guard = self.writer.lock().await; + if let Some(ref mut w) = *guard { + Self::send_raw(w, &format!("NICK {alt}")).await?; + } + current_nick = alt; + } + + "PRIVMSG" => { + if !registered { + continue; + } + + let target = msg.params.first().map_or("", String::as_str); + let text = msg.params.get(1).map_or("", String::as_str); + let sender_nick = msg.nick().unwrap_or("unknown"); + + // Skip messages from NickServ/ChanServ + if sender_nick.eq_ignore_ascii_case("NickServ") + || sender_nick.eq_ignore_ascii_case("ChanServ") + { + continue; + } + + if !self.is_user_allowed(sender_nick) { + continue; + } + + // Determine reply target: if sent to a channel, reply to channel; + // if DM (target == our nick), reply to sender + let is_channel = target.starts_with('#') || target.starts_with('&'); + let reply_to = if is_channel { + target.to_string() + } else { + sender_nick.to_string() + }; + let content = if is_channel { + format!("{IRC_STYLE_PREFIX}<{sender_nick}> {text}") + } else { + format!("{IRC_STYLE_PREFIX}{text}") + }; + + let seq = MSG_SEQ.fetch_add(1, Ordering::Relaxed); + let channel_msg = ChannelMessage { + id: format!("irc_{}_{seq}", chrono::Utc::now().timestamp_millis()), + sender: reply_to, + content, + channel: "irc".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(channel_msg).await.is_err() { + return Ok(()); + } + } + + // ERR_PASSWDMISMATCH (464) or other fatal errors + "464" => { + anyhow::bail!("IRC password mismatch"); + } + + _ => {} + } + } + } + + async fn health_check(&self) -> bool { + // Lightweight connectivity check: TLS connect + QUIT + match self.connect().await { + Ok(tls) => { + let (_, mut writer) = tokio::io::split(tls); + let _ = Self::send_raw(&mut writer, "QUIT :health check").await; + true + } + Err(_) => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── IRC message parsing ────────────────────────────────── + + #[test] + fn parse_privmsg_with_prefix() { + let msg = IrcMessage::parse(":nick!user@host PRIVMSG #channel :Hello world").unwrap(); + assert_eq!(msg.prefix.as_deref(), Some("nick!user@host")); + assert_eq!(msg.command, "PRIVMSG"); + assert_eq!(msg.params, vec!["#channel", "Hello world"]); + } + + #[test] + fn parse_privmsg_dm() { + let msg = IrcMessage::parse(":alice!a@host PRIVMSG botname :hi there").unwrap(); + assert_eq!(msg.command, "PRIVMSG"); + assert_eq!(msg.params, vec!["botname", "hi there"]); + assert_eq!(msg.nick(), Some("alice")); + } + + #[test] + fn parse_ping() { + let msg = IrcMessage::parse("PING :server.example.com").unwrap(); + assert!(msg.prefix.is_none()); + assert_eq!(msg.command, "PING"); + assert_eq!(msg.params, vec!["server.example.com"]); + } + + #[test] + fn parse_numeric_reply() { + let msg = IrcMessage::parse(":server 001 botname :Welcome to the IRC network").unwrap(); + assert_eq!(msg.prefix.as_deref(), Some("server")); + assert_eq!(msg.command, "001"); + assert_eq!(msg.params, vec!["botname", "Welcome to the IRC network"]); + } + + #[test] + fn parse_no_trailing() { + let msg = IrcMessage::parse(":server 433 * botname").unwrap(); + assert_eq!(msg.command, "433"); + assert_eq!(msg.params, vec!["*", "botname"]); + } + + #[test] + fn parse_cap_ack() { + let msg = IrcMessage::parse(":server CAP * ACK :sasl").unwrap(); + assert_eq!(msg.command, "CAP"); + assert_eq!(msg.params, vec!["*", "ACK", "sasl"]); + } + + #[test] + fn parse_empty_line_returns_none() { + assert!(IrcMessage::parse("").is_none()); + assert!(IrcMessage::parse("\r\n").is_none()); + } + + #[test] + fn parse_strips_crlf() { + let msg = IrcMessage::parse("PING :test\r\n").unwrap(); + assert_eq!(msg.params, vec!["test"]); + } + + #[test] + fn parse_command_uppercase() { + let msg = IrcMessage::parse("ping :test").unwrap(); + assert_eq!(msg.command, "PING"); + } + + #[test] + fn nick_extraction_full_prefix() { + let msg = IrcMessage::parse(":nick!user@host PRIVMSG #ch :msg").unwrap(); + assert_eq!(msg.nick(), Some("nick")); + } + + #[test] + fn nick_extraction_nick_only() { + let msg = IrcMessage::parse(":server 001 bot :Welcome").unwrap(); + assert_eq!(msg.nick(), Some("server")); + } + + #[test] + fn nick_extraction_no_prefix() { + let msg = IrcMessage::parse("PING :token").unwrap(); + assert_eq!(msg.nick(), None); + } + + #[test] + fn parse_authenticate_plus() { + let msg = IrcMessage::parse("AUTHENTICATE +").unwrap(); + assert_eq!(msg.command, "AUTHENTICATE"); + assert_eq!(msg.params, vec!["+"]); + } + + // ── SASL PLAIN encoding ───────────────────────────────── + + #[test] + fn sasl_plain_encode() { + let encoded = encode_sasl_plain("jilles", "sesame"); + // \0jilles\0sesame → base64 + assert_eq!(encoded, "AGppbGxlcwBzZXNhbWU="); + } + + #[test] + fn sasl_plain_empty_password() { + let encoded = encode_sasl_plain("nick", ""); + // \0nick\0 → base64 + assert_eq!(encoded, "AG5pY2sA"); + } + + // ── Message splitting ─────────────────────────────────── + + #[test] + fn split_short_message() { + let chunks = split_message("hello", 400); + assert_eq!(chunks, vec!["hello"]); + } + + #[test] + fn split_long_message() { + let msg = "a".repeat(800); + let chunks = split_message(&msg, 400); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), 400); + assert_eq!(chunks[1].len(), 400); + } + + #[test] + fn split_exact_boundary() { + let msg = "a".repeat(400); + let chunks = split_message(&msg, 400); + assert_eq!(chunks.len(), 1); + } + + #[test] + fn split_unicode_safe() { + // 'é' is 2 bytes in UTF-8; splitting at byte 3 would split mid-char + let msg = "ééé"; // 6 bytes + let chunks = split_message(msg, 3); + // Should split at char boundary (2 bytes), not mid-char + assert_eq!(chunks.len(), 3); + assert_eq!(chunks[0], "é"); + assert_eq!(chunks[1], "é"); + assert_eq!(chunks[2], "é"); + } + + #[test] + fn split_empty_message() { + let chunks = split_message("", 400); + assert_eq!(chunks, vec![""]); + } + + #[test] + fn split_newlines_into_separate_lines() { + let chunks = split_message("line one\nline two\nline three", 400); + assert_eq!(chunks, vec!["line one", "line two", "line three"]); + } + + #[test] + fn split_crlf_newlines() { + let chunks = split_message("hello\r\nworld", 400); + assert_eq!(chunks, vec!["hello", "world"]); + } + + #[test] + fn split_skips_empty_lines() { + let chunks = split_message("hello\n\n\nworld", 400); + assert_eq!(chunks, vec!["hello", "world"]); + } + + #[test] + fn split_trailing_newline() { + let chunks = split_message("hello\n", 400); + assert_eq!(chunks, vec!["hello"]); + } + + #[test] + fn split_multiline_with_long_line() { + let long = "a".repeat(800); + let msg = format!("short\n{long}\nend"); + let chunks = split_message(&msg, 400); + assert_eq!(chunks.len(), 4); + assert_eq!(chunks[0], "short"); + assert_eq!(chunks[1].len(), 400); + assert_eq!(chunks[2].len(), 400); + assert_eq!(chunks[3], "end"); + } + + #[test] + fn split_only_newlines() { + let chunks = split_message("\n\n\n", 400); + assert_eq!(chunks, vec![""]); + } + + // ── Allowlist ─────────────────────────────────────────── + + #[test] + fn wildcard_allows_anyone() { + let ch = make_channel(); + // Default make_channel has wildcard + assert!(ch.is_user_allowed("anyone")); + assert!(ch.is_user_allowed("stranger")); + } + + #[test] + fn specific_user_allowed() { + let ch = IrcChannel::new( + "irc.test".into(), + 6697, + "bot".into(), + None, + vec![], + vec!["alice".into(), "bob".into()], + None, + None, + None, + true, + ); + assert!(ch.is_user_allowed("alice")); + assert!(ch.is_user_allowed("bob")); + assert!(!ch.is_user_allowed("eve")); + } + + #[test] + fn allowlist_case_insensitive() { + let ch = IrcChannel::new( + "irc.test".into(), + 6697, + "bot".into(), + None, + vec![], + vec!["Alice".into()], + None, + None, + None, + true, + ); + assert!(ch.is_user_allowed("alice")); + assert!(ch.is_user_allowed("ALICE")); + assert!(ch.is_user_allowed("Alice")); + } + + #[test] + fn empty_allowlist_denies_all() { + let ch = IrcChannel::new( + "irc.test".into(), + 6697, + "bot".into(), + None, + vec![], + vec![], + None, + None, + None, + true, + ); + assert!(!ch.is_user_allowed("anyone")); + } + + // ── Constructor ───────────────────────────────────────── + + #[test] + fn new_defaults_username_to_nickname() { + let ch = IrcChannel::new( + "irc.test".into(), + 6697, + "mybot".into(), + None, + vec![], + vec![], + None, + None, + None, + true, + ); + assert_eq!(ch.username, "mybot"); + } + + #[test] + fn new_uses_explicit_username() { + let ch = IrcChannel::new( + "irc.test".into(), + 6697, + "mybot".into(), + Some("customuser".into()), + vec![], + vec![], + None, + None, + None, + true, + ); + assert_eq!(ch.username, "customuser"); + assert_eq!(ch.nickname, "mybot"); + } + + #[test] + fn name_returns_irc() { + let ch = make_channel(); + assert_eq!(ch.name(), "irc"); + } + + #[test] + fn new_stores_all_fields() { + let ch = IrcChannel::new( + "irc.example.com".into(), + 6697, + "zcbot".into(), + Some("zeroclaw".into()), + vec!["#test".into()], + vec!["alice".into()], + Some("serverpass".into()), + Some("nspass".into()), + Some("saslpass".into()), + false, + ); + assert_eq!(ch.server, "irc.example.com"); + assert_eq!(ch.port, 6697); + assert_eq!(ch.nickname, "zcbot"); + assert_eq!(ch.username, "zeroclaw"); + assert_eq!(ch.channels, vec!["#test"]); + assert_eq!(ch.allowed_users, vec!["alice"]); + assert_eq!(ch.server_password.as_deref(), Some("serverpass")); + assert_eq!(ch.nickserv_password.as_deref(), Some("nspass")); + assert_eq!(ch.sasl_password.as_deref(), Some("saslpass")); + assert!(!ch.verify_tls); + } + + // ── Config serde ──────────────────────────────────────── + + #[test] + fn irc_config_serde_roundtrip() { + use crate::config::schema::IrcConfig; + + let config = IrcConfig { + server: "irc.example.com".into(), + port: 6697, + nickname: "zcbot".into(), + username: Some("zeroclaw".into()), + channels: vec!["#test".into(), "#dev".into()], + allowed_users: vec!["alice".into()], + server_password: None, + nickserv_password: Some("secret".into()), + sasl_password: None, + verify_tls: Some(true), + }; + + let toml_str = toml::to_string(&config).unwrap(); + let parsed: IrcConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!(parsed.server, "irc.example.com"); + assert_eq!(parsed.port, 6697); + assert_eq!(parsed.nickname, "zcbot"); + assert_eq!(parsed.username.as_deref(), Some("zeroclaw")); + assert_eq!(parsed.channels, vec!["#test", "#dev"]); + assert_eq!(parsed.allowed_users, vec!["alice"]); + assert!(parsed.server_password.is_none()); + assert_eq!(parsed.nickserv_password.as_deref(), Some("secret")); + assert!(parsed.sasl_password.is_none()); + assert_eq!(parsed.verify_tls, Some(true)); + } + + #[test] + fn irc_config_minimal_toml() { + use crate::config::schema::IrcConfig; + + let toml_str = r#" +server = "irc.example.com" +nickname = "bot" +"#; + let parsed: IrcConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(parsed.server, "irc.example.com"); + assert_eq!(parsed.port, 6697); // default + assert_eq!(parsed.nickname, "bot"); + assert!(parsed.username.is_none()); + assert!(parsed.channels.is_empty()); + assert!(parsed.allowed_users.is_empty()); + assert!(parsed.server_password.is_none()); + assert!(parsed.nickserv_password.is_none()); + assert!(parsed.sasl_password.is_none()); + assert!(parsed.verify_tls.is_none()); + } + + #[test] + fn irc_config_default_port() { + use crate::config::schema::IrcConfig; + + let json = r#"{"server":"irc.test","nickname":"bot"}"#; + let parsed: IrcConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.port, 6697); + } + + // ── Helpers ───────────────────────────────────────────── + + fn make_channel() -> IrcChannel { + IrcChannel::new( + "irc.example.com".into(), + 6697, + "zcbot".into(), + None, + vec!["#zeroclaw".into()], + vec!["*".into()], + None, + None, + None, + true, + ) + } +} diff --git a/src/channels/mod.rs b/src/channels/mod.rs index fa44411..8670116 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -2,6 +2,7 @@ pub mod cli; pub mod discord; pub mod email_channel; pub mod imessage; +pub mod irc; pub mod matrix; pub mod slack; pub mod telegram; @@ -11,6 +12,7 @@ pub mod whatsapp; pub use cli::CliChannel; pub use discord::DiscordChannel; pub use imessage::IMessageChannel; +pub use irc::IrcChannel; pub use matrix::MatrixChannel; pub use slack::SlackChannel; pub use telegram::TelegramChannel; @@ -241,6 +243,7 @@ pub fn handle_command(command: crate::ChannelCommands, config: &Config) -> Resul ("iMessage", config.channels_config.imessage.is_some()), ("Matrix", config.channels_config.matrix.is_some()), ("WhatsApp", config.channels_config.whatsapp.is_some()), + ("IRC", config.channels_config.irc.is_some()), ] { println!(" {} {name}", if configured { "✅" } else { "❌" }); } @@ -347,6 +350,24 @@ pub async fn doctor_channels(config: Config) -> Result<()> { )); } + if let Some(ref irc) = config.channels_config.irc { + channels.push(( + "IRC", + Arc::new(IrcChannel::new( + irc.server.clone(), + irc.port, + irc.nickname.clone(), + irc.username.clone(), + irc.channels.clone(), + irc.allowed_users.clone(), + irc.server_password.clone(), + irc.nickserv_password.clone(), + irc.sasl_password.clone(), + irc.verify_tls.unwrap_or(true), + )), + )); + } + if channels.is_empty() { println!("No real-time channels configured. Run `zeroclaw onboard` first."); return Ok(()); @@ -514,6 +535,21 @@ pub async fn start_channels(config: Config) -> Result<()> { ))); } + if let Some(ref irc) = config.channels_config.irc { + channels.push(Arc::new(IrcChannel::new( + irc.server.clone(), + irc.port, + irc.nickname.clone(), + irc.username.clone(), + irc.channels.clone(), + irc.allowed_users.clone(), + irc.server_password.clone(), + irc.nickserv_password.clone(), + irc.sasl_password.clone(), + irc.verify_tls.unwrap_or(true), + ))); + } + if channels.is_empty() { println!("No channels configured. Run `zeroclaw onboard` to set up channels."); return Ok(()); diff --git a/src/config/schema.rs b/src/config/schema.rs index 131be2e..ecc0b9b 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -537,6 +537,7 @@ pub struct ChannelsConfig { pub imessage: Option, pub matrix: Option, pub whatsapp: Option, + pub irc: Option, } impl Default for ChannelsConfig { @@ -550,6 +551,7 @@ impl Default for ChannelsConfig { imessage: None, matrix: None, whatsapp: None, + irc: None, } } } @@ -612,6 +614,37 @@ pub struct WhatsAppConfig { pub allowed_numbers: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IrcConfig { + /// IRC server hostname + pub server: String, + /// IRC server port (default: 6697 for TLS) + #[serde(default = "default_irc_port")] + pub port: u16, + /// Bot nickname + pub nickname: String, + /// Username (defaults to nickname if not set) + pub username: Option, + /// Channels to join on connect + #[serde(default)] + pub channels: Vec, + /// Allowed nicknames (case-insensitive) or "*" for all + #[serde(default)] + pub allowed_users: Vec, + /// Server password (for bouncers like ZNC) + pub server_password: Option, + /// NickServ IDENTIFY password + pub nickserv_password: Option, + /// SASL PLAIN password (IRCv3) + pub sasl_password: Option, + /// Verify TLS certificate (default: true) + pub verify_tls: Option, +} + +fn default_irc_port() -> u16 { + 6697 +} + // ── Config impl ────────────────────────────────────────────────── impl Default for Config { @@ -847,6 +880,7 @@ mod tests { imessage: None, matrix: None, whatsapp: None, + irc: None, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), @@ -1059,6 +1093,7 @@ default_temperature = 0.7 allowed_users: vec!["@u:m".into()], }), whatsapp: None, + irc: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); @@ -1215,6 +1250,7 @@ channel_id = "C123" app_secret: None, allowed_numbers: vec!["+1".into()], }), + irc: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 6e9a85c..d4e0b04 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,4 +1,4 @@ -use crate::config::schema::WhatsAppConfig; +use crate::config::schema::{IrcConfig, WhatsAppConfig}; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, @@ -1114,6 +1114,7 @@ fn setup_channels() -> Result { imessage: None, matrix: None, whatsapp: None, + irc: None, }; loop { @@ -1166,6 +1167,14 @@ fn setup_channels() -> Result { "— Business Cloud API" } ), + format!( + "IRC {}", + if config.irc.is_some() { + "✅ configured" + } else { + "— IRC over TLS" + } + ), format!( "Webhook {}", if config.webhook.is_some() { @@ -1180,7 +1189,7 @@ fn setup_channels() -> Result { let choice = Select::new() .with_prompt(" Connect a channel (or Done to continue)") .items(&options) - .default(7) + .default(8) .interact()?; match choice { @@ -1687,6 +1696,144 @@ fn setup_channels() -> Result { }); } 6 => { + // ── IRC ── + println!(); + println!( + " {} {}", + style("IRC Setup").white().bold(), + style("— IRC over TLS").dim() + ); + print_bullet("IRC connects over TLS to any IRC server"); + print_bullet("Supports SASL PLAIN and NickServ authentication"); + println!(); + + let server: String = Input::new() + .with_prompt(" IRC server (hostname)") + .interact_text()?; + + if server.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + let port_str: String = Input::new() + .with_prompt(" Port") + .default("6697".into()) + .interact_text()?; + + let port: u16 = match port_str.trim().parse() { + Ok(p) => p, + Err(_) => { + println!(" {} Invalid port, using 6697", style("→").dim()); + 6697 + } + }; + + let nickname: String = Input::new() + .with_prompt(" Bot nickname") + .interact_text()?; + + if nickname.trim().is_empty() { + println!(" {} Skipped — nickname required", style("→").dim()); + continue; + } + + let channels_str: String = Input::new() + .with_prompt(" Channels to join (comma-separated: #channel1,#channel2)") + .allow_empty(true) + .interact_text()?; + + let channels = if channels_str.trim().is_empty() { + vec![] + } else { + channels_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + + print_bullet( + "Allowlist nicknames that can interact with the bot (case-insensitive).", + ); + print_bullet("Use '*' to allow anyone (not recommended for production)."); + + let users_str: String = Input::new() + .with_prompt(" Allowed nicknames (comma-separated, or * for all)") + .allow_empty(true) + .interact_text()?; + + let allowed_users = if users_str.trim() == "*" { + vec!["*".into()] + } else { + users_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + + if allowed_users.is_empty() { + print_bullet("⚠️ Empty allowlist — only you can interact. Add nicknames above."); + } + + println!(); + print_bullet("Optional authentication (press Enter to skip each):"); + + let server_password: String = Input::new() + .with_prompt(" Server password (for bouncers like ZNC, leave empty if none)") + .allow_empty(true) + .interact_text()?; + + let nickserv_password: String = Input::new() + .with_prompt(" NickServ password (leave empty if none)") + .allow_empty(true) + .interact_text()?; + + let sasl_password: String = Input::new() + .with_prompt(" SASL PLAIN password (leave empty if none)") + .allow_empty(true) + .interact_text()?; + + let verify_tls: bool = Confirm::new() + .with_prompt(" Verify TLS certificate?") + .default(true) + .interact()?; + + println!( + " {} IRC configured as {}@{}:{}", + style("✅").green().bold(), + style(&nickname).cyan(), + style(&server).cyan(), + style(port).cyan() + ); + + config.irc = Some(IrcConfig { + server: server.trim().to_string(), + port, + nickname: nickname.trim().to_string(), + username: None, + channels, + allowed_users, + server_password: if server_password.trim().is_empty() { + None + } else { + Some(server_password.trim().to_string()) + }, + nickserv_password: if nickserv_password.trim().is_empty() { + None + } else { + Some(nickserv_password.trim().to_string()) + }, + sasl_password: if sasl_password.trim().is_empty() { + None + } else { + Some(sasl_password.trim().to_string()) + }, + verify_tls: Some(verify_tls), + }); + } + 7 => { // ── Webhook ── println!(); println!( @@ -1744,6 +1891,9 @@ fn setup_channels() -> Result { if config.whatsapp.is_some() { active.push("WhatsApp"); } + if config.irc.is_some() { + active.push("IRC"); + } if config.webhook.is_some() { active.push("Webhook"); }