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:
parent
b9e95430f5
commit
a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions
112
src/bot/mod.rs
Normal file
112
src/bot/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue