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:
parent
eb3b8fec40
commit
c522e3aa3e
8 changed files with 426 additions and 21 deletions
33
src/bot/modules/builtins.rs
Normal file
33
src/bot/modules/builtins.rs
Normal 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
99
src/bot/modules/dice.rs
Normal 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
17
src/bot/modules/mod.rs
Normal 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()),
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue