Restructure into a library crate with modular bot and irc

Split the single-binary crate into a reusable `rustbot` library (src/lib.rs)
plus a thin supervisor binary. The monolithic irc.rs and bot.rs become module
folders:

  irc/    message (wire parsing), connection (TCP/TLS transport), encoding
  bot/    mod (state + event loop), caps (IRCv3 cap negotiation + SASL),
          commands (message routing + command table)
  config  Config + layered loading, moved out of main.rs and bot.rs

main.rs is now just the per-network connect/reconnect supervisor over the
library API. Unit tests move out of the source files into tests/ integration
tests (config.rs, protocol.rs) that drive the public surface — prefix parsing
is now covered through Message::parse, and config layering through the new
public config::parse_str. Behavior is unchanged; no new dependencies.
This commit is contained in:
Jean Chevronnet 2026-07-29 15:38:55 +00:00
parent b9e95430f5
commit a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions

115
src/bot/commands.rs Normal file
View file

@ -0,0 +1,115 @@
//! 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::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)
}
/// The command table. Add new bot commands here.
fn handle_command(
&mut self,
reply_to: &str,
sender: &str,
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)?;
}
"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(())
}
}
/// Channels start with one of the standard channel-type prefixes.
fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}