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:
parent
e7e805cb87
commit
eb3b8fec40
16 changed files with 1189 additions and 1116 deletions
141
src/irc/connection.rs
Normal file
141
src/irc/connection.rs
Normal 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}"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue