From 33fbe7d5fc0ed1188337899a7e968e5db1504c63 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 16:24:30 +0000 Subject: [PATCH] Remove code comments Co-Authored-By: Claude Opus 4.8 --- src/bot/caps.rs | 23 +++------------- src/bot/commands.rs | 29 +------------------- src/bot/mod.rs | 35 +----------------------- src/bot/module.rs | 39 --------------------------- src/bot/modules/builtins.rs | 4 --- src/bot/modules/dice.rs | 13 +-------- src/bot/modules/mod.rs | 6 ----- src/config.rs | 54 ------------------------------------- src/irc/connection.rs | 31 +-------------------- src/irc/encoding.rs | 12 +-------- src/irc/message.rs | 29 ++------------------ src/irc/mod.rs | 17 +----------- src/lib.rs | 16 ----------- src/main.rs | 13 --------- tests/config.rs | 19 +++++-------- tests/modules.rs | 8 ------ tests/protocol.rs | 5 ---- 17 files changed, 18 insertions(+), 335 deletions(-) diff --git a/src/bot/caps.rs b/src/bot/caps.rs index 10ec67e..789f823 100644 --- a/src/bot/caps.rs +++ b/src/bot/caps.rs @@ -1,15 +1,8 @@ -//! IRCv3 capability negotiation and SASL PLAIN authentication. -//! -//! These are methods on [`Bot`]; they are `pub(super)` where the event loop in -//! [`super`] dispatches to them, and private otherwise. - use std::io; use super::Bot; use crate::irc::{base64_encode, Message}; -/// IRCv3 capabilities we request when the server offers them. `sasl` is added -/// separately, only when credentials are configured. const WANTED_CAPS: &[&str] = &[ "message-tags", "server-time", @@ -27,16 +20,11 @@ const WANTED_CAPS: &[&str] = &[ ]; impl Bot { - /// Handle a `CAP` reply: collect advertised caps, request the ones we want, - /// and record what was acknowledged. pub(super) fn handle_cap(&mut self, msg: &Message) -> io::Result<()> { - // :server CAP [*] : match msg.param(1) { Some("LS") => { - // A "*" in the third field means the list continues in more lines. let more = msg.param(2) == Some("*"); for cap in msg.trailing().unwrap_or("").split_whitespace() { - // Caps may be advertised as "name=value" (e.g. sasl=PLAIN). let name = cap.split('=').next().unwrap_or(cap); self.available_caps.insert(name.to_string()); } @@ -51,14 +39,12 @@ impl Bot { } self.after_cap_ack()?; } - Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on - _ => {} // NEW/DEL: ignored for now + Some("NAK") => self.conn.cap_end()?, + _ => {} } Ok(()) } - /// Request the subset of [`WANTED_CAPS`] the server actually offers, plus - /// `sasl` when credentials are configured. Ends negotiation if none apply. fn request_caps(&mut self) -> io::Result<()> { let mut wanted: Vec<&str> = WANTED_CAPS .iter() @@ -75,24 +61,21 @@ impl Bot { } } - /// After caps are acknowledged, either start SASL or finish negotiation. fn after_cap_ack(&mut self) -> io::Result<()> { if self.enabled_caps.contains("sasl") && self.sasl_credentials().is_some() && !self.sasl_started { self.sasl_started = true; - self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +" + self.conn.authenticate("PLAIN") } else { self.conn.cap_end() } } - /// Respond to the server's SASL prompt with base64 PLAIN credentials. pub(super) fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> { if msg.param(0) == Some("+") { if let Some((user, pass)) = self.sasl_credentials() { - // SASL PLAIN payload: authzid \0 authcid \0 passwd (authzid empty). let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes()); self.conn.authenticate(&encoded)?; } diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 7fff737..cd65995 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -1,24 +1,13 @@ -//! 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 @@ -28,9 +17,6 @@ impl Bot { } } - /// 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) { @@ -47,10 +33,7 @@ impl Bot { 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(()); } @@ -59,21 +42,16 @@ impl Bot { 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(()); }; @@ -84,8 +62,6 @@ impl Bot { 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, @@ -99,7 +75,7 @@ impl Bot { } let Some(&idx) = self.routes.get(command) else { - return Ok(()); // Unknown command: stay silent rather than spam channels. + return Ok(()); }; let cmd = Command { @@ -120,8 +96,6 @@ impl Bot { 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 { @@ -143,7 +117,6 @@ impl Bot { } } -/// Channels start with one of the standard channel-type prefixes. fn is_channel(target: &str) -> bool { target.starts_with(['#', '&', '+', '!']) } diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 69ae21b..8568669 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,11 +1,3 @@ -//! Bot behavior: the [`Bot`] state, registration, and the event loop. -//! -//! Behaviour is split across sibling modules: capability negotiation and SASL -//! live in [`caps`], and message routing in [`commands`]. Features live behind -//! the [`Module`] plugin trait ([`module`]) and are registered in [`modules`], -//! so adding a command is a new file in `bot/modules/` plus one line in -//! [`modules::all`] — no changes to the core dispatch. - use std::collections::{HashMap, HashSet}; use std::io; @@ -19,26 +11,16 @@ pub mod modules; pub use module::{Action, Command, CommandSpec, Module}; -/// The bot: owns its configuration, connection, and feature modules, and runs -/// one session. pub struct Bot { config: Config, conn: Connection, - /// The nick we are currently trying to use (may drift from `config.nick` - /// if the server reports a collision). current_nick: String, registered: bool, - /// Capabilities the server advertised in `CAP LS`. available_caps: HashSet, - /// Capabilities we successfully negotiated (`CAP ACK`). enabled_caps: HashSet, - /// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`). batches: HashMap, - /// Whether the SASL exchange has begun this session. sasl_started: bool, - /// Registered feature modules (a fresh set per network/session). modules: Vec>, - /// Command word -> index into `modules`, built from each module's specs. routes: HashMap<&'static str, usize>, } @@ -46,7 +28,6 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); - // Build the module set and the command -> module route table. let mods = modules::all(); let mut routes: HashMap<&'static str, usize> = HashMap::new(); for (i, m) in mods.iter().enumerate() { @@ -75,10 +56,6 @@ impl Bot { } } - /// Register with the server, then process messages until disconnect. - /// - /// Returns `Ok(())` when the server closes the connection cleanly; the - /// caller (the supervisor) decides whether to reconnect. pub fn run(&mut self) -> io::Result<()> { self.register()?; while let Some(msg) = self.conn.read_message()? { @@ -87,9 +64,6 @@ impl Bot { Ok(()) } - /// Begin registration: open IRCv3 capability negotiation, then send - /// NICK/USER. The server withholds `001` until we send `CAP END`, which - /// happens once caps (and optional SASL) are settled — see [`Bot::handle`]. fn register(&mut self) -> io::Result<()> { if let Some(password) = self.config.password.clone() { self.conn.pass(&password)?; @@ -102,30 +76,23 @@ impl Bot { Ok(()) } - /// Dispatch a single incoming message. fn handle(&mut self, msg: &Message) -> io::Result<()> { match msg.command.as_str() { - // Keep-alive: reply immediately or the server will time us out. "PING" => { let token = msg.trailing().unwrap_or("").to_string(); self.conn.pong(&token)?; } - // IRCv3 capability negotiation and SASL (see `caps`). "CAP" => self.handle_cap(msg)?, "AUTHENTICATE" => self.handle_authenticate(msg)?, - "903" => self.conn.cap_end()?, // RPL_SASLSUCCESS - // SASL failed/aborted/locked/too-long/already-authed — proceed anyway. + "903" => self.conn.cap_end()?, "902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?, - // Track open/closed batches so we can recognise replayed history. "BATCH" => self.handle_batch(msg), - // RPL_WELCOME: registration succeeded — now it's safe to join. "001" => { self.registered = true; for channel in self.config.channels.clone() { self.conn.join(&channel)?; } } - // ERR_NICKNAMEINUSE: append an underscore and try again. "433" => { self.current_nick.push('_'); let nick = self.current_nick.clone(); diff --git a/src/bot/module.rs b/src/bot/module.rs index 05677bf..c47f364 100644 --- a/src/bot/module.rs +++ b/src/bot/module.rs @@ -1,71 +1,32 @@ -//! The module (plugin) framework: the [`Module`] trait plus the [`Command`] -//! context it receives and the [`Action`]s it returns. -//! -//! Modules are pure in spirit: given a [`Command`], a module returns the -//! [`Action`]s to perform without ever touching the connection. That keeps them -//! trivially unit-testable and sidesteps borrow conflicts with the [`super::Bot`] -//! that owns them (a module never needs `&mut` access to the bot). - -/// Metadata describing one command a module provides. Used to route commands to -/// modules and to auto-generate `help`. #[derive(Debug, Clone, Copy)] pub struct CommandSpec { - /// The command word, lowercase, without the prefix (e.g. `"roll"`). pub name: &'static str, - /// Usage shown in help, without the prefix (e.g. `"roll [NdM]"`). pub usage: &'static str, - /// One-line description shown by `help `. pub about: &'static str, } -/// The context passed to a module for one command invocation. All fields borrow -/// from the current message and configuration. pub struct Command<'a> { - /// Nick of the sender. pub sender: &'a str, - /// Where a reply should go: the channel for channel messages, or the - /// sender's nick for private messages. pub reply_to: &'a str, - /// The command word (lowercased, prefix stripped). pub name: &'a str, - /// Arguments after the command word (whitespace-split). pub args: &'a [&'a str], - /// The configured command prefix (e.g. `">"`), for building help text. pub prefix: &'a str, - /// The network's name (`Config::name`), for network-aware modules. pub network: &'a str, } -/// Something a module asks the bot to do in response to a command. Modules -/// return these instead of performing I/O themselves. pub enum Action { - /// Send a `PRIVMSG` back to [`Command::reply_to`]. Reply(String), - /// Send an arbitrary raw IRC line (for power modules, e.g. an admin module). Raw(String), } impl Action { - /// Convenience constructor for a reply from anything string-like. pub fn reply(text: impl Into) -> Action { Action::Reply(text.into()) } } -/// A bot feature: a self-contained unit that declares the commands it handles -/// and turns each invocation into [`Action`]s. -/// -/// Add one by creating a file in `bot/modules/` that implements this trait and -/// listing its constructor in [`super::modules::all`]. pub trait Module { - /// Short identifier for logs and diagnostics (e.g. `"dice"`). fn name(&self) -> &'static str; - - /// The commands this module provides. The registry routes matching command - /// words here and builds `help` from these specs. fn commands(&self) -> &'static [CommandSpec]; - - /// Handle one command (only ever called for a name in [`Module::commands`]). - /// Return the actions to perform; an empty vec does nothing. fn on_command(&mut self, cmd: &Command) -> Vec; } diff --git a/src/bot/modules/builtins.rs b/src/bot/modules/builtins.rs index c21a068..2626f94 100644 --- a/src/bot/modules/builtins.rs +++ b/src/bot/modules/builtins.rs @@ -1,9 +1,5 @@ -//! The built-in commands: `ping`, `echo`, `hello`. (`help` is provided by the -//! dispatcher itself, since it aggregates every module's commands.) - use crate::bot::module::{Action, Command, CommandSpec, Module}; -/// Core commands that ship with the bot. pub struct Builtins; const COMMANDS: &[CommandSpec] = &[ diff --git a/src/bot/modules/dice.rs b/src/bot/modules/dice.rs index 33ad826..7baf417 100644 --- a/src/bot/modules/dice.rs +++ b/src/bot/modules/dice.rs @@ -1,8 +1,3 @@ -//! A dice roller: `roll 2d6`, `roll d20`, or `roll` (defaults to `1d6`). -//! -//! Demonstrates a *stateful* module — it carries its own tiny PRNG, seeded once -//! at construction — while keeping the crate dependency-free (no `rand`). - use std::time::{SystemTime, UNIX_EPOCH}; use crate::bot::module::{Action, Command, CommandSpec, Module}; @@ -13,12 +8,9 @@ const COMMANDS: &[CommandSpec] = &[CommandSpec { about: "roll N dice of M sides (default 1d6), e.g. roll 2d20", }]; -/// Upper bounds to keep output (and the channel) sane. const MAX_DICE: u64 = 100; const MAX_SIDES: u64 = 1000; -/// Dice roller with a self-contained xorshift PRNG — fine for dice, not for -/// anything security-sensitive. pub struct Dice { rng: u64, } @@ -29,7 +21,7 @@ impl Dice { .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0x9E37_79B9_7F4A_7C15); - Dice { rng: seed | 1 } // never seed to 0 (xorshift would stay stuck) + Dice { rng: seed | 1 } } fn next_u64(&mut self) -> u64 { @@ -41,7 +33,6 @@ impl Dice { x } - /// Roll a single die in `1..=sides`. fn die(&mut self, sides: u64) -> u64 { self.next_u64() % sides + 1 } @@ -81,8 +72,6 @@ impl Module for Dice { } } -/// Parse `NdM` / `dM` / `M` into `(count, sides)` within sane bounds. A bare -/// number `M` means `1dM`. fn parse_spec(s: &str) -> Option<(u64, u64)> { let (count, sides) = match s.split_once('d') { Some((c, m)) => { diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index d6f613a..9dd67f5 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,14 +1,8 @@ -//! The registry of feature modules. Add a feature by creating a file here that -//! implements [`super::module::Module`] and listing its constructor in [`all`]. - pub mod builtins; pub mod dice; use super::module::Module; -/// Construct the default set of modules, in priority order (earlier modules win -/// command-name collisions). Each network's [`super::Bot`] gets its own fresh -/// set, so stateful modules keep independent per-network state. pub fn all() -> Vec> { vec![ Box::new(builtins::Builtins), diff --git a/src/config.rs b/src/config.rs index c93cf61..1c7011c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,33 +1,20 @@ -//! Configuration: the [`Config`] one network needs, and how a config file -//! (with optional `[network]` sections) plus environment variables are layered -//! into one [`Config`] per network. - use std::fs; use std::path::PathBuf; use crate::log; -/// Everything the bot needs to know to connect to one network and behave. #[derive(Debug, Clone)] pub struct Config { - /// Short label for this network, used in logs and as the thread name. Set - /// via an INI-style `[section]` header (or a `name =` key); defaults to the - /// server host. Only one network? It stays out of the logs entirely. pub name: String, pub server: String, pub port: u16, - /// Connect over TLS (typically port 6697) instead of plaintext. pub tls: bool, pub nick: String, pub user: String, pub realname: String, pub channels: Vec, - /// Prefix that marks a chat message as a bot command, e.g. `!`. pub command_prefix: String, - /// Optional server password (`PASS`). Not the same as SASL/NickServ. pub password: Option, - /// SASL PLAIN credentials. When both are set (and the server offers `sasl`), - /// the bot authenticates during capability negotiation. pub sasl_user: Option, pub sasl_pass: Option, } @@ -51,19 +38,8 @@ impl Default for Config { } } -/// A parsed `key = value` pair with its 1-based source line, for diagnostics. type Entry = (usize, String, String); -/// Build one [`Config`] per network by layering sources. -/// -/// The config file is INI-flavoured: `key = value` lines, optionally grouped -/// under `[network]` section headers. Keys *before* the first header are shared -/// defaults; each `[section]` defines one network that inherits those defaults -/// and overrides them. A file with **no** sections yields a single network — -/// exactly the historical behaviour. -/// -/// Precedence within a network: built-in defaults < shared file defaults < -/// environment variables < that network's section. pub fn load() -> Vec { let path = config_path(); let content = path.as_ref().and_then(|p| match fs::read_to_string(p) { @@ -82,8 +58,6 @@ pub fn load() -> Vec { None => (Vec::new(), Vec::new()), }; - // Shared defaults: built-ins, then top-level keys, then environment. Each - // network is derived from this base. let mut base = Config::default(); for (line, key, value) in &toplevel { apply_kv(&mut base, key, value, &loc(&path, *line)); @@ -93,8 +67,6 @@ pub fn load() -> Vec { assemble(base, sections, &path) } -/// Parse networks from config *text* alone — no file access, no environment. -/// Handy for tests and for embedding rustbot. Warnings reference `line N`. pub fn parse_str(content: &str) -> Vec { let (toplevel, sections) = parse_sections(content); let mut base = Config::default(); @@ -104,9 +76,6 @@ pub fn parse_str(content: &str) -> Vec { assemble(base, sections, &None) } -/// Derive one finished [`Config`] per network from the shared `base` and the -/// parsed sections. With no sections, `base` itself is the single network. -/// `path` is used only to describe locations in warnings. fn assemble(base: Config, sections: Vec<(String, Vec)>, path: &Option) -> Vec { let mut configs = Vec::new(); if sections.is_empty() { @@ -127,9 +96,6 @@ fn assemble(base: Config, sections: Vec<(String, Vec)>, path: &Option (Vec, Vec<(String, Vec)>) { let mut toplevel: Vec = Vec::new(); let mut sections: Vec<(String, Vec)> = Vec::new(); @@ -155,7 +115,6 @@ fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { if line.is_empty() || line.starts_with('#') || line.starts_with(';') { continue; } - // Section header: `[name]`. if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { sections.push((inner.trim().to_string(), Vec::new())); continue; @@ -173,8 +132,6 @@ fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { (toplevel, sections) } -/// Apply a single `key = value` pair onto `c`. `where_` describes the source -/// location for warnings (e.g. `rustbot.conf:12`). fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { match key { "name" => c.name = value.to_string(), @@ -196,7 +153,6 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { } } -/// Format a source location for a line, including the file path when known. fn loc(path: &Option, line: usize) -> String { match path { Some(p) => format!("{}:{}", p.display(), line), @@ -204,8 +160,6 @@ fn loc(path: &Option, line: usize) -> String { } } -/// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then -/// `./rustbot.conf` if it happens to exist. Returns `None` when there is none. fn config_path() -> Option { if let Some(arg) = std::env::args().nth(1) { return Some(PathBuf::from(arg)); @@ -217,12 +171,6 @@ fn config_path() -> Option { default.exists().then_some(default) } -/// Overlay environment variables onto `c` (applied to the shared defaults, so -/// they affect every network unless a `[section]` overrides them). -/// -/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`, -/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`, -/// `IRC_SASL_USER`, `IRC_SASL_PASS`. fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_SERVER") { c.server = v; @@ -257,7 +205,6 @@ fn apply_env(c: &mut Config) { } } -/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case). fn parse_bool(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), @@ -265,7 +212,6 @@ fn parse_bool(value: &str) -> bool { ) } -/// Split a channel list on commas and/or whitespace, dropping empties. fn split_list(value: &str) -> Vec { value .split(|ch: char| ch == ',' || ch.is_whitespace()) diff --git a/src/irc/connection.rs b/src/irc/connection.rs index 8e17600..8c46ba6 100644 --- a/src/irc/connection.rs +++ b/src/irc/connection.rs @@ -1,6 +1,3 @@ -//! The blocking IRC transport: a plain-TCP or TLS byte stream wrapped with -//! line reading and convenience senders. - use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -8,29 +5,15 @@ use native_tls::TlsConnector; use super::Message; -/// Any bidirectional byte stream: a plain `TcpStream` or a TLS session. The -/// blanket impl covers both, so [`Connection`] can treat them uniformly. trait Stream: Read + Write {} impl Stream for T {} -/// A blocking IRC connection over plain TCP or TLS. -/// -/// The event loop is single-threaded (read a message, handle it, maybe write a -/// reply, repeat), so one stream serves both directions: reads go through the -/// `BufReader`, writes through `get_mut()`. This is what lets a single TLS -/// session — which cannot be cloned into independent halves — work unchanged. pub struct Connection { inner: BufReader>, - /// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay - /// distinguishable. Empty for a single network, keeping its output terse. log_prefix: String, } impl Connection { - /// Connect to `host:port`, optionally wrapping the socket in TLS. - /// - /// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host` - /// supplied as the SNI name and for certificate verification. pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result { let tcp = TcpStream::connect((host, port))?; let stream: Box = if use_tls { @@ -46,9 +29,6 @@ impl Connection { Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() }) } - /// Set a short label prepended to this connection's wire logs, so output - /// from multiple simultaneous networks stays distinguishable. An empty - /// label clears it (the terse single-network default). pub fn set_label(&mut self, label: &str) { self.log_prefix = if label.is_empty() { String::new() @@ -57,13 +37,11 @@ impl Connection { }; } - /// Read and parse the next message. Returns `Ok(None)` on a clean EOF - /// (the server closed the connection). Blank/unparseable lines are skipped. pub fn read_message(&mut self) -> io::Result> { loop { let mut line = String::new(); if self.inner.read_line(&mut line)? == 0 { - return Ok(None); // EOF + return Ok(None); } let trimmed = line.trim_end_matches(['\r', '\n']); if trimmed.is_empty() { @@ -73,12 +51,9 @@ impl Connection { if let Some(msg) = Message::parse(trimmed) { return Ok(Some(msg)); } - // Unparseable line: skip and keep reading. } } - /// Send one raw, pre-formatted IRC line. The CRLF terminator is added here; - /// any embedded CR/LF are neutralised to prevent command injection. pub fn send_raw(&mut self, line: &str) -> io::Result<()> { let clean = line.replace(['\r', '\n'], " "); eprintln!("{}>> {clean}", self.log_prefix); @@ -87,8 +62,6 @@ impl Connection { writer.flush() } - // --- Convenience senders ------------------------------------------------- - pub fn pass(&mut self, password: &str) -> io::Result<()> { self.send_raw(&format!("PASS {password}")) } @@ -121,8 +94,6 @@ impl Connection { self.send_raw(&format!("QUIT :{reason}")) } - // --- IRCv3 capability negotiation --------------------------------------- - pub fn cap_ls(&mut self) -> io::Result<()> { self.send_raw("CAP LS 302") } diff --git a/src/irc/encoding.rs b/src/irc/encoding.rs index b65d69b..5fc0587 100644 --- a/src/irc/encoding.rs +++ b/src/irc/encoding.rs @@ -1,10 +1,5 @@ -//! Small self-contained helpers: base64 (for SASL) and `server-time` parsing. -//! Kept dependency-free so the protocol layer needs nothing but TLS. - use std::time::{SystemTime, UNIX_EPOCH}; -/// Base64-encode bytes (standard alphabet, padded). Used for SASL PLAIN so we -/// stay dependency-free. pub fn base64_encode(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -22,7 +17,6 @@ pub fn base64_encode(input: &[u8]) -> String { out } -/// Current Unix time in whole seconds (0 if the clock predates the epoch). pub fn now_unix() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -30,11 +24,9 @@ pub fn now_unix() -> u64 { .unwrap_or(0) } -/// Parse an IRCv3 `server-time` tag (`YYYY-MM-DDThh:mm:ss[.fff]Z`) into Unix -/// seconds. Returns `None` if the value isn't in the expected UTC format. pub fn parse_server_time(s: &str) -> Option { let (date, time) = s.split_once('T')?; - let time = time.trim_end_matches('Z').split('.').next()?; // drop fractional secs + let time = time.trim_end_matches('Z').split('.').next()?; let mut d = date.splitn(3, '-'); let year: i64 = d.next()?.parse().ok()?; let month: i64 = d.next()?.parse().ok()?; @@ -47,8 +39,6 @@ pub fn parse_server_time(s: &str) -> Option { u64::try_from(secs).ok() } -/// Days since the Unix epoch for a proleptic-Gregorian date. -/// Howard Hinnant's algorithm: fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = (if y >= 0 { y } else { y - 399 }) / 400; diff --git a/src/irc/message.rs b/src/irc/message.rs index 57f9f3a..db92eec 100644 --- a/src/irc/message.rs +++ b/src/irc/message.rs @@ -1,11 +1,7 @@ -//! Parsing of the IRC wire format into [`Message`] / [`Prefix`] values. - use std::collections::HashMap; -/// The source of a message: `nick!user@host`, or a bare nick / server name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Prefix { - /// The full, unparsed prefix as received. pub raw: String, pub nick: Option, pub user: Option, @@ -13,7 +9,6 @@ pub struct Prefix { } impl Prefix { - /// Parse a prefix per the grammar `servername / (nick [ [ "!" user ] "@" host ])`. fn parse(raw: &str) -> Prefix { let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') { match rest.split_once('@') { @@ -23,7 +18,6 @@ impl Prefix { } else if let Some((n, h)) = raw.split_once('@') { (Some(n), None, Some(h)) } else { - // A bare nick or a server name — keep it as the nick for convenience. (Some(raw), None, None) }; @@ -35,36 +29,26 @@ impl Prefix { } } - /// Best display name for the sender: the nick if we have one, else the raw prefix. pub fn name(&self) -> &str { self.nick.as_deref().unwrap_or(&self.raw) } } -/// A parsed IRC message. #[derive(Debug, Clone)] pub struct Message { - /// IRCv3 message tags (already unescaped). Empty when none were present. pub tags: HashMap, - /// The message source, if the server included one. pub prefix: Option, - /// The command, upper-cased (e.g. `PRIVMSG`) or a 3-digit numeric (e.g. `001`). pub command: String, - /// Command parameters; the last one may be a "trailing" value containing spaces. pub params: Vec, } impl Message { - /// Parse a single line of IRC (the trailing CRLF may be present or not). - /// - /// Returns `None` for blank or structurally invalid lines. pub fn parse(line: &str) -> Option { let mut rest = line.trim_end_matches(['\r', '\n']).trim_start(); if rest.is_empty() { return None; } - // 1. Tags: `@key=value;key2 ` (optional, space-terminated). let mut tags = HashMap::new(); if let Some(stripped) = rest.strip_prefix('@') { let (tag_str, r) = stripped.split_once(' ')?; @@ -77,7 +61,6 @@ impl Message { } } - // 2. Source / prefix: `:nick!user@host ` (optional, space-terminated). let mut prefix = None; if let Some(stripped) = rest.strip_prefix(':') { let (p, r) = stripped.split_once(' ')?; @@ -85,7 +68,6 @@ impl Message { rest = r.trim_start(); } - // 3. Command (required). let (command, mut rest) = match rest.split_once(' ') { Some((c, r)) => (c, r.trim_start()), None => (rest, ""), @@ -94,8 +76,6 @@ impl Message { return None; } - // 4. Parameters. A parameter beginning with ':' is the trailing param and - // consumes the remainder of the line verbatim (spaces included). let mut params = Vec::new(); while !rest.is_empty() { if let Some(trailing) = rest.strip_prefix(':') { @@ -124,28 +104,23 @@ impl Message { }) } - /// Get the parameter at `index`, if present. pub fn param(&self, index: usize) -> Option<&str> { self.params.get(index).map(String::as_str) } - /// The last parameter — for `PRIVMSG`/`NOTICE`/`PING` this is the message text. pub fn trailing(&self) -> Option<&str> { self.params.last().map(String::as_str) } - /// The sender's nick, if the message carried a `nick!user@host` prefix. pub fn nick(&self) -> Option<&str> { self.prefix.as_ref().and_then(|p| p.nick.as_deref()) } - /// The value of an IRCv3 message tag (e.g. `time`, `batch`), if present. pub fn tag(&self, key: &str) -> Option<&str> { self.tags.get(key).map(String::as_str) } } -/// Unescape an IRCv3 tag value per . fn unescape_tag_value(value: &str) -> String { let mut out = String::with_capacity(value.len()); let mut chars = value.chars(); @@ -160,8 +135,8 @@ fn unescape_tag_value(value: &str) -> String { Some('\\') => out.push('\\'), Some('r') => out.push('\r'), Some('n') => out.push('\n'), - Some(other) => out.push(other), // unknown escape: drop the backslash - None => {} // trailing backslash: ignore + Some(other) => out.push(other), + None => {} } } out diff --git a/src/irc/mod.rs b/src/irc/mod.rs index eb0c747..2bf2002 100644 --- a/src/irc/mod.rs +++ b/src/irc/mod.rs @@ -1,19 +1,4 @@ -//! Minimal IRC protocol layer — the "IRC library". -//! -//! Implements just enough of the modern IRC client protocol -//! () to drive a bot: -//! -//! * [`Message`] / [`Prefix`] — parsing of the wire format -//! `@tags :source COMMAND params`, including IRCv3 message tags and -//! trailing parameters ([`message`]). -//! * [`Connection`] — a blocking TCP/TLS transport with convenience senders -//! ([`connection`]). -//! * Small self-contained helpers — base64 and `server-time` parsing -//! ([`encoding`]) — so the layer stays dependency-free apart from TLS. -//! -//! It is deliberately self-contained, so it can grow into a small reusable IRC -//! library independent of the bot logic in [`crate::bot`]. -#![allow(dead_code)] // Library-style module; not every helper/sender is used yet. +#![allow(dead_code)] mod connection; mod encoding; diff --git a/src/lib.rs b/src/lib.rs index 1a953a8..70bc600 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,7 @@ -//! rustbot — a small, modular IRC bot, split into a reusable library (this -//! crate) and a thin binary (`src/main.rs`). -//! -//! Modules: -//! * [`irc`] — protocol + transport (the "IRC library"): message parsing, -//! IRCv3 tags, and a blocking TCP/TLS [`irc::Connection`]. -//! * [`bot`] — behavior: registration, capability negotiation, the event -//! loop, and commands, driving one [`irc::Connection`]. -//! * [`config`] — the layered [`config::Config`] and how it is loaded from a -//! file (with optional `[network]` sections) and environment. -//! -//! The binary adds only a per-network connect/reconnect supervisor on top, so -//! the whole bot is exercisable — and reusable — through this library's API. - pub mod bot; pub mod config; pub mod irc; -/// Tiny stderr logger shared by the library and the binary. Swap for the -/// `log`/`tracing` crates if you outgrow it. pub fn log(msg: &str) { eprintln!("[rustbot] {msg}"); } diff --git a/src/main.rs b/src/main.rs index ae2375c..2782d81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,3 @@ -//! rustbot binary — a per-network connect/reconnect supervisor built on the -//! `rustbot` library. All the real logic lives in the library -//! (`config`, `irc`, `bot`); this file only spawns and keeps sessions alive. - use std::thread; use std::time::Duration; @@ -24,10 +20,6 @@ fn main() { names.join(", ") )); - // One supervisor thread per network. Each keeps its own connection alive - // with independent reconnect/backoff, so a failure on one network never - // disturbs the others. Labels appear in the logs only when there is more - // than one network, keeping single-network output terse and unchanged. let labelled = configs.len() > 1; let mut handles = Vec::new(); for config in configs { @@ -43,9 +35,6 @@ fn main() { } } -/// Keep one network's bot alive across disconnects, with capped exponential -/// backoff. Runs until the process is killed. When `labelled`, log lines are -/// tagged with the network name so multiple networks stay distinguishable. fn supervise(config: Config, labelled: bool) { let tag = if labelled { format!("[{}] ", config.name) @@ -71,8 +60,6 @@ fn supervise(config: Config, labelled: bool) { } } -/// Run a single connection lifecycle for one network: connect, register, loop -/// until disconnect. fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> { let mut conn = Connection::connect(&config.server, config.port, config.tls)?; if labelled { diff --git a/tests/config.rs b/tests/config.rs index ce5237c..39112f1 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,3 @@ -//! Integration tests for config layering and `[network]` sections, exercising -//! the public `rustbot::config::parse_str` (no filesystem or environment). - use rustbot::config::parse_str; #[test] @@ -10,7 +7,6 @@ fn flat_config_is_a_single_network() { assert_eq!(cfgs[0].server, "irc.example.org"); assert_eq!(cfgs[0].nick, "flatbot"); assert_eq!(cfgs[0].channels, vec!["#a", "#b"]); - // The label defaults to the server host. assert_eq!(cfgs[0].name, "irc.example.org"); } @@ -26,17 +22,17 @@ fn sections_inherit_top_level_defaults_and_override() { let libera = &cfgs[0]; assert_eq!(libera.name, "libera"); assert_eq!(libera.server, "irc.libera.chat"); - assert_eq!(libera.nick, "shared"); // inherited - assert_eq!(libera.channels, vec!["#lobby"]); // inherited + assert_eq!(libera.nick, "shared"); + assert_eq!(libera.channels, vec!["#lobby"]); assert!(libera.tls); - assert_eq!(libera.port, 6697); // TLS-port convention applied per network + assert_eq!(libera.port, 6697); let oftc = &cfgs[1]; assert_eq!(oftc.name, "oftc"); assert_eq!(oftc.server, "irc.oftc.net"); - assert_eq!(oftc.nick, "other"); // overridden - assert_eq!(oftc.channels, vec!["#oftc"]); // overridden - assert!(!oftc.tls); // default + assert_eq!(oftc.nick, "other"); + assert_eq!(oftc.channels, vec!["#oftc"]); + assert!(!oftc.tls); } #[test] @@ -48,8 +44,7 @@ fn a_section_may_rename_itself() { #[test] fn comments_and_blanks_are_ignored() { - // A lone commented/blank file yields the built-in default single network. let cfgs = parse_str("# comment\n; also\n\n \n"); assert_eq!(cfgs.len(), 1); - assert_eq!(cfgs[0].server, "irc.tchatou.fr"); // built-in default + assert_eq!(cfgs[0].server, "irc.tchatou.fr"); } diff --git a/tests/modules.rs b/tests/modules.rs index d15021d..b202e46 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -1,7 +1,3 @@ -//! Unit tests for feature modules — constructed directly and driven through the -//! public `Module` trait, with no network. This is the payoff of the module -//! system: features are testable in isolation. - use rustbot::bot::module::{Action, Command, Module}; use rustbot::bot::modules::{builtins::Builtins, dice::Dice}; @@ -9,7 +5,6 @@ fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" } } -/// Assert the module produced exactly one reply and return its text. fn reply(actions: &[Action]) -> &str { match actions { [Action::Reply(s)] => s, @@ -47,7 +42,6 @@ fn dice_2d6_total_is_in_range() { let mut d = Dice::new(); for _ in 0..300 { let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string(); - // Format: "2d6: a + b = total" let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap(); assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}"); } @@ -57,7 +51,6 @@ fn dice_2d6_total_is_in_range() { fn dice_defaults_to_1d6() { let mut d = Dice::new(); let r = reply(&d.on_command(&cmd("roll", &[]))).to_string(); - // Format: "1d6: N" assert!(r.starts_with("1d6: "), "unexpected format: {r:?}"); let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap(); assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}"); @@ -81,7 +74,6 @@ fn dice_rejects_garbage_with_usage() { #[test] fn dice_rejects_out_of_bounds() { let mut d = Dice::new(); - // 999 dice exceeds the cap; 1-sided die is below the minimum. assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage")); assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage")); } diff --git a/tests/protocol.rs b/tests/protocol.rs index a846528..c25ad0a 100644 --- a/tests/protocol.rs +++ b/tests/protocol.rs @@ -1,6 +1,3 @@ -//! Integration tests for the IRC protocol layer, exercising the public -//! `rustbot::irc` API: message parsing, IRCv3 tags, base64, and server-time. - use rustbot::irc::{base64_encode, parse_server_time, Message}; #[test] @@ -39,7 +36,6 @@ fn parses_numeric_reply() { #[test] fn full_prefix_is_split() { - // Prefix parsing is exercised through the public `Message::parse`. let m = Message::parse(":nick!user@host.example PRIVMSG #c :hi").unwrap(); let p = m.prefix.expect("prefix present"); assert_eq!(p.nick.as_deref(), Some("nick")); @@ -59,7 +55,6 @@ fn base64_matches_known_vectors() { assert_eq!(base64_encode(b"f"), "Zg=="); assert_eq!(base64_encode(b"fo"), "Zm8="); assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - // A SASL PLAIN payload: authzid="" authcid="me" passwd="pw" assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3"); }