diff --git a/README.md b/README.md index 37a47f3..1afc243 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: | Path | Layer | Responsibility | |-----------------|----------------|-------------------------------------------------------------------| | `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) | -| `src/bot/` | behavior | `mod` (state + event loop), `caps` (IRCv3 cap negotiation + SASL), `commands` (message routing + command table) | +| `src/bot/` | behavior | `mod` (state + loop), `caps` (IRCv3 + SASL), `commands` (routing + help), `module` (plugin trait), `modules/` (feature modules) | | `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing | | `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library | | `src/main.rs` | binary | Thin per-network connect/reconnect supervisor | @@ -18,7 +18,44 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: The layers are deliberately separable: `irc` knows nothing about the bot, `bot` knows nothing about how the process is launched, and `main` only supervises. -Adding a command is usually a one-file change in `src/bot/commands.rs`. +Adding a feature is a new **module** — see [Modules](#modules). + +## Modules + +Features are **modules**: self-contained units behind the `Module` trait +(`src/bot/module.rs`). A module declares the commands it provides and turns each +invocation into `Action`s — it never touches the socket, so it stays pure and +unit-testable: + +```rust +pub trait Module { + fn name(&self) -> &'static str; + fn commands(&self) -> &'static [CommandSpec]; + fn on_command(&mut self, cmd: &Command) -> Vec; +} +``` + +At startup the bot builds a `command → module` route table and auto-generates +`help` from every module's `CommandSpec`s. Each network gets its own fresh set +of modules, so stateful ones (like the dice roller's PRNG) keep per-network +state with no locking. + +**To add a module**, create `src/bot/modules/.rs` implementing `Module`, +then add one line to `all()` in `src/bot/modules/mod.rs`: + +```rust +pub fn all() -> Vec> { + vec![ + Box::new(builtins::Builtins), + Box::new(dice::Dice::new()), + Box::new(myfeature::MyFeature::new()), // <- your module + ] +} +``` + +Shipped modules: **`builtins`** (`ping`, `echo`, `hello`) and **`dice`** +(`roll [NdM]`). Module tests live in `tests/modules.rs` — construct a module and +assert on the `Action`s it returns, no network required. ## Build & run diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 95cc5df..7fff737 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -6,6 +6,7 @@ use std::io; +use super::module::{Action, Command}; use super::Bot; use crate::irc::{now_unix, parse_server_time, Message}; @@ -83,7 +84,8 @@ impl Bot { self.handle_command(&reply_to, sender, &command, &args) } - /// The command table. Add new bot commands here. + /// Route a command to the module that declares it and execute the actions + /// it returns. `help` is handled here, aggregating every module's commands. fn handle_command( &mut self, reply_to: &str, @@ -91,22 +93,54 @@ impl Bot { 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)?; + if command == "help" { + let text = self.help_text(args.first().copied()); + return self.conn.privmsg(reply_to, &text); + } + + let Some(&idx) = self.routes.get(command) else { + return Ok(()); // Unknown command: stay silent rather than spam channels. + }; + + let cmd = Command { + sender, + reply_to, + name: command, + args, + prefix: &self.config.command_prefix, + network: &self.config.name, + }; + let actions = self.modules[idx].on_command(&cmd); + for action in actions { + match action { + Action::Reply(text) => self.conn.privmsg(reply_to, &text)?, + Action::Raw(line) => self.conn.send_raw(&line)?, } - "help" => { - let p = &self.config.command_prefix; - let text = format!("commands: {p}ping, {p}echo , {p}hello, {p}help"); - self.conn.privmsg(reply_to, &text)?; - } - _ => {} // Unknown command: stay silent rather than spam channels. } Ok(()) } + + /// Build the `help` reply: with no argument, list every module's commands; + /// with a command name, show that command's description. + fn help_text(&self, arg: Option<&str>) -> String { + let p = &self.config.command_prefix; + if let Some(name) = arg { + for m in &self.modules { + if let Some(spec) = m.commands().iter().find(|s| s.name == name) { + return format!("{p}{}: {}", spec.usage, spec.about); + } + } + return format!("no such command '{name}'; try {p}help"); + } + let mut items: Vec = Vec::new(); + for m in &self.modules { + for spec in m.commands() { + items.push(format!("{p}{}", spec.usage)); + } + } + items.push(format!("{p}help")); + format!("commands: {}", items.join(", ")) + } } /// Channels start with one of the standard channel-type prefixes. diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 036dfd4..69ae21b 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,10 +1,10 @@ //! 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`]. +//! 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; @@ -14,8 +14,13 @@ use crate::irc::{Connection, Message}; mod caps; mod commands; +pub mod module; +pub mod modules; -/// The bot: owns its configuration and connection, and runs one session. +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, @@ -31,11 +36,31 @@ pub struct Bot { 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, @@ -45,6 +70,8 @@ impl Bot { enabled_caps: HashSet::new(), batches: HashMap::new(), sasl_started: false, + modules: mods, + routes, } } diff --git a/src/bot/module.rs b/src/bot/module.rs new file mode 100644 index 0000000..05677bf --- /dev/null +++ b/src/bot/module.rs @@ -0,0 +1,71 @@ +//! The module (plugin) framework: the [`Module`] trait plus the [`Command`] +//! context it receives and the [`Action`]s it returns. +//! +//! Modules are pure in spirit: given a [`Command`], a module returns the +//! [`Action`]s to perform without ever touching the connection. That keeps them +//! trivially unit-testable and sidesteps borrow conflicts with the [`super::Bot`] +//! that owns them (a module never needs `&mut` access to the bot). + +/// Metadata describing one command a module provides. Used to route commands to +/// modules and to auto-generate `help`. +#[derive(Debug, Clone, Copy)] +pub struct CommandSpec { + /// The command word, lowercase, without the prefix (e.g. `"roll"`). + pub name: &'static str, + /// Usage shown in help, without the prefix (e.g. `"roll [NdM]"`). + pub usage: &'static str, + /// One-line description shown by `help `. + pub about: &'static str, +} + +/// The context passed to a module for one command invocation. All fields borrow +/// from the current message and configuration. +pub struct Command<'a> { + /// Nick of the sender. + pub sender: &'a str, + /// Where a reply should go: the channel for channel messages, or the + /// sender's nick for private messages. + pub reply_to: &'a str, + /// The command word (lowercased, prefix stripped). + pub name: &'a str, + /// Arguments after the command word (whitespace-split). + pub args: &'a [&'a str], + /// The configured command prefix (e.g. `">"`), for building help text. + pub prefix: &'a str, + /// The network's name (`Config::name`), for network-aware modules. + pub network: &'a str, +} + +/// Something a module asks the bot to do in response to a command. Modules +/// return these instead of performing I/O themselves. +pub enum Action { + /// Send a `PRIVMSG` back to [`Command::reply_to`]. + Reply(String), + /// Send an arbitrary raw IRC line (for power modules, e.g. an admin module). + Raw(String), +} + +impl Action { + /// Convenience constructor for a reply from anything string-like. + pub fn reply(text: impl Into) -> Action { + Action::Reply(text.into()) + } +} + +/// A bot feature: a self-contained unit that declares the commands it handles +/// and turns each invocation into [`Action`]s. +/// +/// Add one by creating a file in `bot/modules/` that implements this trait and +/// listing its constructor in [`super::modules::all`]. +pub trait Module { + /// Short identifier for logs and diagnostics (e.g. `"dice"`). + fn name(&self) -> &'static str; + + /// The commands this module provides. The registry routes matching command + /// words here and builds `help` from these specs. + fn commands(&self) -> &'static [CommandSpec]; + + /// Handle one command (only ever called for a name in [`Module::commands`]). + /// Return the actions to perform; an empty vec does nothing. + fn on_command(&mut self, cmd: &Command) -> Vec; +} diff --git a/src/bot/modules/builtins.rs b/src/bot/modules/builtins.rs new file mode 100644 index 0000000..c21a068 --- /dev/null +++ b/src/bot/modules/builtins.rs @@ -0,0 +1,33 @@ +//! The built-in commands: `ping`, `echo`, `hello`. (`help` is provided by the +//! dispatcher itself, since it aggregates every module's commands.) + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +/// Core commands that ship with the bot. +pub struct Builtins; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" }, + CommandSpec { name: "echo", usage: "echo ", about: "echo the text back" }, + CommandSpec { name: "hello", usage: "hello", about: "greet the sender" }, +]; + +impl Module for Builtins { + fn name(&self) -> &'static str { + "builtins" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let reply = match cmd.name { + "ping" => "pong".to_string(), + "echo" => cmd.args.join(" "), + "hello" => format!("hello, {}!", cmd.sender), + _ => return Vec::new(), + }; + vec![Action::Reply(reply)] + } +} diff --git a/src/bot/modules/dice.rs b/src/bot/modules/dice.rs new file mode 100644 index 0000000..33ad826 --- /dev/null +++ b/src/bot/modules/dice.rs @@ -0,0 +1,99 @@ +//! A dice roller: `roll 2d6`, `roll d20`, or `roll` (defaults to `1d6`). +//! +//! Demonstrates a *stateful* module — it carries its own tiny PRNG, seeded once +//! at construction — while keeping the crate dependency-free (no `rand`). + +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "roll", + usage: "roll [NdM]", + about: "roll N dice of M sides (default 1d6), e.g. roll 2d20", +}]; + +/// Upper bounds to keep output (and the channel) sane. +const MAX_DICE: u64 = 100; +const MAX_SIDES: u64 = 1000; + +/// Dice roller with a self-contained xorshift PRNG — fine for dice, not for +/// anything security-sensitive. +pub struct Dice { + rng: u64, +} + +impl Dice { + pub fn new() -> Dice { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + Dice { rng: seed | 1 } // never seed to 0 (xorshift would stay stuck) + } + + fn next_u64(&mut self) -> u64 { + let mut x = self.rng; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.rng = x; + x + } + + /// Roll a single die in `1..=sides`. + fn die(&mut self, sides: u64) -> u64 { + self.next_u64() % sides + 1 + } +} + +impl Default for Dice { + fn default() -> Dice { + Dice::new() + } +} + +impl Module for Dice { + fn name(&self) -> &'static str { + "dice" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let spec = cmd.args.first().copied().unwrap_or("1d6"); + let reply = match parse_spec(spec) { + Some((count, sides)) => { + let rolls: Vec = (0..count).map(|_| self.die(sides)).collect(); + let total: u64 = rolls.iter().sum(); + if count == 1 { + format!("{count}d{sides}: {total}") + } else { + let parts: Vec = rolls.iter().map(u64::to_string).collect(); + format!("{count}d{sides}: {} = {total}", parts.join(" + ")) + } + } + None => format!("usage: {p}roll [NdM], e.g. {p}roll 2d6", p = cmd.prefix), + }; + vec![Action::Reply(reply)] + } +} + +/// Parse `NdM` / `dM` / `M` into `(count, sides)` within sane bounds. A bare +/// number `M` means `1dM`. +fn parse_spec(s: &str) -> Option<(u64, u64)> { + let (count, sides) = match s.split_once('d') { + Some((c, m)) => { + let count = if c.is_empty() { 1 } else { c.parse().ok()? }; + (count, m.parse().ok()?) + } + None => (1, s.parse().ok()?), + }; + if (1..=MAX_DICE).contains(&count) && (2..=MAX_SIDES).contains(&sides) { + Some((count, sides)) + } else { + None + } +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs new file mode 100644 index 0000000..d6f613a --- /dev/null +++ b/src/bot/modules/mod.rs @@ -0,0 +1,17 @@ +//! The registry of feature modules. Add a feature by creating a file here that +//! implements [`super::module::Module`] and listing its constructor in [`all`]. + +pub mod builtins; +pub mod dice; + +use super::module::Module; + +/// Construct the default set of modules, in priority order (earlier modules win +/// command-name collisions). Each network's [`super::Bot`] gets its own fresh +/// set, so stateful modules keep independent per-network state. +pub fn all() -> Vec> { + vec![ + Box::new(builtins::Builtins), + Box::new(dice::Dice::new()), + ] +} diff --git a/tests/modules.rs b/tests/modules.rs new file mode 100644 index 0000000..d15021d --- /dev/null +++ b/tests/modules.rs @@ -0,0 +1,87 @@ +//! Unit tests for feature modules — constructed directly and driven through the +//! public `Module` trait, with no network. This is the payoff of the module +//! system: features are testable in isolation. + +use rustbot::bot::module::{Action, Command, Module}; +use rustbot::bot::modules::{builtins::Builtins, dice::Dice}; + +fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { + Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" } +} + +/// Assert the module produced exactly one reply and return its text. +fn reply(actions: &[Action]) -> &str { + match actions { + [Action::Reply(s)] => s, + _ => panic!("expected exactly one Reply action"), + } +} + +#[test] +fn builtins_ping_pongs() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("ping", &[]))), "pong"); +} + +#[test] +fn builtins_echo_joins_args() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world"); +} + +#[test] +fn builtins_hello_greets_sender() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("hello", &[]))), "hello, alice!"); +} + +#[test] +fn dice_declares_roll_command() { + let m = Dice::new(); + assert_eq!(m.name(), "dice"); + assert!(m.commands().iter().any(|c| c.name == "roll")); +} + +#[test] +fn dice_2d6_total_is_in_range() { + let mut d = Dice::new(); + for _ in 0..300 { + let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string(); + // Format: "2d6: a + b = total" + let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap(); + assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}"); + } +} + +#[test] +fn dice_defaults_to_1d6() { + let mut d = Dice::new(); + let r = reply(&d.on_command(&cmd("roll", &[]))).to_string(); + // Format: "1d6: N" + assert!(r.starts_with("1d6: "), "unexpected format: {r:?}"); + let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap(); + assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}"); +} + +#[test] +fn dice_bare_number_means_one_die() { + let mut d = Dice::new(); + let r = reply(&d.on_command(&cmd("roll", &["20"]))).to_string(); + assert!(r.starts_with("1d20: "), "expected 1d20, got {r:?}"); +} + +#[test] +fn dice_rejects_garbage_with_usage() { + let mut d = Dice::new(); + let actions = d.on_command(&cmd("roll", &["banana"])); + let r = reply(&actions); + assert!(r.contains("usage"), "expected a usage hint, got {r:?}"); +} + +#[test] +fn dice_rejects_out_of_bounds() { + let mut d = Dice::new(); + // 999 dice exceeds the cap; 1-sided die is below the minimum. + assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage")); + assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage")); +}