Introduce a `Module` trait (src/bot/module.rs): a feature declares the commands it provides and turns each invocation into a `Vec<Action>`, without touching the connection. Modules are pure — context in, actions out — so they are unit-testable in isolation and avoid borrowing the `Bot` that owns them. Bot::new builds the module set and a command -> module route table; the `commands` dispatcher routes to the owning module and executes its actions, and `help` is auto-generated from every module's CommandSpec metadata. The existing ping/echo/hello move into a `builtins` module, and a stateful `dice` module (`roll [NdM]`, with its own dependency-free xorshift PRNG) is added as a worked example. Each network gets its own module set, so stateful modules keep independent per-network state with no locking. Adding a feature is now a new file in bot/modules/ plus one line in modules::all(). Tests: new tests/modules.rs drives modules through the trait with no network; end-to-end dispatch and auto-help verified against a fake server. No new deps.
139 lines
5.1 KiB
Rust
139 lines
5.1 KiB
Rust
//! 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<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,
|
|
/// Registered feature modules (a fresh set per network/session).
|
|
modules: Vec<Box<dyn Module>>,
|
|
/// 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(())
|
|
}
|
|
}
|