Refactor the Channel trait to accept a SendMessage struct instead of separate message and recipient string parameters. This enables passing additional metadata like email subjects. Changes: - Add SendMessage struct with content, recipient, and optional subject - Update Channel::send() signature to accept &SendMessage - Update all 12 channel implementations - Update call sites in channels/mod.rs and gateway/mod.rs Subject field usage: - Email: uses subject for email subject line - DingTalk: uses subject as markdown message title - All others: ignore subject (no native platform support)
1014 lines
35 KiB
Rust
1014 lines
35 KiB
Rust
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
|
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<String>,
|
|
allowed_users: Vec<String>,
|
|
server_password: Option<String>,
|
|
nickserv_password: Option<String>,
|
|
sasl_password: Option<String>,
|
|
verify_tls: bool,
|
|
/// Shared write half of the TLS stream for sending messages.
|
|
writer: Arc<Mutex<Option<WriteHalf>>>,
|
|
}
|
|
|
|
type WriteHalf = tokio::io::WriteHalf<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>;
|
|
|
|
/// 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<String>,
|
|
command: String,
|
|
params: Vec<String>,
|
|
}
|
|
|
|
impl IrcMessage {
|
|
/// Parse a raw IRC line into an `IrcMessage`.
|
|
///
|
|
/// IRC format: `[:<prefix>] <command> [<params>] [:<trailing>]`
|
|
fn parse(line: &str) -> Option<Self> {
|
|
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<String> = 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<String> {
|
|
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::<Vec<_>>()
|
|
.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
|
|
}
|
|
|
|
/// Configuration for constructing an `IrcChannel`.
|
|
pub struct IrcChannelConfig {
|
|
pub server: String,
|
|
pub port: u16,
|
|
pub nickname: String,
|
|
pub username: Option<String>,
|
|
pub channels: Vec<String>,
|
|
pub allowed_users: Vec<String>,
|
|
pub server_password: Option<String>,
|
|
pub nickserv_password: Option<String>,
|
|
pub sasl_password: Option<String>,
|
|
pub verify_tls: bool,
|
|
}
|
|
|
|
impl IrcChannel {
|
|
pub fn new(cfg: IrcChannelConfig) -> Self {
|
|
let username = cfg.username.unwrap_or_else(|| cfg.nickname.clone());
|
|
Self {
|
|
server: cfg.server,
|
|
port: cfg.port,
|
|
nickname: cfg.nickname,
|
|
username,
|
|
channels: cfg.channels,
|
|
allowed_users: cfg.allowed_users,
|
|
server_password: cfg.server_password,
|
|
nickserv_password: cfg.nickserv_password,
|
|
sasl_password: cfg.sasl_password,
|
|
verify_tls: cfg.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<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
|
|
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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
}
|
|
|
|
fn verify_tls12_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
_dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn verify_tls13_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
|
_dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
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: &SendMessage) -> 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 + message.recipient.len() + 2;
|
|
let max_payload = 512_usize.saturating_sub(overhead);
|
|
let chunks = split_message(&message.content, max_payload);
|
|
|
|
for chunk in chunks {
|
|
Self::send_raw(writer, &format!("PRIVMSG {} :{chunk}", message.recipient)).await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> 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 == "+") {
|
|
if let Some(password) = self.sasl_password.as_deref() {
|
|
let encoded = encode_sasl_plain(¤t_nick, password);
|
|
let mut guard = self.writer.lock().await;
|
|
if let Some(ref mut w) = *guard {
|
|
Self::send_raw(w, &format!("AUTHENTICATE {encoded}")).await?;
|
|
}
|
|
} else {
|
|
// SASL was requested but no password is configured; abort SASL
|
|
tracing::warn!(
|
|
"SASL authentication requested but no SASL password is configured; aborting SASL"
|
|
);
|
|
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_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: sender_nick.to_string(),
|
|
reply_target: 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(IrcChannelConfig {
|
|
server: "irc.test".into(),
|
|
port: 6697,
|
|
nickname: "bot".into(),
|
|
username: None,
|
|
channels: vec![],
|
|
allowed_users: vec!["alice".into(), "bob".into()],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: 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(IrcChannelConfig {
|
|
server: "irc.test".into(),
|
|
port: 6697,
|
|
nickname: "bot".into(),
|
|
username: None,
|
|
channels: vec![],
|
|
allowed_users: vec!["Alice".into()],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: 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(IrcChannelConfig {
|
|
server: "irc.test".into(),
|
|
port: 6697,
|
|
nickname: "bot".into(),
|
|
username: None,
|
|
channels: vec![],
|
|
allowed_users: vec![],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: true,
|
|
});
|
|
assert!(!ch.is_user_allowed("anyone"));
|
|
}
|
|
|
|
// ── Constructor ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn new_defaults_username_to_nickname() {
|
|
let ch = IrcChannel::new(IrcChannelConfig {
|
|
server: "irc.test".into(),
|
|
port: 6697,
|
|
nickname: "mybot".into(),
|
|
username: None,
|
|
channels: vec![],
|
|
allowed_users: vec![],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: true,
|
|
});
|
|
assert_eq!(ch.username, "mybot");
|
|
}
|
|
|
|
#[test]
|
|
fn new_uses_explicit_username() {
|
|
let ch = IrcChannel::new(IrcChannelConfig {
|
|
server: "irc.test".into(),
|
|
port: 6697,
|
|
nickname: "mybot".into(),
|
|
username: Some("customuser".into()),
|
|
channels: vec![],
|
|
allowed_users: vec![],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: 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(IrcChannelConfig {
|
|
server: "irc.example.com".into(),
|
|
port: 6697,
|
|
nickname: "zcbot".into(),
|
|
username: Some("zeroclaw".into()),
|
|
channels: vec!["#test".into()],
|
|
allowed_users: vec!["alice".into()],
|
|
server_password: Some("serverpass".into()),
|
|
nickserv_password: Some("nspass".into()),
|
|
sasl_password: Some("saslpass".into()),
|
|
verify_tls: 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(IrcChannelConfig {
|
|
server: "irc.example.com".into(),
|
|
port: 6697,
|
|
nickname: "zcbot".into(),
|
|
username: None,
|
|
channels: vec!["#zeroclaw".into()],
|
|
allowed_users: vec!["*".into()],
|
|
server_password: None,
|
|
nickserv_password: None,
|
|
sasl_password: None,
|
|
verify_tls: true,
|
|
})
|
|
}
|
|
}
|