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.
This commit is contained in:
Jean Chevronnet 2026-07-29 15:38:55 +00:00
parent b9e95430f5
commit a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions

109
src/bot/caps.rs Normal file
View file

@ -0,0 +1,109 @@
//! IRCv3 capability negotiation and SASL PLAIN authentication.
//!
//! These are methods on [`Bot`]; they are `pub(super)` where the event loop in
//! [`super`] dispatches to them, and private otherwise.
use std::io;
use super::Bot;
use crate::irc::{base64_encode, Message};
/// IRCv3 capabilities we request when the server offers them. `sasl` is added
/// separately, only when credentials are configured.
const WANTED_CAPS: &[&str] = &[
"message-tags",
"server-time",
"batch",
"echo-message",
"account-tag",
"extended-join",
"multi-prefix",
"away-notify",
"chghost",
"setname",
"userhost-in-names",
"cap-notify",
"labeled-response",
];
impl Bot {
/// Handle a `CAP` reply: collect advertised caps, request the ones we want,
/// and record what was acknowledged.
pub(super) fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
// :server CAP <target> <subcommand> [*] :<space-separated caps>
match msg.param(1) {
Some("LS") => {
// A "*" in the third field means the list continues in more lines.
let more = msg.param(2) == Some("*");
for cap in msg.trailing().unwrap_or("").split_whitespace() {
// Caps may be advertised as "name=value" (e.g. sasl=PLAIN).
let name = cap.split('=').next().unwrap_or(cap);
self.available_caps.insert(name.to_string());
}
if !more {
self.request_caps()?;
}
}
Some("ACK") => {
for cap in msg.trailing().unwrap_or("").split_whitespace() {
self.enabled_caps
.insert(cap.trim_start_matches(['-', '~', '=']).to_string());
}
self.after_cap_ack()?;
}
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
_ => {} // NEW/DEL: ignored for now
}
Ok(())
}
/// Request the subset of [`WANTED_CAPS`] the server actually offers, plus
/// `sasl` when credentials are configured. Ends negotiation if none apply.
fn request_caps(&mut self) -> io::Result<()> {
let mut wanted: Vec<&str> = WANTED_CAPS
.iter()
.copied()
.filter(|c| self.available_caps.contains(*c))
.collect();
if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") {
wanted.push("sasl");
}
if wanted.is_empty() {
self.conn.cap_end()
} else {
self.conn.cap_req(&wanted.join(" "))
}
}
/// After caps are acknowledged, either start SASL or finish negotiation.
fn after_cap_ack(&mut self) -> io::Result<()> {
if self.enabled_caps.contains("sasl")
&& self.sasl_credentials().is_some()
&& !self.sasl_started
{
self.sasl_started = true;
self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +"
} else {
self.conn.cap_end()
}
}
/// Respond to the server's SASL prompt with base64 PLAIN credentials.
pub(super) fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
if msg.param(0) == Some("+") {
if let Some((user, pass)) = self.sasl_credentials() {
// SASL PLAIN payload: authzid \0 authcid \0 passwd (authzid empty).
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
self.conn.authenticate(&encoded)?;
}
}
Ok(())
}
fn sasl_credentials(&self) -> Option<(String, String)> {
match (&self.config.sasl_user, &self.config.sasl_pass) {
(Some(u), Some(p)) => Some((u.clone(), p.clone())),
_ => None,
}
}
}

115
src/bot/commands.rs Normal file
View file

@ -0,0 +1,115 @@
//! Message routing and the command table — the part you extend most.
//!
//! [`Bot::handle_privmsg`] filters out replayed history and the bot's own
//! echo, then routes prefixed commands to [`Bot::handle_command`]. Add new
//! commands there.
use std::io;
use super::Bot;
use crate::irc::{now_unix, parse_server_time, Message};
/// A message whose `server-time` is older than this is treated as replayed
/// history. Wide enough to absorb network latency and modest clock skew, but
/// far below the age of real chat history (minutes to weeks).
const HISTORY_GRACE_SECS: u64 = 60;
impl Bot {
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
pub(super) fn handle_batch(&mut self, msg: &Message) {
// :server BATCH +<ref> <type> [params] opens; BATCH -<ref> closes.
let Some(reference) = msg.param(0) else { return };
if let Some(name) = reference.strip_prefix('+') {
self.batches
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
} else if let Some(name) = reference.strip_prefix('-') {
self.batches.remove(name);
}
}
/// Is this message replayed history rather than something happening now?
/// Two independent signals: membership in a chathistory/playback BATCH, or a
/// `server-time` tag older than [`HISTORY_GRACE_SECS`].
fn is_historical(&self, msg: &Message) -> bool {
if let Some(reference) = msg.tag("batch") {
if let Some(btype) = self.batches.get(reference) {
if btype.contains("chathistory") || btype.contains("playback") {
return true;
}
}
}
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
return true;
}
}
false
}
/// Handle a channel or private message, routing recognised commands.
pub(super) fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
// Ignore replayed channel history (InspIRCd's on-join playback), or the
// bot would re-answer old commands on every reconnect.
if self.is_historical(msg) {
return Ok(());
}
let Some(target) = msg.param(0) else { return Ok(()) };
let text = msg.trailing().unwrap_or("");
let sender = msg.nick().unwrap_or("");
// Never react to our own messages. Some networks (like this one) echo
// channel messages back to the sender; without this guard a command
// whose reply starts with the prefix could trigger the bot on itself.
if sender.eq_ignore_ascii_case(&self.current_nick) {
return Ok(());
}
// Reply in-channel for channel messages, or back to the user for DMs.
let reply_to = if is_channel(target) {
target.to_string()
} else {
sender.to_string()
};
// Only react to text that starts with the configured command prefix.
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
return Ok(());
};
let mut parts = rest.split_whitespace();
let command = parts.next().unwrap_or("").to_lowercase();
let args: Vec<&str> = parts.collect();
self.handle_command(&reply_to, sender, &command, &args)
}
/// The command table. Add new bot commands here.
fn handle_command(
&mut self,
reply_to: &str,
sender: &str,
command: &str,
args: &[&str],
) -> io::Result<()> {
match command {
"ping" => self.conn.privmsg(reply_to, "pong")?,
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?,
"hello" => {
let text = format!("hello, {sender}!");
self.conn.privmsg(reply_to, &text)?;
}
"help" => {
let p = &self.config.command_prefix;
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help");
self.conn.privmsg(reply_to, &text)?;
}
_ => {} // Unknown command: stay silent rather than spam channels.
}
Ok(())
}
}
/// Channels start with one of the standard channel-type prefixes.
fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}

112
src/bot/mod.rs Normal file
View file

@ -0,0 +1,112 @@
//! Bot behavior: the [`Bot`] state, registration, and the event loop.
//!
//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`]
//! and turns incoming [`Message`]s into actions. The behaviour is split across
//! sibling modules — capability negotiation and SASL live in [`caps`], and
//! message routing plus the command table live in [`commands`]. To add a
//! feature you usually only touch [`commands`].
use std::collections::{HashMap, HashSet};
use std::io;
use crate::config::Config;
use crate::irc::{Connection, Message};
mod caps;
mod commands;
/// The bot: owns its configuration and connection, and runs one session.
pub struct Bot {
config: Config,
conn: Connection,
/// The nick we are currently trying to use (may drift from `config.nick`
/// if the server reports a collision).
current_nick: String,
registered: bool,
/// Capabilities the server advertised in `CAP LS`.
available_caps: HashSet<String>,
/// Capabilities we successfully negotiated (`CAP ACK`).
enabled_caps: HashSet<String>,
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
batches: HashMap<String, String>,
/// Whether the SASL exchange has begun this session.
sasl_started: bool,
}
impl Bot {
pub fn new(config: Config, conn: Connection) -> Bot {
let current_nick = config.nick.clone();
Bot {
config,
conn,
current_nick,
registered: false,
available_caps: HashSet::new(),
enabled_caps: HashSet::new(),
batches: HashMap::new(),
sasl_started: false,
}
}
/// Register with the server, then process messages until disconnect.
///
/// Returns `Ok(())` when the server closes the connection cleanly; the
/// caller (the supervisor) decides whether to reconnect.
pub fn run(&mut self) -> io::Result<()> {
self.register()?;
while let Some(msg) = self.conn.read_message()? {
self.handle(&msg)?;
}
Ok(())
}
/// Begin registration: open IRCv3 capability negotiation, then send
/// NICK/USER. The server withholds `001` until we send `CAP END`, which
/// happens once caps (and optional SASL) are settled — see [`Bot::handle`].
fn register(&mut self) -> io::Result<()> {
if let Some(password) = self.config.password.clone() {
self.conn.pass(&password)?;
}
self.conn.cap_ls()?;
let nick = self.current_nick.clone();
self.conn.nick(&nick)?;
let (user, realname) = (self.config.user.clone(), self.config.realname.clone());
self.conn.user(&user, &realname)?;
Ok(())
}
/// Dispatch a single incoming message.
fn handle(&mut self, msg: &Message) -> io::Result<()> {
match msg.command.as_str() {
// Keep-alive: reply immediately or the server will time us out.
"PING" => {
let token = msg.trailing().unwrap_or("").to_string();
self.conn.pong(&token)?;
}
// IRCv3 capability negotiation and SASL (see `caps`).
"CAP" => self.handle_cap(msg)?,
"AUTHENTICATE" => self.handle_authenticate(msg)?,
"903" => self.conn.cap_end()?, // RPL_SASLSUCCESS
// SASL failed/aborted/locked/too-long/already-authed — proceed anyway.
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
// Track open/closed batches so we can recognise replayed history.
"BATCH" => self.handle_batch(msg),
// RPL_WELCOME: registration succeeded — now it's safe to join.
"001" => {
self.registered = true;
for channel in self.config.channels.clone() {
self.conn.join(&channel)?;
}
}
// ERR_NICKNAMEINUSE: append an underscore and try again.
"433" => {
self.current_nick.push('_');
let nick = self.current_nick.clone();
self.conn.nick(&nick)?;
}
"PRIVMSG" => self.handle_privmsg(msg)?,
_ => {}
}
Ok(())
}
}