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

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
}
}