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.
149 lines
5.5 KiB
Rust
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(['#', '&', '+', '!'])
|
|
}
|