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.