//! Bot behavior: the [`Bot`] state, registration, and the event loop. //! //! Behaviour is split across sibling modules: capability negotiation and SASL //! live in [`caps`], and message routing in [`commands`]. Features live behind //! the [`Module`] plugin trait ([`module`]) and are registered in [`modules`], //! so adding a command is a new file in `bot/modules/` plus one line in //! [`modules::all`] — no changes to the core dispatch. use std::collections::{HashMap, HashSet}; use std::io; use crate::config::Config; use crate::irc::{Connection, Message}; mod caps; mod commands; pub mod module; pub mod modules; pub use module::{Action, Command, CommandSpec, Module}; /// The bot: owns its configuration, connection, and feature modules, 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, /// Capabilities we successfully negotiated (`CAP ACK`). enabled_caps: HashSet, /// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`). batches: HashMap, /// Whether the SASL exchange has begun this session. sasl_started: bool, /// Registered feature modules (a fresh set per network/session). modules: Vec>, /// Command word -> index into `modules`, built from each module's specs. routes: HashMap<&'static str, usize>, } impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); // Build the module set and the command -> module route table. let mods = modules::all(); let mut routes: HashMap<&'static str, usize> = HashMap::new(); for (i, m) in mods.iter().enumerate() { for spec in m.commands() { if routes.insert(spec.name, i).is_some() { crate::log(&format!( "module '{}' overrides command '{}'", m.name(), spec.name )); } } } Bot { config, conn, current_nick, registered: false, available_caps: HashSet::new(), enabled_caps: HashSet::new(), batches: HashMap::new(), sasl_started: false, modules: mods, routes, } } /// 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(()) } }