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:
parent
b9e95430f5
commit
a15fb4f9a9
16 changed files with 1189 additions and 1116 deletions
115
src/bot/commands.rs
Normal file
115
src/bot/commands.rs
Normal 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(['#', '&', '+', '!'])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue