Restructure into a library crate with modular bot and irc

Split the single-binary crate into a reusable `rustbot` library (src/lib.rs)
plus a thin supervisor binary. The monolithic irc.rs and bot.rs become module
folders:

  irc/    message (wire parsing), connection (TCP/TLS transport), encoding
  bot/    mod (state + event loop), caps (IRCv3 cap negotiation + SASL),
          commands (message routing + command table)
  config  Config + layered loading, moved out of main.rs and bot.rs

main.rs is now just the per-network connect/reconnect supervisor over the
library API. Unit tests move out of the source files into tests/ integration
tests (config.rs, protocol.rs) that drive the public surface — prefix parsing
is now covered through Message::parse, and config layering through the new
public config::parse_str. Behavior is unchanged; no new dependencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean Chevronnet 2026-07-29 15:38:55 +00:00
parent e7e805cb87
commit eb3b8fec40
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
16 changed files with 1189 additions and 1116 deletions

141
src/irc/connection.rs Normal file
View file

@ -0,0 +1,141 @@
//! The blocking IRC transport: a plain-TCP or TLS byte stream wrapped with
//! line reading and convenience senders.
use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use native_tls::TlsConnector;
use super::Message;
/// Any bidirectional byte stream: a plain `TcpStream` or a TLS session. The
/// blanket impl covers both, so [`Connection`] can treat them uniformly.
trait Stream: Read + Write {}
impl<T: Read + Write> Stream for T {}
/// A blocking IRC connection over plain TCP or TLS.
///
/// The event loop is single-threaded (read a message, handle it, maybe write a
/// reply, repeat), so one stream serves both directions: reads go through the
/// `BufReader`, writes through `get_mut()`. This is what lets a single TLS
/// session — which cannot be cloned into independent halves — work unchanged.
pub struct Connection {
inner: BufReader<Box<dyn Stream>>,
/// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay
/// distinguishable. Empty for a single network, keeping its output terse.
log_prefix: String,
}
impl Connection {
/// Connect to `host:port`, optionally wrapping the socket in TLS.
///
/// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host`
/// supplied as the SNI name and for certificate verification.
pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result<Connection> {
let tcp = TcpStream::connect((host, port))?;
let stream: Box<dyn Stream> = if use_tls {
let connector =
TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?;
let tls = connector
.connect(host, tcp)
.map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?;
Box::new(tls)
} else {
Box::new(tcp)
};
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
}
/// Set a short label prepended to this connection's wire logs, so output
/// from multiple simultaneous networks stays distinguishable. An empty
/// label clears it (the terse single-network default).
pub fn set_label(&mut self, label: &str) {
self.log_prefix = if label.is_empty() {
String::new()
} else {
format!("[{label}] ")
};
}
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
/// (the server closed the connection). Blank/unparseable lines are skipped.
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
loop {
let mut line = String::new();
if self.inner.read_line(&mut line)? == 0 {
return Ok(None); // EOF
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
eprintln!("{}<< {trimmed}", self.log_prefix);
if let Some(msg) = Message::parse(trimmed) {
return Ok(Some(msg));
}
// Unparseable line: skip and keep reading.
}
}
/// Send one raw, pre-formatted IRC line. The CRLF terminator is added here;
/// any embedded CR/LF are neutralised to prevent command injection.
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
let clean = line.replace(['\r', '\n'], " ");
eprintln!("{}>> {clean}", self.log_prefix);
let writer = self.inner.get_mut();
write!(writer, "{clean}\r\n")?;
writer.flush()
}
// --- Convenience senders -------------------------------------------------
pub fn pass(&mut self, password: &str) -> io::Result<()> {
self.send_raw(&format!("PASS {password}"))
}
pub fn nick(&mut self, nick: &str) -> io::Result<()> {
self.send_raw(&format!("NICK {nick}"))
}
pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> {
self.send_raw(&format!("USER {user} 0 * :{realname}"))
}
pub fn join(&mut self, channel: &str) -> io::Result<()> {
self.send_raw(&format!("JOIN {channel}"))
}
pub fn pong(&mut self, token: &str) -> io::Result<()> {
self.send_raw(&format!("PONG :{token}"))
}
pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> {
self.send_raw(&format!("PRIVMSG {target} :{text}"))
}
pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> {
self.send_raw(&format!("NOTICE {target} :{text}"))
}
pub fn quit(&mut self, reason: &str) -> io::Result<()> {
self.send_raw(&format!("QUIT :{reason}"))
}
// --- IRCv3 capability negotiation ---------------------------------------
pub fn cap_ls(&mut self) -> io::Result<()> {
self.send_raw("CAP LS 302")
}
pub fn cap_req(&mut self, caps: &str) -> io::Result<()> {
self.send_raw(&format!("CAP REQ :{caps}"))
}
pub fn cap_end(&mut self) -> io::Result<()> {
self.send_raw("CAP END")
}
pub fn authenticate(&mut self, payload: &str) -> io::Result<()> {
self.send_raw(&format!("AUTHENTICATE {payload}"))
}
}