From ab02a5436e0a5efb4d38a954e15726e59d035747 Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 28 Jul 2026 14:57:44 +0000 Subject: [PATCH 01/11] Add TLS transport support Connect over TLS (default port 6697) using native-tls with the system OpenSSL trust store for certificate verification. Unify the plaintext and TLS transports behind a single Box, dropping the read/write socket clone that a TLS session cannot support. Add a `tls` config key and IRC_TLS override; the port auto-defaults to 6697 when TLS is enabled. --- Cargo.toml | 7 +++++-- README.md | 23 +++++++++++++++++------ src/bot.rs | 3 +++ src/irc.rs | 53 ++++++++++++++++++++++++++++++++++------------------- src/main.rs | 20 +++++++++++++++++++- 5 files changed, 78 insertions(+), 28 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2d66b3c..8ca62a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,12 @@ authors = ["a.srenov"] name = "rustbot" path = "src/main.rs" -# Intentionally dependency-free. The IRC protocol layer lives in `src/irc.rs` -# and follows the modern IRC client spec: https://modern.ircdocs.horse/ +# The IRC protocol layer is hand-rolled in `src/irc.rs` (modern IRC client +# spec: https://modern.ircdocs.horse/). The sole dependency is native-tls for +# the optional TLS transport; it wraps the system OpenSSL, so TLS security +# fixes arrive through normal OS updates rather than a vendored crypto crate. [dependencies] +native-tls = "0.2" [profile.release] lto = true diff --git a/README.md b/README.md index 4df5d4a..2715f92 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,13 @@ cargo run -- /etc/rustbot.conf # explicit path RUSTBOT_CONFIG=/etc/rustbot.conf cargo run ``` -Config file keys: `server`, `port`, `nick`, `user`, `realname`, -`channels` (comma/space-separated), `prefix`, `password`. +Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, +`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`. Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. -Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`, -`IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`. +Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, +`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`, +`IRC_SASL_USER`, `IRC_SASL_PASS`. ```sh IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run @@ -101,9 +102,19 @@ Two of these do real work: `sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`). base64 is hand-rolled, so this stays dependency-free. +## TLS + +Set `tls = true` and the port defaults to 6697. The transport wraps the socket +with `native-tls`, which verifies certificates against the **system OpenSSL +trust store** — so TLS security fixes arrive via OS updates rather than a +vendored crypto crate. Plaintext still works with `tls = false` / `port = 6667`. + +Plain TCP and TLS share one code path: `Connection` holds a single +`Box`, since the single-threaded event loop never needs the +old read/write socket split (which TLS can't do anyway). + ## Roadmap / natural next steps -- **TLS** (port 6697, or the `tls` STARTTLS cap) — the one remaining item that - needs a dependency (`rustls`/`native-tls`). - **Read timeout + client-sent PING** to detect dead connections faster. +- **STARTTLS** (the `tls` cap) for networks without a dedicated TLS port. - Split commands into a registry/trait once there are many of them. diff --git a/src/bot.rs b/src/bot.rs index 2cdf7fa..929fdb9 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -37,6 +37,8 @@ const HISTORY_GRACE_SECS: u64 = 60; pub struct Config { 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, @@ -56,6 +58,7 @@ impl Default for Config { Config { server: "irc.tchatou.fr".to_string(), port: 6667, + tls: false, nick: "rubot".to_string(), user: "rubot".to_string(), realname: "rubot".to_string(), diff --git a/src/irc.rs b/src/irc.rs index 33f3c7e..e35ff58 100644 --- a/src/irc.rs +++ b/src/irc.rs @@ -13,10 +13,12 @@ #![allow(dead_code)] // This is a library-style module; not every helper is used yet. use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Write}; +use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::time::{SystemTime, UNIX_EPOCH}; +use native_tls::TlsConnector; + /// The source of a message: `nick!user@host`, or a bare nick / server name. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Prefix { @@ -182,27 +184,39 @@ fn unescape_tag_value(value: &str) -> String { out } -/// A blocking IRC connection over plain TCP. +/// 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. /// -/// Reading and writing use separate clones of the same socket, so the event -/// loop can block on `read_message` while senders write independently. +/// 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 { - reader: BufReader, - writer: TcpStream, + inner: BufReader>, } impl Connection { - /// Open a plaintext TCP connection to `host:port`. + /// Connect to `host:port`, optionally wrapping the socket in TLS. /// - /// Note: this is cleartext (typically port 6667). TLS (port 6697) would be a - /// natural next step by wrapping the stream in `rustls`/`native-tls`. - pub fn connect(host: &str, port: u16) -> io::Result { - let stream = TcpStream::connect((host, port))?; - let writer = stream.try_clone()?; - Ok(Connection { - reader: BufReader::new(stream), - writer, - }) + /// 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 { + let connector = + TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?; + let tls = connector + .connect(host, tcp) + .map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?; + Box::new(tls) + } else { + Box::new(tcp) + }; + Ok(Connection { inner: BufReader::new(stream) }) } /// Read and parse the next message. Returns `Ok(None)` on a clean EOF @@ -210,7 +224,7 @@ impl Connection { pub fn read_message(&mut self) -> io::Result> { loop { let mut line = String::new(); - if self.reader.read_line(&mut line)? == 0 { + if self.inner.read_line(&mut line)? == 0 { return Ok(None); // EOF } let trimmed = line.trim_end_matches(['\r', '\n']); @@ -230,8 +244,9 @@ impl Connection { pub fn send_raw(&mut self, line: &str) -> io::Result<()> { let clean = line.replace(['\r', '\n'], " "); eprintln!(">> {clean}"); - write!(self.writer, "{clean}\r\n")?; - self.writer.flush() + let writer = self.inner.get_mut(); + write!(writer, "{clean}\r\n")?; + writer.flush() } // --- Convenience senders ------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index 8ea097f..0224a1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,7 +44,7 @@ fn main() { /// Run a single connection lifecycle: connect, register, loop until disconnect. fn run_once(config: &Config) -> std::io::Result<()> { - let conn = Connection::connect(&config.server, config.port)?; + let conn = Connection::connect(&config.server, config.port, config.tls)?; let mut bot = Bot::new(config.clone(), conn); bot.run() } @@ -62,6 +62,12 @@ fn load_config() -> Config { } apply_env(&mut c); + // Convention: TLS listens on 6697. If TLS is enabled but the port is still + // the plaintext default, switch to the conventional TLS port. + if c.tls && c.port == 6667 { + c.port = 6697; + } + c } @@ -101,6 +107,7 @@ fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> { Ok(p) => c.port = p, Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)), }, + "tls" => c.tls = parse_bool(value), "nick" => c.nick = value.to_string(), "user" => c.user = value.to_string(), "realname" => c.realname = value.to_string(), @@ -127,6 +134,9 @@ fn apply_env(c: &mut Config) { if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) { c.port = p; } + if let Ok(v) = std::env::var("IRC_TLS") { + c.tls = parse_bool(&v); + } if let Ok(v) = std::env::var("IRC_NICK") { c.user = v.clone(); c.nick = v; @@ -151,6 +161,14 @@ 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(), + "1" | "true" | "yes" | "on" + ) +} + /// Split a channel list on commas and/or whitespace, dropping empties. fn split_list(value: &str) -> Vec { value From b9e95430f5d7db38dd10914e1fd41c6117e7210b Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 28 Jul 2026 16:12:57 +0000 Subject: [PATCH 02/11] Add multi-network support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect to several IRC networks at once. The config file gains optional `[network]` section headers: keys before the first header are shared defaults, and each section defines one network that inherits and overrides them. A file with no sections is a single network exactly as before, so existing configs are unaffected. main spawns one supervisor thread per network, each with its own connect/reconnect/backoff loop, so a failure on one network never disturbs the others. The blocking Bot/Connection are reused unchanged — no async runtime, no new dependencies. With more than one network, wire logs are tagged with the section name; a lone network stays unprefixed. --- README.md | 35 +++++- src/bot.rs | 5 + src/irc.rs | 20 +++- src/main.rs | 332 +++++++++++++++++++++++++++++++++++++++++----------- 4 files changed, 318 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 2715f92..3a164d0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors |---------------|------------------|-----------------------------------------------------------| | `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport | | `src/bot.rs` | behavior | Registration, event loop, command handling | -| `src/main.rs` | wiring | Config from env vars, connect/reconnect supervisor | +| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network | The layers are deliberately separable: `irc.rs` knows nothing about the bot, and `bot.rs` knows nothing about how the process is launched. @@ -40,8 +40,37 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run ``` Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, -`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`. -Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. +`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, +`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments +only — inline comments would clash with channel `#`s. + +### Multiple networks + +Group keys under `[name]` section headers to connect to several networks at +once — one supervisor thread each, with independent reconnect/backoff. Keys +*before* the first header are shared defaults every network inherits; each +section overrides them. A file with no headers is a single network (the +historical behaviour), and its logs stay unprefixed. + +```ini +# shared defaults, inherited by every network below +nick = rubot +realname = rubot + +[tchatou] +server = irc.tchatou.fr +tls = true +channels = #devs + +[libera] +server = irc.libera.chat +tls = true +channels = #rust, #rust-beginners +nick = rubot_ # override just for this network +``` + +With more than one network, each network's log lines are tagged with its +section name (`[libera] << …`) so the interleaved output stays readable. Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`, diff --git a/src/bot.rs b/src/bot.rs index 929fdb9..ba1ac21 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -35,6 +35,10 @@ const HISTORY_GRACE_SECS: u64 = 60; /// Everything the bot needs to know to connect 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. @@ -56,6 +60,7 @@ pub struct Config { impl Default for Config { fn default() -> Config { Config { + name: String::new(), server: "irc.tchatou.fr".to_string(), port: 6667, tls: false, diff --git a/src/irc.rs b/src/irc.rs index e35ff58..a525ed4 100644 --- a/src/irc.rs +++ b/src/irc.rs @@ -197,6 +197,9 @@ impl Stream for T {} /// 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 { @@ -216,7 +219,18 @@ impl Connection { } else { Box::new(tcp) }; - Ok(Connection { inner: BufReader::new(stream) }) + 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() + } else { + format!("[{label}] ") + }; } /// Read and parse the next message. Returns `Ok(None)` on a clean EOF @@ -231,7 +245,7 @@ impl Connection { if trimmed.is_empty() { continue; } - eprintln!("<< {trimmed}"); + eprintln!("{}<< {trimmed}", self.log_prefix); if let Some(msg) = Message::parse(trimmed) { return Ok(Some(msg)); } @@ -243,7 +257,7 @@ impl Connection { /// 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}"); + eprintln!("{}>> {clean}", self.log_prefix); let writer = self.inner.get_mut(); write!(writer, "{clean}\r\n")?; writer.flush() diff --git a/src/main.rs b/src/main.rs index 0224a1b..8eec2bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,68 +7,233 @@ //! //! Configuration is layered (see [`load_config`]): built-in defaults, then a //! `key = value` config file, then environment variables — so the same binary -//! can target any network without a rebuild. +//! can target any network without a rebuild. The file may define several +//! `[network]` sections, in which case the bot connects to all of them at once, +//! one supervisor thread each. mod bot; mod irc; use std::fs; -use std::io; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::thread; use std::time::Duration; use bot::{Bot, Config}; use irc::Connection; fn main() { - let config = load_config(); + let configs = load_config(); + if configs.is_empty() { + log("no networks configured; nothing to do"); + std::process::exit(1); + } + + let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect(); log(&format!( - "starting rustbot as {} -> {}:{}", - config.nick, config.server, config.port + "starting rustbot across {} network(s): {}", + configs.len(), + 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 { + let name = config.name.clone(); + let handle = thread::Builder::new() + .name(name.clone()) + .spawn(move || supervise(config, labelled)) + .unwrap_or_else(|e| panic!("failed to spawn thread for {name}: {e}")); + handles.push(handle); + } + for handle in handles { + let _ = handle.join(); + } +} + +/// 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) + } else { + String::new() + }; + log(&format!( + "{tag}connecting as {} -> {}:{} (tls={})", + config.nick, config.server, config.port, config.tls )); - // Supervisor: keep the bot alive across disconnects with capped backoff. let mut backoff = 1u64; loop { - match run_once(&config) { + match run_once(&config, labelled) { Ok(()) => { - log("server closed the connection; reconnecting shortly"); + log(&format!("{tag}server closed the connection; reconnecting shortly")); backoff = 1; } - Err(e) => log(&format!("session error: {e}; retrying in {backoff}s")), + Err(e) => log(&format!("{tag}session error: {e}; retrying in {backoff}s")), } - std::thread::sleep(Duration::from_secs(backoff)); + thread::sleep(Duration::from_secs(backoff)); backoff = (backoff * 2).min(60); } } -/// Run a single connection lifecycle: connect, register, loop until disconnect. -fn run_once(config: &Config) -> std::io::Result<()> { - let conn = Connection::connect(&config.server, config.port, config.tls)?; +/// 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 { + conn.set_label(&config.name); + } let mut bot = Bot::new(config.clone(), conn); bot.run() } -/// Build the [`Config`] by layering sources in increasing priority: -/// built-in defaults, then a config file, then environment variables. -fn load_config() -> Config { - let mut c = Config::default(); +/// 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. +fn load_config() -> Vec { + let path = config_path(); + let content = path.as_ref().and_then(|p| match fs::read_to_string(p) { + Ok(s) => { + log(&format!("loaded config from {}", p.display())); + Some(s) + } + Err(e) => { + log(&format!("could not read config {}: {e}", p.display())); + None + } + }); - if let Some(path) = config_path() { - match apply_config_file(&mut c, &path) { - Ok(()) => log(&format!("loaded config from {}", path.display())), - Err(e) => log(&format!("could not read config {}: {e}", path.display())), + let (toplevel, sections) = match &content { + Some(text) => parse_sections(text), + 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)); + } + apply_env(&mut base); + + assemble(base, sections, &path) +} + +/// 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() { + let mut c = base; + finalize(&mut c); + configs.push(c); + } else { + for (name, entries) in sections { + let mut c = base.clone(); + c.name = name; + for (line, key, value) in &entries { + apply_kv(&mut c, key, value, &loc(path, *line)); + } + finalize(&mut c); + configs.push(c); } } - apply_env(&mut c); + configs +} - // Convention: TLS listens on 6697. If TLS is enabled but the port is still - // the plaintext default, switch to the conventional TLS port. +/// Per-network finishing touches: default the label to the server host, and +/// honour the TLS-port convention (6697) when TLS is on but the port is still +/// the plaintext default. +fn finalize(c: &mut Config) { + if c.name.is_empty() { + c.name = c.server.clone(); + } if c.tls && c.port == 6667 { c.port = 6697; } +} - c +/// A parsed `key = value` pair with its 1-based source line, for diagnostics. +type Entry = (usize, String, String); + +/// Split config text into shared top-level entries and `[name]` sections. +/// +/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line +/// opens a section; entries after it belong to that network until the next +/// header. Lines that are neither a header nor `key = value` are reported and +/// skipped. +fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { + let mut toplevel: Vec = Vec::new(); + let mut sections: Vec<(String, Vec)> = Vec::new(); + + for (i, raw) in content.lines().enumerate() { + let line = raw.trim(); + let lineno = i + 1; + 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; + } + let Some((key, value)) = line.split_once('=') else { + log(&format!("line {lineno}: ignoring line without '='")); + continue; + }; + let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string()); + match sections.last_mut() { + Some((_, entries)) => entries.push(entry), + None => toplevel.push(entry), + } + } + (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(), + "server" => c.server = value.to_string(), + "port" => match value.parse() { + Ok(p) => c.port = p, + Err(_) => log(&format!("{where_}: invalid port '{value}'")), + }, + "tls" => c.tls = parse_bool(value), + "nick" => c.nick = value.to_string(), + "user" => c.user = value.to_string(), + "realname" => c.realname = value.to_string(), + "channels" => c.channels = split_list(value), + "prefix" | "command_prefix" => c.command_prefix = value.to_string(), + "password" => c.password = (!value.is_empty()).then(|| value.to_string()), + "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), + "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), + other => log(&format!("{where_}: unknown key '{other}'")), + } +} + +/// Format a source location for a top-level line, including the file path. +fn loc(path: &Option, line: usize) -> String { + match path { + Some(p) => format!("{}:{}", p.display(), line), + None => format!("line {line}"), + } } /// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then @@ -84,49 +249,12 @@ fn config_path() -> Option { default.exists().then_some(default) } -/// Parse a `key = value` config file onto `c`. +/// Overlay environment variables onto `c` (applied to the shared defaults, so +/// they affect every network unless a `[section]` overrides them). /// -/// Blank lines are ignored. A line whose first non-space character is `#` or -/// `;` is a comment — note that inline comments are *not* supported, because -/// IRC channel names legitimately contain `#`. -fn apply_config_file(c: &mut Config, path: &Path) -> io::Result<()> { - let content = fs::read_to_string(path)?; - for (i, raw) in content.lines().enumerate() { - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - log(&format!("{}:{}: ignoring line without '='", path.display(), i + 1)); - continue; - }; - let (key, value) = (key.trim().to_ascii_lowercase(), value.trim()); - match key.as_str() { - "server" => c.server = value.to_string(), - "port" => match value.parse() { - Ok(p) => c.port = p, - Err(_) => log(&format!("{}:{}: invalid port '{value}'", path.display(), i + 1)), - }, - "tls" => c.tls = parse_bool(value), - "nick" => c.nick = value.to_string(), - "user" => c.user = value.to_string(), - "realname" => c.realname = value.to_string(), - "channels" => c.channels = split_list(value), - "prefix" | "command_prefix" => c.command_prefix = value.to_string(), - "password" => c.password = (!value.is_empty()).then(|| value.to_string()), - "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), - "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), - other => log(&format!("{}:{}: unknown key '{other}'", path.display(), i + 1)), - } - } - Ok(()) -} - -/// Overlay environment variables onto `c` (highest priority — handy for -/// one-off overrides without editing the file). -/// -/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_NICK`, `IRC_REALNAME`, -/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`. +/// 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; @@ -182,3 +310,71 @@ fn split_list(value: &str) -> Vec { fn log(msg: &str) { eprintln!("[rustbot] {msg}"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build the network list from config text, mirroring [`load_config`] but + /// without touching the filesystem or environment (so tests are hermetic). + fn build(content: &str) -> Vec { + let (toplevel, sections) = parse_sections(content); + let mut base = Config::default(); + for (line, key, value) in &toplevel { + apply_kv(&mut base, key, value, &format!("line {line}")); + } + assemble(base, sections, &None) + } + + #[test] + fn flat_config_is_a_single_network() { + let cfgs = build("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n"); + assert_eq!(cfgs.len(), 1); + 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"); + } + + #[test] + fn sections_inherit_top_level_defaults_and_override() { + let cfgs = build( + "nick = shared\nchannels = #lobby\n\ + [libera]\nserver = irc.libera.chat\ntls = true\n\ + [oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n", + ); + assert_eq!(cfgs.len(), 2); + + 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!(libera.tls); + assert_eq!(libera.port, 6697); // TLS-port convention applied per network + + 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 + } + + #[test] + fn a_section_may_rename_itself() { + let cfgs = build("[net]\nname = pretty\nserver = irc.example.org\n"); + assert_eq!(cfgs.len(), 1); + assert_eq!(cfgs[0].name, "pretty"); + } + + #[test] + fn comments_and_blanks_are_ignored() { + let (toplevel, sections) = parse_sections("# comment\n; also\n\n \nserver = x\n"); + assert!(sections.is_empty()); + assert_eq!(toplevel.len(), 1); + assert_eq!(toplevel[0].1, "server"); + assert_eq!(toplevel[0].2, "x"); + } +} From a15fb4f9a92ace50f4a10b5cf3f3d95a9f502134 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 15:38:55 +0000 Subject: [PATCH 03/11] Restructure into a library crate with modular bot and irc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.toml | 6 +- README.md | 24 ++- src/bot.rs | 351 -------------------------------- src/bot/caps.rs | 109 ++++++++++ src/bot/commands.rs | 115 +++++++++++ src/bot/mod.rs | 112 +++++++++++ src/config.rs | 275 ++++++++++++++++++++++++++ src/irc.rs | 450 ------------------------------------------ src/irc/connection.rs | 141 +++++++++++++ src/irc/encoding.rs | 59 ++++++ src/irc/message.rs | 168 ++++++++++++++++ src/irc/mod.rs | 24 +++ src/lib.rs | 23 +++ src/main.rs | 313 +---------------------------- tests/config.rs | 55 ++++++ tests/protocol.rs | 80 ++++++++ 16 files changed, 1189 insertions(+), 1116 deletions(-) delete mode 100644 src/bot.rs create mode 100644 src/bot/caps.rs create mode 100644 src/bot/commands.rs create mode 100644 src/bot/mod.rs create mode 100644 src/config.rs delete mode 100644 src/irc.rs create mode 100644 src/irc/connection.rs create mode 100644 src/irc/encoding.rs create mode 100644 src/irc/message.rs create mode 100644 src/irc/mod.rs create mode 100644 src/lib.rs create mode 100644 tests/config.rs create mode 100644 tests/protocol.rs diff --git a/Cargo.toml b/Cargo.toml index 8ca62a2..a016294 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,15 @@ description = "A modular, dependency-free IRC bot in Rust with a hand-rolled IRC license = "MIT" authors = ["a.srenov"] +[lib] +name = "rustbot" +path = "src/lib.rs" + [[bin]] name = "rustbot" path = "src/main.rs" -# The IRC protocol layer is hand-rolled in `src/irc.rs` (modern IRC client +# The IRC protocol layer is hand-rolled in `src/irc/` (modern IRC client # spec: https://modern.ircdocs.horse/). The sole dependency is native-tls for # the optional TLS transport; it wraps the system OpenSSL, so TLS security # fixes arrive through normal OS updates rather than a vendored crypto crate. diff --git a/README.md b/README.md index 3a164d0..37a47f3 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,26 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors ## Structure -| File | Layer | Responsibility | -|---------------|------------------|-----------------------------------------------------------| -| `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport | -| `src/bot.rs` | behavior | Registration, event loop, command handling | -| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network | +rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: -The layers are deliberately separable: `irc.rs` knows nothing about the bot, and -`bot.rs` knows nothing about how the process is launched. +| Path | Layer | Responsibility | +|-----------------|----------------|-------------------------------------------------------------------| +| `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) | +| `src/bot/` | behavior | `mod` (state + event loop), `caps` (IRCv3 cap negotiation + SASL), `commands` (message routing + command table) | +| `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing | +| `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library | +| `src/main.rs` | binary | Thin per-network connect/reconnect supervisor | +| `tests/` | tests | Integration tests (`config.rs`, `protocol.rs`) against the public API | + +The layers are deliberately separable: `irc` knows nothing about the bot, `bot` +knows nothing about how the process is launched, and `main` only supervises. +Adding a command is usually a one-file change in `src/bot/commands.rs`. ## Build & run ```sh cargo build # or: cargo build --release -cargo test # unit tests for the message parser +cargo test # integration tests in tests/ (config + protocol) cargo run ``` @@ -26,7 +32,7 @@ cargo run Settings are layered, each overriding the previous: -1. **Built-in defaults** (`Config::default()` in `src/bot.rs`) +1. **Built-in defaults** (`Config::default()` in `src/config.rs`) 2. **A config file** — `key = value`, see [`rustbot.conf`](rustbot.conf) 3. **Environment variables** — handy for one-off overrides diff --git a/src/bot.rs b/src/bot.rs deleted file mode 100644 index ba1ac21..0000000 --- a/src/bot.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! Bot behavior: configuration, registration, the event loop, and commands. -//! -//! This layer is transport-agnostic in spirit: it drives a [`Connection`] from -//! `irc.rs` and turns incoming [`Message`]s into actions. To add a feature you -//! usually only touch [`Bot::handle`] or [`Bot::handle_command`]. - -use std::collections::{HashMap, HashSet}; -use std::io; - -use crate::irc::{base64_encode, now_unix, parse_server_time, Connection, 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", - "batch", - "echo-message", - "account-tag", - "extended-join", - "multi-prefix", - "away-notify", - "chghost", - "setname", - "userhost-in-names", - "cap-notify", - "labeled-response", -]; - -/// 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; - -/// Everything the bot needs to know to connect 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, -} - -impl Default for Config { - fn default() -> Config { - Config { - name: String::new(), - server: "irc.tchatou.fr".to_string(), - port: 6667, - tls: false, - nick: "rubot".to_string(), - user: "rubot".to_string(), - realname: "rubot".to_string(), - channels: vec!["#devs".to_string()], - command_prefix: "!".to_string(), - password: None, - sasl_user: None, - sasl_pass: None, - } - } -} - -/// The bot: owns its configuration and connection, 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, -} - -impl Bot { - pub fn new(config: Config, conn: Connection) -> Bot { - let current_nick = config.nick.clone(); - Bot { - config, - conn, - current_nick, - registered: false, - available_caps: HashSet::new(), - enabled_caps: HashSet::new(), - batches: HashMap::new(), - sasl_started: false, - } - } - - /// Register with the server, then process messages until disconnect. - /// - /// Returns `Ok(())` when the server closes the connection cleanly; the - /// caller (main) decides whether to reconnect. - pub fn run(&mut self) -> io::Result<()> { - self.register()?; - while let Some(msg) = self.conn.read_message()? { - self.handle(&msg)?; - } - 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)?; - } - self.conn.cap_ls()?; - let nick = self.current_nick.clone(); - self.conn.nick(&nick)?; - let (user, realname) = (self.config.user.clone(), self.config.realname.clone()); - self.conn.user(&user, &realname)?; - 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. - "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. - "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(); - self.conn.nick(&nick)?; - } - "PRIVMSG" => self.handle_privmsg(msg)?, - _ => {} - } - Ok(()) - } - - /// Handle a `CAP` reply: collect advertised caps, request the ones we want, - /// and record what was acknowledged. - 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()); - } - if !more { - self.request_caps()?; - } - } - Some("ACK") => { - for cap in msg.trailing().unwrap_or("").split_whitespace() { - self.enabled_caps - .insert(cap.trim_start_matches(['-', '~', '=']).to_string()); - } - self.after_cap_ack()?; - } - Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on - _ => {} // NEW/DEL: ignored for now - } - 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() - .copied() - .filter(|c| self.available_caps.contains(*c)) - .collect(); - if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") { - wanted.push("sasl"); - } - if wanted.is_empty() { - self.conn.cap_end() - } else { - self.conn.cap_req(&wanted.join(" ")) - } - } - - /// 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 +" - } else { - self.conn.cap_end() - } - } - - /// Respond to the server's SASL prompt with base64 PLAIN credentials. - 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)?; - } - } - Ok(()) - } - - /// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history. - 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); - } - } - - fn sasl_credentials(&self) -> Option<(String, String)> { - match (&self.config.sasl_user, &self.config.sasl_pass) { - (Some(u), Some(p)) => Some((u.clone(), p.clone())), - _ => None, - } - } - - /// 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. - 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 , {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(['#', '&', '+', '!']) -} diff --git a/src/bot/caps.rs b/src/bot/caps.rs new file mode 100644 index 0000000..10ec67e --- /dev/null +++ b/src/bot/caps.rs @@ -0,0 +1,109 @@ +//! 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", + "batch", + "echo-message", + "account-tag", + "extended-join", + "multi-prefix", + "away-notify", + "chghost", + "setname", + "userhost-in-names", + "cap-notify", + "labeled-response", +]; + +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()); + } + if !more { + self.request_caps()?; + } + } + Some("ACK") => { + for cap in msg.trailing().unwrap_or("").split_whitespace() { + self.enabled_caps + .insert(cap.trim_start_matches(['-', '~', '=']).to_string()); + } + self.after_cap_ack()?; + } + Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on + _ => {} // NEW/DEL: ignored for now + } + 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() + .copied() + .filter(|c| self.available_caps.contains(*c)) + .collect(); + if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") { + wanted.push("sasl"); + } + if wanted.is_empty() { + self.conn.cap_end() + } else { + self.conn.cap_req(&wanted.join(" ")) + } + } + + /// 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 +" + } 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)?; + } + } + Ok(()) + } + + fn sasl_credentials(&self) -> Option<(String, String)> { + match (&self.config.sasl_user, &self.config.sasl_pass) { + (Some(u), Some(p)) => Some((u.clone(), p.clone())), + _ => None, + } + } +} diff --git a/src/bot/commands.rs b/src/bot/commands.rs new file mode 100644 index 0000000..95cc5df --- /dev/null +++ b/src/bot/commands.rs @@ -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 + [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) + } + + /// 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 , {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(['#', '&', '+', '!']) +} diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 0000000..036dfd4 --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,112 @@ +//! Bot behavior: the [`Bot`] state, registration, and the event loop. +//! +//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`] +//! and turns incoming [`Message`]s into actions. The behaviour is split across +//! sibling modules — capability negotiation and SASL live in [`caps`], and +//! message routing plus the command table live in [`commands`]. To add a +//! feature you usually only touch [`commands`]. + +use std::collections::{HashMap, HashSet}; +use std::io; + +use crate::config::Config; +use crate::irc::{Connection, Message}; + +mod caps; +mod commands; + +/// The bot: owns its configuration and connection, 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, +} + +impl Bot { + pub fn new(config: Config, conn: Connection) -> Bot { + let current_nick = config.nick.clone(); + Bot { + config, + conn, + current_nick, + registered: false, + available_caps: HashSet::new(), + enabled_caps: HashSet::new(), + batches: HashMap::new(), + sasl_started: false, + } + } + + /// 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()? { + self.handle(&msg)?; + } + 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)?; + } + self.conn.cap_ls()?; + let nick = self.current_nick.clone(); + self.conn.nick(&nick)?; + let (user, realname) = (self.config.user.clone(), self.config.realname.clone()); + self.conn.user(&user, &realname)?; + 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. + "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(); + self.conn.nick(&nick)?; + } + "PRIVMSG" => self.handle_privmsg(msg)?, + _ => {} + } + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c93cf61 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,275 @@ +//! 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, +} + +impl Default for Config { + fn default() -> Config { + Config { + name: String::new(), + server: "irc.tchatou.fr".to_string(), + port: 6667, + tls: false, + nick: "rubot".to_string(), + user: "rubot".to_string(), + realname: "rubot".to_string(), + channels: vec!["#devs".to_string()], + command_prefix: "!".to_string(), + password: None, + sasl_user: None, + sasl_pass: None, + } + } +} + +/// 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) { + Ok(s) => { + log(&format!("loaded config from {}", p.display())); + Some(s) + } + Err(e) => { + log(&format!("could not read config {}: {e}", p.display())); + None + } + }); + + let (toplevel, sections) = match &content { + Some(text) => parse_sections(text), + 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)); + } + apply_env(&mut base); + + 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(); + for (line, key, value) in &toplevel { + apply_kv(&mut base, key, value, &format!("line {line}")); + } + 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() { + let mut c = base; + finalize(&mut c); + configs.push(c); + } else { + for (name, entries) in sections { + let mut c = base.clone(); + c.name = name; + for (line, key, value) in &entries { + apply_kv(&mut c, key, value, &loc(path, *line)); + } + finalize(&mut c); + configs.push(c); + } + } + configs +} + +/// Per-network finishing touches: default the label to the server host, and +/// honour the TLS-port convention (6697) when TLS is on but the port is still +/// the plaintext default. +fn finalize(c: &mut Config) { + if c.name.is_empty() { + c.name = c.server.clone(); + } + if c.tls && c.port == 6667 { + c.port = 6697; + } +} + +/// Split config text into shared top-level entries and `[name]` sections. +/// +/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line +/// opens a section; entries after it belong to that network until the next +/// header. Lines that are neither a header nor `key = value` are reported and +/// skipped. +fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { + let mut toplevel: Vec = Vec::new(); + let mut sections: Vec<(String, Vec)> = Vec::new(); + + for (i, raw) in content.lines().enumerate() { + let line = raw.trim(); + let lineno = i + 1; + 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; + } + let Some((key, value)) = line.split_once('=') else { + log(&format!("line {lineno}: ignoring line without '='")); + continue; + }; + let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string()); + match sections.last_mut() { + Some((_, entries)) => entries.push(entry), + None => toplevel.push(entry), + } + } + (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(), + "server" => c.server = value.to_string(), + "port" => match value.parse() { + Ok(p) => c.port = p, + Err(_) => log(&format!("{where_}: invalid port '{value}'")), + }, + "tls" => c.tls = parse_bool(value), + "nick" => c.nick = value.to_string(), + "user" => c.user = value.to_string(), + "realname" => c.realname = value.to_string(), + "channels" => c.channels = split_list(value), + "prefix" | "command_prefix" => c.command_prefix = value.to_string(), + "password" => c.password = (!value.is_empty()).then(|| value.to_string()), + "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), + "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), + other => log(&format!("{where_}: unknown key '{other}'")), + } +} + +/// 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), + None => format!("line {line}"), + } +} + +/// 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)); + } + if let Ok(env) = std::env::var("RUSTBOT_CONFIG") { + return Some(PathBuf::from(env)); + } + let default = PathBuf::from("rustbot.conf"); + 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; + } + if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) { + c.port = p; + } + if let Ok(v) = std::env::var("IRC_TLS") { + c.tls = parse_bool(&v); + } + if let Ok(v) = std::env::var("IRC_NICK") { + c.user = v.clone(); + c.nick = v; + } + if let Ok(v) = std::env::var("IRC_REALNAME") { + c.realname = v; + } + if let Ok(v) = std::env::var("IRC_CHANNELS") { + c.channels = split_list(&v); + } + if let Ok(v) = std::env::var("IRC_PREFIX") { + c.command_prefix = v; + } + if let Ok(v) = std::env::var("IRC_PASSWORD") { + c.password = Some(v); + } + if let Ok(v) = std::env::var("IRC_SASL_USER") { + c.sasl_user = Some(v); + } + if let Ok(v) = std::env::var("IRC_SASL_PASS") { + c.sasl_pass = Some(v); + } +} + +/// 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(), + "1" | "true" | "yes" | "on" + ) +} + +/// 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()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} diff --git a/src/irc.rs b/src/irc.rs deleted file mode 100644 index a525ed4..0000000 --- a/src/irc.rs +++ /dev/null @@ -1,450 +0,0 @@ -//! Minimal IRC protocol layer — the "IRC library". -//! -//! Implements just enough of the modern IRC client protocol -//! () to drive a bot: -//! -//! * [`Message`] — parsing of the wire format `@tags :source COMMAND params`, -//! including IRCv3 message tags and trailing parameters. -//! * [`Prefix`] — the `nick!user@host` (or server name) message source. -//! * [`Connection`] — a blocking TCP transport with convenience senders. -//! -//! It is deliberately self-contained and dependency-free, so it can grow into a -//! small reusable IRC library independent of the bot logic in `bot.rs`. -#![allow(dead_code)] // This is a library-style module; not every helper is used yet. - -use std::collections::HashMap; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::net::TcpStream; -use std::time::{SystemTime, UNIX_EPOCH}; - -use native_tls::TlsConnector; - -/// 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, - pub host: Option, -} - -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('@') { - Some((u, h)) => (Some(n), Some(u), Some(h)), - None => (Some(n), Some(rest), None), - } - } 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) - }; - - Prefix { - raw: raw.to_string(), - nick: nick.map(str::to_string), - user: user.map(str::to_string), - host: host.map(str::to_string), - } - } - - /// 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(' ')?; - rest = r.trim_start(); - for item in tag_str.split(';').filter(|s| !s.is_empty()) { - match item.split_once('=') { - Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)), - None => tags.insert(item.to_string(), String::new()), - }; - } - } - - // 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(' ')?; - prefix = Some(Prefix::parse(p)); - rest = r.trim_start(); - } - - // 3. Command (required). - let (command, mut rest) = match rest.split_once(' ') { - Some((c, r)) => (c, r.trim_start()), - None => (rest, ""), - }; - if command.is_empty() { - 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(':') { - params.push(trailing.to_string()); - break; - } - match rest.split_once(' ') { - Some((p, r)) => { - if !p.is_empty() { - params.push(p.to_string()); - } - rest = r.trim_start(); - } - None => { - params.push(rest.to_string()); - break; - } - } - } - - Some(Message { - tags, - prefix, - command: command.to_uppercase(), - params, - }) - } - - /// 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(); - while let Some(c) = chars.next() { - if c != '\\' { - out.push(c); - continue; - } - match chars.next() { - Some(':') => out.push(';'), - Some('s') => out.push(' '), - 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 - } - } - out -} - -/// 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 { - let connector = - TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?; - let tls = connector - .connect(host, tcp) - .map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?; - Box::new(tls) - } else { - Box::new(tcp) - }; - 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() - } else { - format!("[{label}] ") - }; - } - - /// 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 - } - let trimmed = line.trim_end_matches(['\r', '\n']); - if trimmed.is_empty() { - continue; - } - eprintln!("{}<< {trimmed}", self.log_prefix); - 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); - let writer = self.inner.get_mut(); - write!(writer, "{clean}\r\n")?; - writer.flush() - } - - // --- Convenience senders ------------------------------------------------- - - pub fn pass(&mut self, password: &str) -> io::Result<()> { - self.send_raw(&format!("PASS {password}")) - } - - pub fn nick(&mut self, nick: &str) -> io::Result<()> { - self.send_raw(&format!("NICK {nick}")) - } - - pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> { - self.send_raw(&format!("USER {user} 0 * :{realname}")) - } - - pub fn join(&mut self, channel: &str) -> io::Result<()> { - self.send_raw(&format!("JOIN {channel}")) - } - - pub fn pong(&mut self, token: &str) -> io::Result<()> { - self.send_raw(&format!("PONG :{token}")) - } - - pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> { - self.send_raw(&format!("PRIVMSG {target} :{text}")) - } - - pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> { - self.send_raw(&format!("NOTICE {target} :{text}")) - } - - pub fn quit(&mut self, reason: &str) -> io::Result<()> { - self.send_raw(&format!("QUIT :{reason}")) - } - - // --- IRCv3 capability negotiation --------------------------------------- - - pub fn cap_ls(&mut self) -> io::Result<()> { - self.send_raw("CAP LS 302") - } - - pub fn cap_req(&mut self, caps: &str) -> io::Result<()> { - self.send_raw(&format!("CAP REQ :{caps}")) - } - - pub fn cap_end(&mut self) -> io::Result<()> { - self.send_raw("CAP END") - } - - pub fn authenticate(&mut self, payload: &str) -> io::Result<()> { - self.send_raw(&format!("AUTHENTICATE {payload}")) - } -} - -/// 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+/"; - let mut out = String::with_capacity(input.len().div_ceil(3) * 4); - for chunk in input.chunks(3) { - let b0 = chunk[0] as u32; - let b1 = *chunk.get(1).unwrap_or(&0) as u32; - let b2 = *chunk.get(2).unwrap_or(&0) as u32; - let n = (b0 << 16) | (b1 << 8) | b2; - out.push(ALPHABET[(n >> 18 & 63) as usize] as char); - out.push(ALPHABET[(n >> 12 & 63) as usize] as char); - out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' }); - out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' }); - } - 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) - .map(|d| d.as_secs()) - .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 mut d = date.splitn(3, '-'); - let year: i64 = d.next()?.parse().ok()?; - let month: i64 = d.next()?.parse().ok()?; - let day: i64 = d.next()?.parse().ok()?; - let mut t = time.splitn(3, ':'); - let hour: i64 = t.next()?.parse().ok()?; - let min: i64 = t.next()?.parse().ok()?; - let sec: i64 = t.next()?.parse().ok()?; - let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec; - 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; - let yoe = y - era * 400; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_privmsg_with_prefix() { - let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap(); - assert_eq!(m.command, "PRIVMSG"); - assert_eq!(m.nick(), Some("alice")); - assert_eq!(m.param(0), Some("#chan")); - assert_eq!(m.trailing(), Some("hi there")); - } - - #[test] - fn parses_ping_without_prefix() { - let m = Message::parse("PING :LAG1234").unwrap(); - assert_eq!(m.command, "PING"); - assert!(m.prefix.is_none()); - assert_eq!(m.trailing(), Some("LAG1234")); - } - - #[test] - fn parses_and_unescapes_tags() { - let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap(); - assert_eq!(m.tags.get("id").map(String::as_str), Some("123")); - assert_eq!(m.tags.get("key").map(String::as_str), Some("a b;c")); - assert_eq!(m.command, "PRIVMSG"); - assert_eq!(m.trailing(), Some("yo")); - } - - #[test] - fn parses_numeric_reply() { - let m = Message::parse(":srv 001 me :Welcome to the network").unwrap(); - assert_eq!(m.command, "001"); - assert_eq!(m.param(0), Some("me")); - assert_eq!(m.trailing(), Some("Welcome to the network")); - } - - #[test] - fn full_prefix_is_split() { - let p = Prefix::parse("nick!user@host.example"); - assert_eq!(p.nick.as_deref(), Some("nick")); - assert_eq!(p.user.as_deref(), Some("user")); - assert_eq!(p.host.as_deref(), Some("host.example")); - } - - #[test] - fn blank_line_is_none() { - assert!(Message::parse(" \r\n").is_none()); - } - - #[test] - fn base64_matches_known_vectors() { - assert_eq!(base64_encode(b""), ""); - 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"); - } - - #[test] - fn server_time_parses_to_unix_seconds() { - assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0)); - assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800)); - assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200)); - assert_eq!(parse_server_time("not-a-timestamp"), None); - } - - #[test] - fn reads_message_tags_via_accessor() { - let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap(); - assert_eq!(m.tag("batch"), Some("42")); - assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z")); - assert_eq!(m.tag("missing"), None); - } -} diff --git a/src/irc/connection.rs b/src/irc/connection.rs new file mode 100644 index 0000000..8e17600 --- /dev/null +++ b/src/irc/connection.rs @@ -0,0 +1,141 @@ +//! 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; + +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 { + let connector = + TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?; + let tls = connector + .connect(host, tcp) + .map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?; + Box::new(tls) + } else { + Box::new(tcp) + }; + 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() + } else { + format!("[{label}] ") + }; + } + + /// 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 + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + continue; + } + eprintln!("{}<< {trimmed}", self.log_prefix); + 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); + let writer = self.inner.get_mut(); + write!(writer, "{clean}\r\n")?; + writer.flush() + } + + // --- Convenience senders ------------------------------------------------- + + pub fn pass(&mut self, password: &str) -> io::Result<()> { + self.send_raw(&format!("PASS {password}")) + } + + pub fn nick(&mut self, nick: &str) -> io::Result<()> { + self.send_raw(&format!("NICK {nick}")) + } + + pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> { + self.send_raw(&format!("USER {user} 0 * :{realname}")) + } + + pub fn join(&mut self, channel: &str) -> io::Result<()> { + self.send_raw(&format!("JOIN {channel}")) + } + + pub fn pong(&mut self, token: &str) -> io::Result<()> { + self.send_raw(&format!("PONG :{token}")) + } + + pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> { + self.send_raw(&format!("PRIVMSG {target} :{text}")) + } + + pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> { + self.send_raw(&format!("NOTICE {target} :{text}")) + } + + pub fn quit(&mut self, reason: &str) -> io::Result<()> { + self.send_raw(&format!("QUIT :{reason}")) + } + + // --- IRCv3 capability negotiation --------------------------------------- + + pub fn cap_ls(&mut self) -> io::Result<()> { + self.send_raw("CAP LS 302") + } + + pub fn cap_req(&mut self, caps: &str) -> io::Result<()> { + self.send_raw(&format!("CAP REQ :{caps}")) + } + + pub fn cap_end(&mut self) -> io::Result<()> { + self.send_raw("CAP END") + } + + pub fn authenticate(&mut self, payload: &str) -> io::Result<()> { + self.send_raw(&format!("AUTHENTICATE {payload}")) + } +} diff --git a/src/irc/encoding.rs b/src/irc/encoding.rs new file mode 100644 index 0000000..b65d69b --- /dev/null +++ b/src/irc/encoding.rs @@ -0,0 +1,59 @@ +//! 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+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[(n >> 18 & 63) as usize] as char); + out.push(ALPHABET[(n >> 12 & 63) as usize] as char); + out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' }); + out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' }); + } + 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) + .map(|d| d.as_secs()) + .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 mut d = date.splitn(3, '-'); + let year: i64 = d.next()?.parse().ok()?; + let month: i64 = d.next()?.parse().ok()?; + let day: i64 = d.next()?.parse().ok()?; + let mut t = time.splitn(3, ':'); + let hour: i64 = t.next()?.parse().ok()?; + let min: i64 = t.next()?.parse().ok()?; + let sec: i64 = t.next()?.parse().ok()?; + let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec; + 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; + let yoe = y - era * 400; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} diff --git a/src/irc/message.rs b/src/irc/message.rs new file mode 100644 index 0000000..57f9f3a --- /dev/null +++ b/src/irc/message.rs @@ -0,0 +1,168 @@ +//! 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, + pub host: Option, +} + +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('@') { + Some((u, h)) => (Some(n), Some(u), Some(h)), + None => (Some(n), Some(rest), None), + } + } 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) + }; + + Prefix { + raw: raw.to_string(), + nick: nick.map(str::to_string), + user: user.map(str::to_string), + host: host.map(str::to_string), + } + } + + /// 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(' ')?; + rest = r.trim_start(); + for item in tag_str.split(';').filter(|s| !s.is_empty()) { + match item.split_once('=') { + Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)), + None => tags.insert(item.to_string(), String::new()), + }; + } + } + + // 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(' ')?; + prefix = Some(Prefix::parse(p)); + rest = r.trim_start(); + } + + // 3. Command (required). + let (command, mut rest) = match rest.split_once(' ') { + Some((c, r)) => (c, r.trim_start()), + None => (rest, ""), + }; + if command.is_empty() { + 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(':') { + params.push(trailing.to_string()); + break; + } + match rest.split_once(' ') { + Some((p, r)) => { + if !p.is_empty() { + params.push(p.to_string()); + } + rest = r.trim_start(); + } + None => { + params.push(rest.to_string()); + break; + } + } + } + + Some(Message { + tags, + prefix, + command: command.to_uppercase(), + params, + }) + } + + /// 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(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some(':') => out.push(';'), + Some('s') => out.push(' '), + 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 + } + } + out +} diff --git a/src/irc/mod.rs b/src/irc/mod.rs new file mode 100644 index 0000000..eb0c747 --- /dev/null +++ b/src/irc/mod.rs @@ -0,0 +1,24 @@ +//! 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. + +mod connection; +mod encoding; +mod message; + +pub use connection::Connection; +pub use encoding::{base64_encode, now_unix, parse_server_time}; +pub use message::{Message, Prefix}; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1a953a8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! 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 8eec2bb..ae2375c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,17 @@ -//! rustbot — a small, modular IRC bot. -//! -//! Layers: -//! * `irc` — protocol + transport (the "IRC library") -//! * `bot` — behavior: registration, event loop, commands -//! * `main` — configuration and the connect/reconnect supervisor -//! -//! Configuration is layered (see [`load_config`]): built-in defaults, then a -//! `key = value` config file, then environment variables — so the same binary -//! can target any network without a rebuild. The file may define several -//! `[network]` sections, in which case the bot connects to all of them at once, -//! one supervisor thread each. +//! 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. -mod bot; -mod irc; - -use std::fs; -use std::path::PathBuf; use std::thread; use std::time::Duration; -use bot::{Bot, Config}; -use irc::Connection; +use rustbot::bot::Bot; +use rustbot::config::{self, Config}; +use rustbot::irc::Connection; +use rustbot::log; fn main() { - let configs = load_config(); + let configs = config::load(); if configs.is_empty() { log("no networks configured; nothing to do"); std::process::exit(1); @@ -93,288 +81,3 @@ fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> { let mut bot = Bot::new(config.clone(), conn); bot.run() } - -/// 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. -fn load_config() -> Vec { - let path = config_path(); - let content = path.as_ref().and_then(|p| match fs::read_to_string(p) { - Ok(s) => { - log(&format!("loaded config from {}", p.display())); - Some(s) - } - Err(e) => { - log(&format!("could not read config {}: {e}", p.display())); - None - } - }); - - let (toplevel, sections) = match &content { - Some(text) => parse_sections(text), - 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)); - } - apply_env(&mut base); - - assemble(base, sections, &path) -} - -/// 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() { - let mut c = base; - finalize(&mut c); - configs.push(c); - } else { - for (name, entries) in sections { - let mut c = base.clone(); - c.name = name; - for (line, key, value) in &entries { - apply_kv(&mut c, key, value, &loc(path, *line)); - } - finalize(&mut c); - configs.push(c); - } - } - configs -} - -/// Per-network finishing touches: default the label to the server host, and -/// honour the TLS-port convention (6697) when TLS is on but the port is still -/// the plaintext default. -fn finalize(c: &mut Config) { - if c.name.is_empty() { - c.name = c.server.clone(); - } - if c.tls && c.port == 6667 { - c.port = 6697; - } -} - -/// A parsed `key = value` pair with its 1-based source line, for diagnostics. -type Entry = (usize, String, String); - -/// Split config text into shared top-level entries and `[name]` sections. -/// -/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line -/// opens a section; entries after it belong to that network until the next -/// header. Lines that are neither a header nor `key = value` are reported and -/// skipped. -fn parse_sections(content: &str) -> (Vec, Vec<(String, Vec)>) { - let mut toplevel: Vec = Vec::new(); - let mut sections: Vec<(String, Vec)> = Vec::new(); - - for (i, raw) in content.lines().enumerate() { - let line = raw.trim(); - let lineno = i + 1; - 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; - } - let Some((key, value)) = line.split_once('=') else { - log(&format!("line {lineno}: ignoring line without '='")); - continue; - }; - let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string()); - match sections.last_mut() { - Some((_, entries)) => entries.push(entry), - None => toplevel.push(entry), - } - } - (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(), - "server" => c.server = value.to_string(), - "port" => match value.parse() { - Ok(p) => c.port = p, - Err(_) => log(&format!("{where_}: invalid port '{value}'")), - }, - "tls" => c.tls = parse_bool(value), - "nick" => c.nick = value.to_string(), - "user" => c.user = value.to_string(), - "realname" => c.realname = value.to_string(), - "channels" => c.channels = split_list(value), - "prefix" | "command_prefix" => c.command_prefix = value.to_string(), - "password" => c.password = (!value.is_empty()).then(|| value.to_string()), - "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), - "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), - other => log(&format!("{where_}: unknown key '{other}'")), - } -} - -/// Format a source location for a top-level line, including the file path. -fn loc(path: &Option, line: usize) -> String { - match path { - Some(p) => format!("{}:{}", p.display(), line), - None => format!("line {line}"), - } -} - -/// 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)); - } - if let Ok(env) = std::env::var("RUSTBOT_CONFIG") { - return Some(PathBuf::from(env)); - } - let default = PathBuf::from("rustbot.conf"); - 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; - } - if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) { - c.port = p; - } - if let Ok(v) = std::env::var("IRC_TLS") { - c.tls = parse_bool(&v); - } - if let Ok(v) = std::env::var("IRC_NICK") { - c.user = v.clone(); - c.nick = v; - } - if let Ok(v) = std::env::var("IRC_REALNAME") { - c.realname = v; - } - if let Ok(v) = std::env::var("IRC_CHANNELS") { - c.channels = split_list(&v); - } - if let Ok(v) = std::env::var("IRC_PREFIX") { - c.command_prefix = v; - } - if let Ok(v) = std::env::var("IRC_PASSWORD") { - c.password = Some(v); - } - if let Ok(v) = std::env::var("IRC_SASL_USER") { - c.sasl_user = Some(v); - } - if let Ok(v) = std::env::var("IRC_SASL_PASS") { - c.sasl_pass = Some(v); - } -} - -/// 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(), - "1" | "true" | "yes" | "on" - ) -} - -/// 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()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() -} - -/// Tiny stderr logger. Swap for the `log`/`tracing` crates if you outgrow it. -fn log(msg: &str) { - eprintln!("[rustbot] {msg}"); -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Build the network list from config text, mirroring [`load_config`] but - /// without touching the filesystem or environment (so tests are hermetic). - fn build(content: &str) -> Vec { - let (toplevel, sections) = parse_sections(content); - let mut base = Config::default(); - for (line, key, value) in &toplevel { - apply_kv(&mut base, key, value, &format!("line {line}")); - } - assemble(base, sections, &None) - } - - #[test] - fn flat_config_is_a_single_network() { - let cfgs = build("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n"); - assert_eq!(cfgs.len(), 1); - 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"); - } - - #[test] - fn sections_inherit_top_level_defaults_and_override() { - let cfgs = build( - "nick = shared\nchannels = #lobby\n\ - [libera]\nserver = irc.libera.chat\ntls = true\n\ - [oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n", - ); - assert_eq!(cfgs.len(), 2); - - 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!(libera.tls); - assert_eq!(libera.port, 6697); // TLS-port convention applied per network - - 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 - } - - #[test] - fn a_section_may_rename_itself() { - let cfgs = build("[net]\nname = pretty\nserver = irc.example.org\n"); - assert_eq!(cfgs.len(), 1); - assert_eq!(cfgs[0].name, "pretty"); - } - - #[test] - fn comments_and_blanks_are_ignored() { - let (toplevel, sections) = parse_sections("# comment\n; also\n\n \nserver = x\n"); - assert!(sections.is_empty()); - assert_eq!(toplevel.len(), 1); - assert_eq!(toplevel[0].1, "server"); - assert_eq!(toplevel[0].2, "x"); - } -} diff --git a/tests/config.rs b/tests/config.rs new file mode 100644 index 0000000..ce5237c --- /dev/null +++ b/tests/config.rs @@ -0,0 +1,55 @@ +//! 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] +fn flat_config_is_a_single_network() { + let cfgs = parse_str("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n"); + assert_eq!(cfgs.len(), 1); + 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"); +} + +#[test] +fn sections_inherit_top_level_defaults_and_override() { + let cfgs = parse_str( + "nick = shared\nchannels = #lobby\n\ + [libera]\nserver = irc.libera.chat\ntls = true\n\ + [oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n", + ); + assert_eq!(cfgs.len(), 2); + + 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!(libera.tls); + assert_eq!(libera.port, 6697); // TLS-port convention applied per network + + 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 +} + +#[test] +fn a_section_may_rename_itself() { + let cfgs = parse_str("[net]\nname = pretty\nserver = irc.example.org\n"); + assert_eq!(cfgs.len(), 1); + assert_eq!(cfgs[0].name, "pretty"); +} + +#[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 +} diff --git a/tests/protocol.rs b/tests/protocol.rs new file mode 100644 index 0000000..a846528 --- /dev/null +++ b/tests/protocol.rs @@ -0,0 +1,80 @@ +//! 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] +fn parses_privmsg_with_prefix() { + let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap(); + assert_eq!(m.command, "PRIVMSG"); + assert_eq!(m.nick(), Some("alice")); + assert_eq!(m.param(0), Some("#chan")); + assert_eq!(m.trailing(), Some("hi there")); +} + +#[test] +fn parses_ping_without_prefix() { + let m = Message::parse("PING :LAG1234").unwrap(); + assert_eq!(m.command, "PING"); + assert!(m.prefix.is_none()); + assert_eq!(m.trailing(), Some("LAG1234")); +} + +#[test] +fn parses_and_unescapes_tags() { + let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap(); + assert_eq!(m.tag("id"), Some("123")); + assert_eq!(m.tag("key"), Some("a b;c")); + assert_eq!(m.command, "PRIVMSG"); + assert_eq!(m.trailing(), Some("yo")); +} + +#[test] +fn parses_numeric_reply() { + let m = Message::parse(":srv 001 me :Welcome to the network").unwrap(); + assert_eq!(m.command, "001"); + assert_eq!(m.param(0), Some("me")); + assert_eq!(m.trailing(), Some("Welcome to the network")); +} + +#[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")); + assert_eq!(p.user.as_deref(), Some("user")); + assert_eq!(p.host.as_deref(), Some("host.example")); + assert_eq!(p.name(), "nick"); +} + +#[test] +fn blank_line_is_none() { + assert!(Message::parse(" \r\n").is_none()); +} + +#[test] +fn base64_matches_known_vectors() { + assert_eq!(base64_encode(b""), ""); + 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"); +} + +#[test] +fn server_time_parses_to_unix_seconds() { + assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0)); + assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800)); + assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200)); + assert_eq!(parse_server_time("not-a-timestamp"), None); +} + +#[test] +fn reads_message_tags_via_accessor() { + let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap(); + assert_eq!(m.tag("batch"), Some("42")); + assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z")); + assert_eq!(m.tag("missing"), None); +} From a5e58493d8b74b7dcfb28ec511e50003e890ac32 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 16:13:24 +0000 Subject: [PATCH 04/11] Add a module (plugin) system for bot features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a `Module` trait (src/bot/module.rs): a feature declares the commands it provides and turns each invocation into a `Vec`, 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. --- README.md | 41 ++++++++++++++- src/bot/commands.rs | 60 +++++++++++++++++----- src/bot/mod.rs | 39 ++++++++++++--- src/bot/module.rs | 71 ++++++++++++++++++++++++++ src/bot/modules/builtins.rs | 33 +++++++++++++ src/bot/modules/dice.rs | 99 +++++++++++++++++++++++++++++++++++++ src/bot/modules/mod.rs | 17 +++++++ tests/modules.rs | 87 ++++++++++++++++++++++++++++++++ 8 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 src/bot/module.rs create mode 100644 src/bot/modules/builtins.rs create mode 100644 src/bot/modules/dice.rs create mode 100644 src/bot/modules/mod.rs create mode 100644 tests/modules.rs diff --git a/README.md b/README.md index 37a47f3..1afc243 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: | Path | Layer | Responsibility | |-----------------|----------------|-------------------------------------------------------------------| | `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) | -| `src/bot/` | behavior | `mod` (state + event loop), `caps` (IRCv3 cap negotiation + SASL), `commands` (message routing + command table) | +| `src/bot/` | behavior | `mod` (state + loop), `caps` (IRCv3 + SASL), `commands` (routing + help), `module` (plugin trait), `modules/` (feature modules) | | `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing | | `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library | | `src/main.rs` | binary | Thin per-network connect/reconnect supervisor | @@ -18,7 +18,44 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: The layers are deliberately separable: `irc` knows nothing about the bot, `bot` knows nothing about how the process is launched, and `main` only supervises. -Adding a command is usually a one-file change in `src/bot/commands.rs`. +Adding a feature is a new **module** — see [Modules](#modules). + +## Modules + +Features are **modules**: self-contained units behind the `Module` trait +(`src/bot/module.rs`). A module declares the commands it provides and turns each +invocation into `Action`s — it never touches the socket, so it stays pure and +unit-testable: + +```rust +pub trait Module { + fn name(&self) -> &'static str; + fn commands(&self) -> &'static [CommandSpec]; + fn on_command(&mut self, cmd: &Command) -> Vec; +} +``` + +At startup the bot builds a `command → module` route table and auto-generates +`help` from every module's `CommandSpec`s. Each network gets its own fresh set +of modules, so stateful ones (like the dice roller's PRNG) keep per-network +state with no locking. + +**To add a module**, create `src/bot/modules/.rs` implementing `Module`, +then add one line to `all()` in `src/bot/modules/mod.rs`: + +```rust +pub fn all() -> Vec> { + vec![ + Box::new(builtins::Builtins), + Box::new(dice::Dice::new()), + Box::new(myfeature::MyFeature::new()), // <- your module + ] +} +``` + +Shipped modules: **`builtins`** (`ping`, `echo`, `hello`) and **`dice`** +(`roll [NdM]`). Module tests live in `tests/modules.rs` — construct a module and +assert on the `Action`s it returns, no network required. ## Build & run diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 95cc5df..7fff737 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -6,6 +6,7 @@ use std::io; +use super::module::{Action, Command}; use super::Bot; use crate::irc::{now_unix, parse_server_time, Message}; @@ -83,7 +84,8 @@ impl Bot { self.handle_command(&reply_to, sender, &command, &args) } - /// The command table. Add new bot commands here. + /// 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, @@ -91,22 +93,54 @@ impl Bot { 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)?; + 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)?, } - "help" => { - let p = &self.config.command_prefix; - let text = format!("commands: {p}ping, {p}echo , {p}hello, {p}help"); - self.conn.privmsg(reply_to, &text)?; - } - _ => {} // Unknown command: stay silent rather than spam channels. } 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. diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 036dfd4..69ae21b 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,10 +1,10 @@ //! Bot behavior: the [`Bot`] state, registration, and the event loop. //! -//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`] -//! and turns incoming [`Message`]s into actions. The behaviour is split across -//! sibling modules — capability negotiation and SASL live in [`caps`], and -//! message routing plus the command table live in [`commands`]. To add a -//! feature you usually only touch [`commands`]. +//! 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; @@ -14,8 +14,13 @@ use crate::irc::{Connection, Message}; mod caps; mod commands; +pub mod module; +pub mod modules; -/// The bot: owns its configuration and connection, and runs one session. +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, @@ -31,11 +36,31 @@ pub struct Bot { 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>, } 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() { + for spec in m.commands() { + if routes.insert(spec.name, i).is_some() { + crate::log(&format!( + "module '{}' overrides command '{}'", + m.name(), + spec.name + )); + } + } + } + Bot { config, conn, @@ -45,6 +70,8 @@ impl Bot { enabled_caps: HashSet::new(), batches: HashMap::new(), sasl_started: false, + modules: mods, + routes, } } diff --git a/src/bot/module.rs b/src/bot/module.rs new file mode 100644 index 0000000..05677bf --- /dev/null +++ b/src/bot/module.rs @@ -0,0 +1,71 @@ +//! 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 new file mode 100644 index 0000000..c21a068 --- /dev/null +++ b/src/bot/modules/builtins.rs @@ -0,0 +1,33 @@ +//! 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] = &[ + CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" }, + CommandSpec { name: "echo", usage: "echo ", about: "echo the text back" }, + CommandSpec { name: "hello", usage: "hello", about: "greet the sender" }, +]; + +impl Module for Builtins { + fn name(&self) -> &'static str { + "builtins" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let reply = match cmd.name { + "ping" => "pong".to_string(), + "echo" => cmd.args.join(" "), + "hello" => format!("hello, {}!", cmd.sender), + _ => return Vec::new(), + }; + vec![Action::Reply(reply)] + } +} diff --git a/src/bot/modules/dice.rs b/src/bot/modules/dice.rs new file mode 100644 index 0000000..33ad826 --- /dev/null +++ b/src/bot/modules/dice.rs @@ -0,0 +1,99 @@ +//! 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}; + +const COMMANDS: &[CommandSpec] = &[CommandSpec { + name: "roll", + usage: "roll [NdM]", + 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, +} + +impl Dice { + pub fn new() -> Dice { + let seed = SystemTime::now() + .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) + } + + fn next_u64(&mut self) -> u64 { + let mut x = self.rng; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.rng = x; + x + } + + /// Roll a single die in `1..=sides`. + fn die(&mut self, sides: u64) -> u64 { + self.next_u64() % sides + 1 + } +} + +impl Default for Dice { + fn default() -> Dice { + Dice::new() + } +} + +impl Module for Dice { + fn name(&self) -> &'static str { + "dice" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let spec = cmd.args.first().copied().unwrap_or("1d6"); + let reply = match parse_spec(spec) { + Some((count, sides)) => { + let rolls: Vec = (0..count).map(|_| self.die(sides)).collect(); + let total: u64 = rolls.iter().sum(); + if count == 1 { + format!("{count}d{sides}: {total}") + } else { + let parts: Vec = rolls.iter().map(u64::to_string).collect(); + format!("{count}d{sides}: {} = {total}", parts.join(" + ")) + } + } + None => format!("usage: {p}roll [NdM], e.g. {p}roll 2d6", p = cmd.prefix), + }; + vec![Action::Reply(reply)] + } +} + +/// 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)) => { + let count = if c.is_empty() { 1 } else { c.parse().ok()? }; + (count, m.parse().ok()?) + } + None => (1, s.parse().ok()?), + }; + if (1..=MAX_DICE).contains(&count) && (2..=MAX_SIDES).contains(&sides) { + Some((count, sides)) + } else { + None + } +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs new file mode 100644 index 0000000..d6f613a --- /dev/null +++ b/src/bot/modules/mod.rs @@ -0,0 +1,17 @@ +//! 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), + Box::new(dice::Dice::new()), + ] +} diff --git a/tests/modules.rs b/tests/modules.rs new file mode 100644 index 0000000..d15021d --- /dev/null +++ b/tests/modules.rs @@ -0,0 +1,87 @@ +//! 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}; + +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, + _ => panic!("expected exactly one Reply action"), + } +} + +#[test] +fn builtins_ping_pongs() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("ping", &[]))), "pong"); +} + +#[test] +fn builtins_echo_joins_args() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world"); +} + +#[test] +fn builtins_hello_greets_sender() { + let mut m = Builtins; + assert_eq!(reply(&m.on_command(&cmd("hello", &[]))), "hello, alice!"); +} + +#[test] +fn dice_declares_roll_command() { + let m = Dice::new(); + assert_eq!(m.name(), "dice"); + assert!(m.commands().iter().any(|c| c.name == "roll")); +} + +#[test] +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:?}"); + } +} + +#[test] +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:?}"); +} + +#[test] +fn dice_bare_number_means_one_die() { + let mut d = Dice::new(); + let r = reply(&d.on_command(&cmd("roll", &["20"]))).to_string(); + assert!(r.starts_with("1d20: "), "expected 1d20, got {r:?}"); +} + +#[test] +fn dice_rejects_garbage_with_usage() { + let mut d = Dice::new(); + let actions = d.on_command(&cmd("roll", &["banana"])); + let r = reply(&actions); + assert!(r.contains("usage"), "expected a usage hint, got {r:?}"); +} + +#[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")); +} From 52d7c13013eaf801acd21c82aca05d7496ca70b2 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 16:24:30 +0000 Subject: [PATCH 05/11] Remove code comments --- 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"); } From ebb3333e782b9fd77c976a895a731a6190ecbb34 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 17:32:33 +0000 Subject: [PATCH 06/11] Add weather module --- .gitignore | 3 +- README.md | 6 +- src/bot/mod.rs | 2 +- src/bot/modules/mod.rs | 4 +- src/bot/modules/weather.rs | 262 +++++++++++++++++++++++++++++++++++++ tests/weather.rs | 67 ++++++++++ 6 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/bot/modules/weather.rs create mode 100644 tests/weather.rs diff --git a/.gitignore b/.gitignore index 49ac0ec..af888df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target Cargo.lock rustbot.service -rustbot.conf \ No newline at end of file +rustbot.conf +weather.*.json \ No newline at end of file diff --git a/README.md b/README.md index 1afc243..92f4327 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,10 @@ pub fn all() -> Vec> { } ``` -Shipped modules: **`builtins`** (`ping`, `echo`, `hello`) and **`dice`** -(`roll [NdM]`). Module tests live in `tests/modules.rs` — construct a module and +Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`** +(`roll [NdM]`), and **`weather`** (`add ` then `w [location]` via +wttr.in; per-user locations saved to a per-network JSON file, path from +`RUSTBOT_DATA_DIR`). Module tests live in `tests/` — construct a module and assert on the `Action`s it returns, no network required. ## Build & run diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 8568669..6bb98e0 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -28,7 +28,7 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); - let mods = modules::all(); + let mods = modules::all(&config.name); let mut routes: HashMap<&'static str, usize> = HashMap::new(); for (i, m) in mods.iter().enumerate() { for spec in m.commands() { diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 9dd67f5..c12f042 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,11 +1,13 @@ pub mod builtins; pub mod dice; +pub mod weather; use super::module::Module; -pub fn all() -> Vec> { +pub fn all(network: &str) -> Vec> { vec![ Box::new(builtins::Builtins), Box::new(dice::Dice::new()), + Box::new(weather::Weather::new(network)), ] } diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs new file mode 100644 index 0000000..2ff4263 --- /dev/null +++ b/src/bot/modules/weather.rs @@ -0,0 +1,262 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, Read, Write}; +use std::iter::Peekable; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::str::Chars; +use std::time::Duration; + +use native_tls::TlsConnector; + +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { name: "add", usage: "add ", about: "save your weather location" }, + CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" }, +]; + +pub struct Weather { + store: Store, + path: PathBuf, +} + +impl Weather { + pub fn new(network: &str) -> Weather { + let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); + let safe: String = network + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + Weather::with_path(PathBuf::from(dir).join(format!("weather.{safe}.json"))) + } + + pub fn with_path(path: PathBuf) -> Weather { + Weather { store: Store::load(&path), path } + } + + pub fn location_of(&self, nick: &str) -> Option<&str> { + self.store.get(nick).map(String::as_str) + } +} + +impl Module for Weather { + fn name(&self) -> &'static str { + "weather" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + match cmd.name { + "add" => { + if cmd.args.is_empty() { + return vec![Action::reply(format!("usage: {}add ", cmd.prefix))]; + } + let loc = cmd.args.join(" "); + self.store.set(cmd.sender, &loc); + if let Err(e) = self.store.save(&self.path) { + crate::log(&format!("weather: could not save {}: {e}", self.path.display())); + return vec![Action::reply(format!("{}: could not save location", cmd.sender))]; + } + vec![Action::reply(format!("{}: saved {loc} — try {}w", cmd.sender, cmd.prefix))] + } + "w" => { + let loc = if !cmd.args.is_empty() { + cmd.args.join(" ") + } else if let Some(l) = self.store.get(cmd.sender) { + l.clone() + } else { + return vec![Action::reply(format!( + "{}: no location set — {}add first", + cmd.sender, cmd.prefix + ))]; + }; + match fetch_weather(&loc) { + Ok(text) => vec![Action::reply(text)], + Err(e) => vec![Action::reply(format!("weather unavailable: {e}"))], + } + } + _ => vec![], + } + } +} + +struct Store { + map: BTreeMap, +} + +impl Store { + fn load(path: &Path) -> Store { + match fs::read_to_string(path) { + Ok(s) => Store { map: parse_db(&s) }, + Err(_) => Store { map: BTreeMap::new() }, + } + } + + fn save(&self, path: &Path) -> io::Result<()> { + fs::write(path, to_json(&self.map) + "\n") + } + + fn get(&self, nick: &str) -> Option<&String> { + self.map.get(&nick.to_lowercase()) + } + + fn set(&mut self, nick: &str, loc: &str) { + self.map.insert(nick.to_lowercase(), loc.to_string()); + } +} + +fn fetch_weather(location: &str) -> Result { + let path = format!("/{}?format=%l:+%C+%t+feels+%f+%w+%h&m", url_encode(location)); + let body = https_get("wttr.in", &path)?; + let line: String = body + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .chars() + .take(250) + .collect(); + if line.is_empty() { + return Err("no data".to_string()); + } + Ok(line) +} + +fn https_get(host: &str, path: &str) -> Result { + let addr = (host, 443) + .to_socket_addrs() + .map_err(|e| e.to_string())? + .next() + .ok_or_else(|| "dns lookup failed".to_string())?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let connector = TlsConnector::new().map_err(|e| e.to_string())?; + let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; + + let req = format!( + "GET {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: curl/rubot\r\nAccept: text/plain\r\nConnection: close\r\n\r\n" + ); + tls.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + + let mut buf = Vec::new(); + tls.read_to_end(&mut buf).map_err(|e| e.to_string())?; + let text = String::from_utf8_lossy(&buf); + match text.split_once("\r\n\r\n") { + Some((_, body)) => Ok(body.to_string()), + None => Err("malformed response".to_string()), + } +} + +fn url_encode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +pub fn to_json(map: &BTreeMap) -> String { + let items: Vec = map + .iter() + .map(|(k, v)| format!("\"{}\":\"{}\"", json_escape(k), json_escape(v))) + .collect(); + format!("{{{}}}", items.join(",")) +} + +pub fn parse_db(s: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut chars = s.chars().peekable(); + skip_ws(&mut chars); + if chars.next() != Some('{') { + return map; + } + loop { + skip_ws(&mut chars); + match chars.peek() { + Some('"') => {} + Some(',') => { + chars.next(); + continue; + } + _ => break, + } + let Some(key) = parse_string(&mut chars) else { break }; + skip_ws(&mut chars); + if chars.next() != Some(':') { + break; + } + skip_ws(&mut chars); + let Some(value) = parse_string(&mut chars) else { break }; + map.insert(key, value); + } + map +} + +fn skip_ws(chars: &mut Peekable) { + while let Some(c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + } else { + break; + } + } +} + +fn parse_string(chars: &mut Peekable) -> Option { + if chars.next() != Some('"') { + return None; + } + let mut out = String::new(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(out), + '\\' => match chars.next()? { + '"' => out.push('"'), + '\\' => out.push('\\'), + '/' => out.push('/'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + 'b' => out.push('\u{08}'), + 'f' => out.push('\u{0C}'), + 'u' => { + let mut code = 0u32; + for _ in 0..4 { + code = code * 16 + chars.next()?.to_digit(16)?; + } + if let Some(ch) = char::from_u32(code) { + out.push(ch); + } + } + other => out.push(other), + }, + c => out.push(c), + } + } + None +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} diff --git a/tests/weather.rs b/tests/weather.rs new file mode 100644 index 0000000..2b4c303 --- /dev/null +++ b/tests/weather.rs @@ -0,0 +1,67 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use rustbot::bot::module::{Action, Command, Module}; +use rustbot::bot::modules::weather::{parse_db, to_json, Weather}; + +fn cmd<'a>(sender: &'a str, name: &'a str, args: &'a [&'a str]) -> Command<'a> { + Command { sender, reply_to: "#chan", name, args, prefix: ">", network: "test" } +} + +fn reply(actions: &[Action]) -> &str { + match actions { + [Action::Reply(s)] => s, + _ => panic!("expected exactly one Reply"), + } +} + +fn temp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("rubot-test-{name}.json")); + let _ = std::fs::remove_file(&p); + p +} + +#[test] +fn json_round_trip_handles_special_chars() { + let mut m = BTreeMap::new(); + m.insert("alice".to_string(), "73700".to_string()); + m.insert("bob".to_string(), "Saint-Étienne, FR".to_string()); + m.insert("carol".to_string(), "a\"b\\c\nd".to_string()); + let back = parse_db(&to_json(&m)); + assert_eq!(m, back); +} + +#[test] +fn parse_db_tolerates_junk() { + assert!(parse_db("").is_empty()); + assert!(parse_db("not json").is_empty()); + assert_eq!(parse_db("{}").len(), 0); + assert_eq!(parse_db("{\"a\":\"b\"}").get("a").map(String::as_str), Some("b")); +} + +#[test] +fn add_saves_and_persists_case_insensitively() { + let path = temp("weather-add"); + let mut w = Weather::with_path(path.clone()); + let r = reply(&w.on_command(&cmd("Alice", "add", &["73700"]))).to_string(); + assert!(r.contains("73700"), "reply: {r:?}"); + + let w2 = Weather::with_path(path.clone()); + assert_eq!(w2.location_of("alice"), Some("73700")); + assert_eq!(w2.location_of("ALICE"), Some("73700")); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn add_without_arg_shows_usage() { + let mut w = Weather::with_path(temp("weather-usage")); + let r = reply(&w.on_command(&cmd("alice", "add", &[]))).to_string(); + assert!(r.contains("usage"), "reply: {r:?}"); +} + +#[test] +fn w_without_saved_location_hints_to_add() { + let mut w = Weather::with_path(temp("weather-hint")); + let r = reply(&w.on_command(&cmd("nobody", "w", &[]))).to_string(); + assert!(r.contains("add"), "reply: {r:?}"); +} From 7fefcbac46509ca950646b7528940ca0070b6123 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 18:08:43 +0000 Subject: [PATCH 07/11] Retry weather fetch on transient failures --- src/bot/modules/weather.rs | 93 ++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 23 deletions(-) diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs index 2ff4263..0f92267 100644 --- a/src/bot/modules/weather.rs +++ b/src/bot/modules/weather.rs @@ -111,46 +111,93 @@ impl Store { fn fetch_weather(location: &str) -> Result { let path = format!("/{}?format=%l:+%C+%t+feels+%f+%w+%h&m", url_encode(location)); - let body = https_get("wttr.in", &path)?; - let line: String = body - .lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .unwrap_or("") - .chars() - .take(250) - .collect(); - if line.is_empty() { - return Err("no data".to_string()); + let mut last = "no response".to_string(); + for attempt in 0..3 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(250)); + } + match https_get("wttr.in", &path) { + Ok(body) => { + let line: String = body + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .chars() + .take(250) + .collect(); + if !line.is_empty() { + return Ok(line); + } + last = "empty response".to_string(); + } + Err(e) => { + let retry = e.retry; + last = e.msg; + if !retry { + break; + } + } + } } - Ok(line) + Err(last) } -fn https_get(host: &str, path: &str) -> Result { +struct HttpErr { + msg: String, + retry: bool, +} + +fn https_get(host: &str, path: &str) -> Result { let addr = (host, 443) .to_socket_addrs() - .map_err(|e| e.to_string())? + .map_err(|e| HttpErr { msg: e.to_string(), retry: false })? .next() - .ok_or_else(|| "dns lookup failed".to_string())?; - let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|e| e.to_string())?; + .ok_or(HttpErr { msg: "dns lookup failed".to_string(), retry: false })?; + let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(4)) + .map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?; stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); - let connector = TlsConnector::new().map_err(|e| e.to_string())?; - let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?; + let connector = TlsConnector::new().map_err(|e| HttpErr { msg: e.to_string(), retry: false })?; + let mut tls = connector + .connect(host, stream) + .map_err(|e| HttpErr { msg: e.to_string(), retry: true })?; let req = format!( "GET {path} HTTP/1.0\r\nHost: {host}\r\nUser-Agent: curl/rubot\r\nAccept: text/plain\r\nConnection: close\r\n\r\n" ); - tls.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + tls.write_all(req.as_bytes()) + .map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?; let mut buf = Vec::new(); - tls.read_to_end(&mut buf).map_err(|e| e.to_string())?; + tls.read_to_end(&mut buf) + .map_err(|e| HttpErr { retry: retryable_io(&e), msg: e.to_string() })?; + let text = String::from_utf8_lossy(&buf); - match text.split_once("\r\n\r\n") { - Some((_, body)) => Ok(body.to_string()), - None => Err("malformed response".to_string()), + let Some((head, body)) = text.split_once("\r\n\r\n") else { + return Err(HttpErr { msg: "malformed response".to_string(), retry: true }); + }; + let code = head + .lines() + .next() + .unwrap_or("") + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .unwrap_or(0); + if !(200..300).contains(&code) { + return Err(HttpErr { retry: code == 429 || code >= 500, msg: format!("http {code}") }); } + Ok(body.to_string()) +} + +fn retryable_io(e: &io::Error) -> bool { + use io::ErrorKind::{BrokenPipe, ConnectionAborted, ConnectionRefused, ConnectionReset, UnexpectedEof}; + matches!( + e.kind(), + ConnectionReset | ConnectionAborted | ConnectionRefused | BrokenPipe | UnexpectedEof + ) } fn url_encode(s: &str) -> String { From 5e0ca7232ae2c42ba2180f0345249b80063dbeb2 Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 18:14:51 +0000 Subject: [PATCH 08/11] Friendlier weather output --- src/bot/modules/weather.rs | 33 ++++++++++++++++++++++----------- tests/weather.rs | 18 +++++++++++++++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs index 0f92267..4acbe8d 100644 --- a/src/bot/modules/weather.rs +++ b/src/bot/modules/weather.rs @@ -110,7 +110,7 @@ impl Store { } fn fetch_weather(location: &str) -> Result { - let path = format!("/{}?format=%l:+%C+%t+feels+%f+%w+%h&m", url_encode(location)); + let path = format!("/{}?format=%l|%c|%C|%t|%f|%w|%h&m", url_encode(location)); let mut last = "no response".to_string(); for attempt in 0..3 { if attempt > 0 { @@ -118,16 +118,9 @@ fn fetch_weather(location: &str) -> Result { } match https_get("wttr.in", &path) { Ok(body) => { - let line: String = body - .lines() - .map(str::trim) - .find(|l| !l.is_empty()) - .unwrap_or("") - .chars() - .take(250) - .collect(); - if !line.is_empty() { - return Ok(line); + let raw = body.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); + if !raw.is_empty() { + return Ok(format_weather(raw, location)); } last = "empty response".to_string(); } @@ -143,6 +136,24 @@ fn fetch_weather(location: &str) -> Result { Err(last) } +pub fn format_weather(raw: &str, location: &str) -> String { + let f: Vec<&str> = raw.split('|').map(str::trim).collect(); + if f.len() >= 7 { + let temp = f[3].trim_start_matches('+'); + let feels = f[4].trim_start_matches('+'); + let msg = format!( + "{} {}: {}, {} (feels like {}) · 💨 {} · 💧 {}", + f[1], f[0], f[2], temp, feels, f[5], f[6] + ); + return msg.chars().take(250).collect(); + } + let low = raw.to_lowercase(); + if low.contains("not found") || low.contains("unknown location") { + return format!("couldn't find '{location}' — try a city name or postcode"); + } + raw.chars().take(250).collect() +} + struct HttpErr { msg: String, retry: bool, diff --git a/tests/weather.rs b/tests/weather.rs index 2b4c303..9744f06 100644 --- a/tests/weather.rs +++ b/tests/weather.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use rustbot::bot::module::{Action, Command, Module}; -use rustbot::bot::modules::weather::{parse_db, to_json, Weather}; +use rustbot::bot::modules::weather::{format_weather, parse_db, to_json, Weather}; fn cmd<'a>(sender: &'a str, name: &'a str, args: &'a [&'a str]) -> Command<'a> { Command { sender, reply_to: "#chan", name, args, prefix: ">", network: "test" } @@ -65,3 +65,19 @@ fn w_without_saved_location_hints_to_add() { let r = reply(&w.on_command(&cmd("nobody", "w", &[]))).to_string(); assert!(r.contains("add"), "reply: {r:?}"); } + +#[test] +fn format_weather_builds_friendly_line() { + let out = format_weather("73700|☀️ |Sunny|+23°C|+18°C|↘8km/h|45%", "73700"); + assert!(out.contains("☀️"), "{out}"); + assert!(out.contains("73700: Sunny"), "{out}"); + assert!(out.contains("23°C") && !out.contains("+23"), "{out}"); + assert!(out.contains("feels like 18°C"), "{out}"); + assert!(out.contains("45%"), "{out}"); +} + +#[test] +fn format_weather_friendly_not_found() { + let out = format_weather("location not found: upstream error", "zzz"); + assert!(out.contains("couldn't find 'zzz'"), "{out}"); +} From 1c320ad0c3f187bac3552c193ed916bfa7f5c95b Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 18:29:56 +0000 Subject: [PATCH 09/11] Port skybot hash plugin as a module --- README.md | 9 +- src/bot/modules/hash.rs | 250 ++++++++++++++++++++++++++++++++++++++++ src/bot/modules/mod.rs | 2 + tests/hash.rs | 76 ++++++++++++ 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 src/bot/modules/hash.rs create mode 100644 tests/hash.rs diff --git a/README.md b/README.md index 92f4327..0958c4e 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,11 @@ pub fn all() -> Vec> { ``` Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`** -(`roll [NdM]`), and **`weather`** (`add ` then `w [location]` via -wttr.in; per-user locations saved to a per-network JSON file, path from -`RUSTBOT_DATA_DIR`). Module tests live in `tests/` — construct a module and -assert on the `Action`s it returns, no network required. +(`roll [NdM]`), **`hash`** (`md5`/`sha1`/`sha256`/`hash `, hand-rolled), +and **`weather`** (`add ` then `w [location]` via wttr.in; per-user +locations saved to a per-network JSON file, path from `RUSTBOT_DATA_DIR`). +Module tests live in `tests/` — construct a module and assert on the `Action`s +it returns, no network required. ## Build & run diff --git a/src/bot/modules/hash.rs b/src/bot/modules/hash.rs new file mode 100644 index 0000000..985db90 --- /dev/null +++ b/src/bot/modules/hash.rs @@ -0,0 +1,250 @@ +use crate::bot::module::{Action, Command, CommandSpec, Module}; + +pub struct Hash; + +const COMMANDS: &[CommandSpec] = &[ + CommandSpec { name: "md5", usage: "md5 ", about: "MD5 hex digest of " }, + CommandSpec { name: "sha1", usage: "sha1 ", about: "SHA1 hex digest of " }, + CommandSpec { name: "sha256", usage: "sha256 ", about: "SHA256 hex digest of " }, + CommandSpec { name: "hash", usage: "hash ", about: "md5, sha1 and sha256 of " }, +]; + +impl Module for Hash { + fn name(&self) -> &'static str { + "hash" + } + + fn commands(&self) -> &'static [CommandSpec] { + COMMANDS + } + + fn on_command(&mut self, cmd: &Command) -> Vec { + let input = cmd.args.join(" "); + if input.is_empty() { + return vec![Action::reply(format!("usage: {}{} ", cmd.prefix, cmd.name))]; + } + let b = input.as_bytes(); + let reply = match cmd.name { + "md5" => md5(b), + "sha1" => sha1(b), + "sha256" => sha256(b), + "hash" => format!("md5: {}, sha1: {}, sha256: {}", md5(b), sha1(b), sha256(b)), + _ => return vec![], + }; + vec![Action::reply(reply)] + } +} + +fn pad(data: &[u8], little_endian_len: bool) -> Vec { + let bits = (data.len() as u64).wrapping_mul(8); + let mut m = data.to_vec(); + m.push(0x80); + while m.len() % 64 != 56 { + m.push(0); + } + if little_endian_len { + m.extend_from_slice(&bits.to_le_bytes()); + } else { + m.extend_from_slice(&bits.to_be_bytes()); + } + m +} + +fn to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +pub fn md5(data: &[u8]) -> String { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + + let (mut a0, mut b0, mut c0, mut d0) = + (0x67452301u32, 0xefcdab89u32, 0x98badcfeu32, 0x10325476u32); + let msg = pad(data, true); + for chunk in msg.chunks(64) { + let mut m = [0u32; 16]; + for (i, word) in m.iter_mut().enumerate() { + *word = u32::from_le_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64 { + let (f, g) = if i < 16 { + ((b & c) | (!b & d), i) + } else if i < 32 { + ((d & b) | (!d & c), (5 * i + 1) % 16) + } else if i < 48 { + (b ^ c ^ d, (3 * i + 5) % 16) + } else { + (c ^ (b | !d), (7 * i) % 16) + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + + let mut out = Vec::with_capacity(16); + for v in [a0, b0, c0, d0] { + out.extend_from_slice(&v.to_le_bytes()); + } + to_hex(&out) +} + +pub fn sha1(data: &[u8]) -> String { + let (mut h0, mut h1, mut h2, mut h3, mut h4) = ( + 0x67452301u32, + 0xEFCDAB89u32, + 0x98BADCFEu32, + 0x10325476u32, + 0xC3D2E1F0u32, + ); + let msg = pad(data, false); + for chunk in msg.chunks(64) { + let mut w = [0u32; 80]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let (mut a, mut b, mut c, mut d, mut e) = (h0, h1, h2, h3, h4); + for (i, &word) in w.iter().enumerate() { + let (f, k) = if i < 20 { + ((b & c) | (!b & d), 0x5A827999u32) + } else if i < 40 { + (b ^ c ^ d, 0x6ED9EBA1u32) + } else if i < 60 { + ((b & c) | (b & d) | (c & d), 0x8F1BBCDCu32) + } else { + (b ^ c ^ d, 0xCA62C1D6u32) + }; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + let mut out = Vec::with_capacity(20); + for v in [h0, h1, h2, h3, h4] { + out.extend_from_slice(&v.to_be_bytes()); + } + to_hex(&out) +} + +pub fn sha256(data: &[u8]) -> String { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + let msg = pad(data, false); + for chunk in msg.chunks(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let mut v = h; + for i in 0..64 { + let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25); + let ch = (v[4] & v[5]) ^ (!v[4] & v[6]); + let temp1 = v[7] + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22); + let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]); + let temp2 = s0.wrapping_add(maj); + v[7] = v[6]; + v[6] = v[5]; + v[5] = v[4]; + v[4] = v[3].wrapping_add(temp1); + v[3] = v[2]; + v[2] = v[1]; + v[1] = v[0]; + v[0] = temp1.wrapping_add(temp2); + } + for i in 0..8 { + h[i] = h[i].wrapping_add(v[i]); + } + } + + let mut out = Vec::with_capacity(32); + for v in h { + out.extend_from_slice(&v.to_be_bytes()); + } + to_hex(&out) +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index c12f042..fdf7052 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,5 +1,6 @@ pub mod builtins; pub mod dice; +pub mod hash; pub mod weather; use super::module::Module; @@ -8,6 +9,7 @@ pub fn all(network: &str) -> Vec> { vec![ Box::new(builtins::Builtins), Box::new(dice::Dice::new()), + Box::new(hash::Hash), Box::new(weather::Weather::new(network)), ] } diff --git a/tests/hash.rs b/tests/hash.rs new file mode 100644 index 0000000..49b9351 --- /dev/null +++ b/tests/hash.rs @@ -0,0 +1,76 @@ +use rustbot::bot::module::{Action, Command, Module}; +use rustbot::bot::modules::hash::{md5, sha1, sha256, Hash}; + +fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> { + Command { sender: "u", reply_to: "#c", name, args, prefix: ">", network: "t" } +} + +fn reply(actions: &[Action]) -> &str { + match actions { + [Action::Reply(s)] => s, + _ => panic!("expected exactly one Reply"), + } +} + +const MULTI: &[u8] = b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"; + +#[test] +fn md5_vectors() { + assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!( + md5(b"The quick brown fox jumps over the lazy dog"), + "9e107d9d372bb6826bd81d3542a419d6" + ); + assert_eq!( + md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), + "57edf4a22be3c955ac49da2e2107b67a" + ); +} + +#[test] +fn sha1_vectors() { + assert_eq!(sha1(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!(sha1(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + assert_eq!(sha1(MULTI), "84983e441c3bd26ebaae4aa1f95129e5e54670f1"); +} + +#[test] +fn sha256_vectors() { + assert_eq!( + sha256(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256(MULTI), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); +} + +#[test] +fn module_single_hash() { + let mut m = Hash; + assert_eq!( + reply(&m.on_command(&cmd("sha256", &["abc"]))), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); +} + +#[test] +fn module_all_hashes() { + let mut m = Hash; + let r = reply(&m.on_command(&cmd("hash", &["abc"]))).to_string(); + assert!(r.contains("md5: 900150983cd24fb0d6963f7d28e17f72"), "{r}"); + assert!(r.contains("sha1: a9993e364706816aba3e25717850c26c9cd0d89d"), "{r}"); + assert!(r.contains("sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), "{r}"); +} + +#[test] +fn module_usage_when_empty() { + let mut m = Hash; + assert!(reply(&m.on_command(&cmd("md5", &[]))).contains("usage")); +} From 9325150315d5ef071c466ec560b6518a4781801f Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 18:46:10 +0000 Subject: [PATCH 10/11] Harden bot: line cap, send throttle, panic isolation, verbose flag, weather cache, tests --- src/bot/commands.rs | 10 ++++- src/bot/modules/weather.rs | 20 ++++++++-- src/config.rs | 6 +++ src/irc/connection.rs | 57 ++++++++++++++++++++++++++-- src/lib.rs | 2 + src/main.rs | 2 +- tests/bot.rs | 78 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 tests/bot.rs diff --git a/src/bot/commands.rs b/src/bot/commands.rs index cd65995..2b26c1d 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -86,7 +86,15 @@ impl Bot { prefix: &self.config.command_prefix, network: &self.config.name, }; - let actions = self.modules[idx].on_command(&cmd); + let actions = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.modules[idx].on_command(&cmd) + })) { + Ok(actions) => actions, + Err(_) => { + crate::log(&format!("module panicked on '{command}'")); + return Ok(()); + } + }; for action in actions { match action { Action::Reply(text) => self.conn.privmsg(reply_to, &text)?, diff --git a/src/bot/modules/weather.rs b/src/bot/modules/weather.rs index 4acbe8d..d8b5c06 100644 --- a/src/bot/modules/weather.rs +++ b/src/bot/modules/weather.rs @@ -1,16 +1,18 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io::{self, Read, Write}; use std::iter::Peekable; use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::str::Chars; -use std::time::Duration; +use std::time::{Duration, Instant}; use native_tls::TlsConnector; use crate::bot::module::{Action, Command, CommandSpec, Module}; +const CACHE_TTL: Duration = Duration::from_secs(600); + const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "add", usage: "add ", about: "save your weather location" }, CommandSpec { name: "w", usage: "w [location]", about: "weather for your saved location" }, @@ -19,6 +21,7 @@ const COMMANDS: &[CommandSpec] = &[ pub struct Weather { store: Store, path: PathBuf, + cache: HashMap, } impl Weather { @@ -32,7 +35,7 @@ impl Weather { } pub fn with_path(path: PathBuf) -> Weather { - Weather { store: Store::load(&path), path } + Weather { store: Store::load(&path), path, cache: HashMap::new() } } pub fn location_of(&self, nick: &str) -> Option<&str> { @@ -74,8 +77,17 @@ impl Module for Weather { cmd.sender, cmd.prefix ))]; }; + let key = loc.to_lowercase(); + if let Some((at, text)) = self.cache.get(&key) { + if at.elapsed() < CACHE_TTL { + return vec![Action::reply(text.clone())]; + } + } match fetch_weather(&loc) { - Ok(text) => vec![Action::reply(text)], + Ok(text) => { + self.cache.insert(key, (Instant::now(), text.clone())); + vec![Action::reply(text)] + } Err(e) => vec![Action::reply(format!("weather unavailable: {e}"))], } } diff --git a/src/config.rs b/src/config.rs index 1c7011c..af5289e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub realname: String, pub channels: Vec, pub command_prefix: String, + pub verbose: bool, pub password: Option, pub sasl_user: Option, pub sasl_pass: Option, @@ -31,6 +32,7 @@ impl Default for Config { realname: "rubot".to_string(), channels: vec!["#devs".to_string()], command_prefix: "!".to_string(), + verbose: true, password: None, sasl_user: None, sasl_pass: None, @@ -146,6 +148,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { "realname" => c.realname = value.to_string(), "channels" => c.channels = split_list(value), "prefix" | "command_prefix" => c.command_prefix = value.to_string(), + "verbose" => c.verbose = parse_bool(value), "password" => c.password = (!value.is_empty()).then(|| value.to_string()), "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), @@ -194,6 +197,9 @@ fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_PREFIX") { c.command_prefix = v; } + if let Ok(v) = std::env::var("IRC_VERBOSE") { + c.verbose = parse_bool(&v); + } if let Ok(v) = std::env::var("IRC_PASSWORD") { c.password = Some(v); } diff --git a/src/irc/connection.rs b/src/irc/connection.rs index 8c46ba6..ada8eda 100644 --- a/src/irc/connection.rs +++ b/src/irc/connection.rs @@ -1,20 +1,27 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::TcpStream; +use std::time::{Duration, Instant}; use native_tls::TlsConnector; use super::Message; +const MAX_LINE: usize = 500; +const SEND_THROTTLE: Duration = Duration::from_millis(300); + trait Stream: Read + Write {} impl Stream for T {} pub struct Connection { inner: BufReader>, log_prefix: String, + verbose: bool, + throttle: Duration, + last_send: Option, } impl Connection { - pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result { + pub fn connect(host: &str, port: u16, use_tls: bool, verbose: bool) -> io::Result { let tcp = TcpStream::connect((host, port))?; let stream: Box = if use_tls { let connector = @@ -26,7 +33,24 @@ impl Connection { } else { Box::new(tcp) }; - Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() }) + Ok(Connection { + inner: BufReader::new(stream), + log_prefix: String::new(), + verbose, + throttle: SEND_THROTTLE, + last_send: None, + }) + } + + pub fn from_stream(stream: S) -> Connection { + let boxed: Box = Box::new(stream); + Connection { + inner: BufReader::new(boxed), + log_prefix: String::new(), + verbose: false, + throttle: Duration::ZERO, + last_send: None, + } } pub fn set_label(&mut self, label: &str) { @@ -47,7 +71,9 @@ impl Connection { if trimmed.is_empty() { continue; } - eprintln!("{}<< {trimmed}", self.log_prefix); + if self.verbose { + eprintln!("{}<< {trimmed}", self.log_prefix); + } if let Some(msg) = Message::parse(trimmed) { return Ok(Some(msg)); } @@ -55,8 +81,20 @@ impl Connection { } pub fn send_raw(&mut self, line: &str) -> io::Result<()> { + if !self.throttle.is_zero() { + if let Some(last) = self.last_send { + let elapsed = last.elapsed(); + if elapsed < self.throttle { + std::thread::sleep(self.throttle - elapsed); + } + } + self.last_send = Some(Instant::now()); + } let clean = line.replace(['\r', '\n'], " "); - eprintln!("{}>> {clean}", self.log_prefix); + let clean = truncate_to(&clean, MAX_LINE); + if self.verbose { + eprintln!("{}>> {clean}", self.log_prefix); + } let writer = self.inner.get_mut(); write!(writer, "{clean}\r\n")?; writer.flush() @@ -110,3 +148,14 @@ impl Connection { self.send_raw(&format!("AUTHENTICATE {payload}")) } } + +fn truncate_to(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} diff --git a/src/lib.rs b/src/lib.rs index 70bc600..4926187 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + pub mod bot; pub mod config; pub mod irc; diff --git a/src/main.rs b/src/main.rs index 2782d81..f85853b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,7 +61,7 @@ fn supervise(config: Config, labelled: bool) { } fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> { - let mut conn = Connection::connect(&config.server, config.port, config.tls)?; + let mut conn = Connection::connect(&config.server, config.port, config.tls, config.verbose)?; if labelled { conn.set_label(&config.name); } diff --git a/tests/bot.rs b/tests/bot.rs new file mode 100644 index 0000000..fd6d040 --- /dev/null +++ b/tests/bot.rs @@ -0,0 +1,78 @@ +use std::io::{self, Cursor, Read, Write}; +use std::sync::{Arc, Mutex}; + +use rustbot::bot::Bot; +use rustbot::config::Config; +use rustbot::irc::Connection; + +struct Mock { + input: Cursor>, + output: Arc>>, +} + +impl Read for Mock { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.input.read(buf) + } +} + +impl Write for Mock { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.output.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn run(server: &str) -> String { + let output = Arc::new(Mutex::new(Vec::new())); + let mock = Mock { + input: Cursor::new(server.as_bytes().to_vec()), + output: output.clone(), + }; + let conn = Connection::from_stream(mock); + let mut bot = Bot::new(Config::default(), conn); + let _ = bot.run(); + let bytes = output.lock().unwrap().clone(); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn sends_registration() { + let out = run(""); + assert!(out.contains("CAP LS"), "{out}"); + assert!(out.contains("NICK rubot"), "{out}"); + assert!(out.contains("USER rubot"), "{out}"); +} + +#[test] +fn answers_ping() { + let out = run("PING :tok123\r\n"); + assert!(out.contains("PONG :tok123"), "{out}"); +} + +#[test] +fn joins_configured_channels_on_welcome() { + let out = run(":srv 001 rubot :welcome\r\n"); + assert!(out.contains("JOIN #devs"), "{out}"); +} + +#[test] +fn appends_underscore_on_nick_in_use() { + let out = run(":srv 433 * rubot :nickname is already in use\r\n"); + assert!(out.contains("NICK rubot_"), "{out}"); +} + +#[test] +fn dispatches_command_to_module() { + let out = run(":alice!a@h PRIVMSG #chan :!ping\r\n"); + assert!(out.contains("PRIVMSG #chan :pong"), "{out}"); +} + +#[test] +fn ignores_own_echoed_message() { + let out = run(":rubot!r@h PRIVMSG #chan :!ping\r\n"); + assert!(!out.contains(":pong"), "{out}"); +} From facbde55bd1c94bb1e212cb74125fcc5c55ef0ae Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 29 Jul 2026 18:48:41 +0000 Subject: [PATCH 11/11] Add Forgejo CI (build, test, fmt, clippy) --- .forgejo/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .forgejo/workflows/ci.yml diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..8f28122 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: system deps + run: apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev curl ca-certificates build-essential + - name: install rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal -c clippy -c rustfmt + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: test + run: cargo test --verbose + - name: format + continue-on-error: true + run: cargo fmt --check + - name: clippy + continue-on-error: true + run: cargo clippy --all-targets -- -D warnings