Add a module (plugin) system for bot features

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean Chevronnet 2026-07-29 16:13:24 +00:00
parent eb3b8fec40
commit c522e3aa3e
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
8 changed files with 426 additions and 21 deletions

View file

@ -10,7 +10,7 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top:
| Path | Layer | Responsibility | | Path | Layer | Responsibility |
|-----------------|----------------|-------------------------------------------------------------------| |-----------------|----------------|-------------------------------------------------------------------|
| `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) | | `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/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing |
| `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library | | `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library |
| `src/main.rs` | binary | Thin per-network connect/reconnect supervisor | | `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` The layers are deliberately separable: `irc` knows nothing about the bot, `bot`
knows nothing about how the process is launched, and `main` only supervises. 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<Action>;
}
```
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/<name>.rs` implementing `Module`,
then add one line to `all()` in `src/bot/modules/mod.rs`:
```rust
pub fn all() -> Vec<Box<dyn Module>> {
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 ## Build & run

View file

@ -6,6 +6,7 @@
use std::io; use std::io;
use super::module::{Action, Command};
use super::Bot; use super::Bot;
use crate::irc::{now_unix, parse_server_time, Message}; use crate::irc::{now_unix, parse_server_time, Message};
@ -83,7 +84,8 @@ impl Bot {
self.handle_command(&reply_to, sender, &command, &args) 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( fn handle_command(
&mut self, &mut self,
reply_to: &str, reply_to: &str,
@ -91,22 +93,54 @@ impl Bot {
command: &str, command: &str,
args: &[&str], args: &[&str],
) -> io::Result<()> { ) -> io::Result<()> {
match command { if command == "help" {
"ping" => self.conn.privmsg(reply_to, "pong")?, let text = self.help_text(args.first().copied());
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?, return self.conn.privmsg(reply_to, &text);
"hello" => {
let text = format!("hello, {sender}!");
self.conn.privmsg(reply_to, &text)?;
} }
"help" => {
let p = &self.config.command_prefix; let Some(&idx) = self.routes.get(command) else {
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help"); return Ok(()); // Unknown command: stay silent rather than spam channels.
self.conn.privmsg(reply_to, &text)?; };
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)?,
} }
_ => {} // Unknown command: stay silent rather than spam channels.
} }
Ok(()) 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<String> = 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. /// Channels start with one of the standard channel-type prefixes.

View file

@ -1,10 +1,10 @@
//! Bot behavior: the [`Bot`] state, registration, and the event loop. //! Bot behavior: the [`Bot`] state, registration, and the event loop.
//! //!
//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`] //! Behaviour is split across sibling modules: capability negotiation and SASL
//! and turns incoming [`Message`]s into actions. The behaviour is split across //! live in [`caps`], and message routing in [`commands`]. Features live behind
//! sibling modules — capability negotiation and SASL live in [`caps`], and //! the [`Module`] plugin trait ([`module`]) and are registered in [`modules`],
//! message routing plus the command table live in [`commands`]. To add a //! so adding a command is a new file in `bot/modules/` plus one line in
//! feature you usually only touch [`commands`]. //! [`modules::all`] — no changes to the core dispatch.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io; use std::io;
@ -14,8 +14,13 @@ use crate::irc::{Connection, Message};
mod caps; mod caps;
mod commands; 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 { pub struct Bot {
config: Config, config: Config,
conn: Connection, conn: Connection,
@ -31,11 +36,31 @@ pub struct Bot {
batches: HashMap<String, String>, batches: HashMap<String, String>,
/// Whether the SASL exchange has begun this session. /// Whether the SASL exchange has begun this session.
sasl_started: bool, 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 { impl Bot {
pub fn new(config: Config, conn: Connection) -> Bot { pub fn new(config: Config, conn: Connection) -> Bot {
let current_nick = config.nick.clone(); 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 { Bot {
config, config,
conn, conn,
@ -45,6 +70,8 @@ impl Bot {
enabled_caps: HashSet::new(), enabled_caps: HashSet::new(),
batches: HashMap::new(), batches: HashMap::new(),
sasl_started: false, sasl_started: false,
modules: mods,
routes,
} }
} }

71
src/bot/module.rs Normal file
View file

@ -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 <command>`.
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<String>) -> 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<Action>;
}

View file

@ -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 <text>", 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<Action> {
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)]
}
}

99
src/bot/modules/dice.rs Normal file
View file

@ -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<Action> {
let spec = cmd.args.first().copied().unwrap_or("1d6");
let reply = match parse_spec(spec) {
Some((count, sides)) => {
let rolls: Vec<u64> = (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<String> = 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
}
}

17
src/bot/modules/mod.rs Normal file
View file

@ -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<Box<dyn Module>> {
vec![
Box::new(builtins::Builtins),
Box::new(dice::Dice::new()),
]
}

87
tests/modules.rs Normal file
View file

@ -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"));
}