rubot/src/bot/commands.rs
reverse a5e58493d8 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.
2026-07-29 16:13:24 +00:00

149 lines
5.5 KiB
Rust

//! Message routing and the command table — the part you extend most.
//!
//! [`Bot::handle_privmsg`] filters out replayed history and the bot's own
//! echo, then routes prefixed commands to [`Bot::handle_command`]. Add new
//! commands there.
use std::io;
use super::module::{Action, Command};
use super::Bot;
use crate::irc::{now_unix, parse_server_time, Message};
/// A message whose `server-time` is older than this is treated as replayed
/// history. Wide enough to absorb network latency and modest clock skew, but
/// far below the age of real chat history (minutes to weeks).
const HISTORY_GRACE_SECS: u64 = 60;
impl Bot {
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
pub(super) fn handle_batch(&mut self, msg: &Message) {
// :server BATCH +<ref> <type> [params] opens; BATCH -<ref> closes.
let Some(reference) = msg.param(0) else { return };
if let Some(name) = reference.strip_prefix('+') {
self.batches
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
} else if let Some(name) = reference.strip_prefix('-') {
self.batches.remove(name);
}
}
/// Is this message replayed history rather than something happening now?
/// Two independent signals: membership in a chathistory/playback BATCH, or a
/// `server-time` tag older than [`HISTORY_GRACE_SECS`].
fn is_historical(&self, msg: &Message) -> bool {
if let Some(reference) = msg.tag("batch") {
if let Some(btype) = self.batches.get(reference) {
if btype.contains("chathistory") || btype.contains("playback") {
return true;
}
}
}
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
return true;
}
}
false
}
/// Handle a channel or private message, routing recognised commands.
pub(super) fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
// Ignore replayed channel history (InspIRCd's on-join playback), or the
// bot would re-answer old commands on every reconnect.
if self.is_historical(msg) {
return Ok(());
}
let Some(target) = msg.param(0) else { return Ok(()) };
let text = msg.trailing().unwrap_or("");
let sender = msg.nick().unwrap_or("");
// Never react to our own messages. Some networks (like this one) echo
// channel messages back to the sender; without this guard a command
// whose reply starts with the prefix could trigger the bot on itself.
if sender.eq_ignore_ascii_case(&self.current_nick) {
return Ok(());
}
// Reply in-channel for channel messages, or back to the user for DMs.
let reply_to = if is_channel(target) {
target.to_string()
} else {
sender.to_string()
};
// Only react to text that starts with the configured command prefix.
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
return Ok(());
};
let mut parts = rest.split_whitespace();
let command = parts.next().unwrap_or("").to_lowercase();
let args: Vec<&str> = parts.collect();
self.handle_command(&reply_to, sender, &command, &args)
}
/// 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,
sender: &str,
command: &str,
args: &[&str],
) -> io::Result<()> {
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)?,
}
}
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.
fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}