//! 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 + [params] opens; BATCH - 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 = 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(['#', '&', '+', '!']) }