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.
This commit is contained in:
Jean Chevronnet 2026-07-29 16:13:24 +00:00
parent a15fb4f9a9
commit a5e58493d8
8 changed files with 426 additions and 21 deletions

View file

@ -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 <text>, {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<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.

View file

@ -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<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,
@ -45,6 +70,8 @@ impl Bot {
enabled_caps: HashSet::new(),
batches: HashMap::new(),
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()),
]
}