From 05c004d32da0add16c67f5c21e682f8bff174e6c Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 28 Jul 2026 14:29:34 +0000 Subject: [PATCH] rust bot --- .gitignore | 4 + Cargo.toml | 19 +++ README.md | 109 ++++++++++++++ src/bot.rs | 343 ++++++++++++++++++++++++++++++++++++++++++ src/irc.rs | 421 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 166 +++++++++++++++++++++ 6 files changed, 1062 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/bot.rs create mode 100644 src/irc.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49ac0ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +rustbot.service +rustbot.conf \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2d66b3c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "rustbot" +version = "0.1.0" +edition = "2021" +description = "A modular, dependency-free IRC bot in Rust with a hand-rolled IRC protocol layer" +license = "MIT" +authors = ["a.srenov"] + +[[bin]] +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/ +[dependencies] + +[profile.release] +lto = true +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..4df5d4a --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# rustbot + +A small, modular IRC bot in Rust — **zero dependencies**, with its own IRC +protocol layer built to the [modern IRC client spec](https://modern.ircdocs.horse/). + +## 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 | Config from env vars, connect/reconnect supervisor | + +The layers are deliberately separable: `irc.rs` knows nothing about the bot, and +`bot.rs` knows nothing about how the process is launched. + +## Build & run + +```sh +cargo build # or: cargo build --release +cargo test # unit tests for the message parser +cargo run +``` + +## Configuration + +Settings are layered, each overriding the previous: + +1. **Built-in defaults** (`Config::default()` in `src/bot.rs`) +2. **A config file** — `key = value`, see [`rustbot.conf`](rustbot.conf) +3. **Environment variables** — handy for one-off overrides + +The config file is found via: the **first CLI argument**, else `RUSTBOT_CONFIG`, +else `./rustbot.conf` if present. + +```sh +cargo run # uses ./rustbot.conf +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`. +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`. + +```sh +IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run +``` + +> If you put a real `password` in `rustbot.conf`, add it to `.gitignore` so the +> secret isn't committed. + +## Built-in commands + +With the default `!` prefix: + +- `!ping` → `pong` +- `!echo ` → echoes text back +- `!hello` → greets the sender +- `!help` → lists commands + +Add your own in `Bot::handle_command` in `src/bot.rs`. + +## Deployment (systemd) + +Runs as a system service (`User=debian`), auto-restarts on crash, and starts on +boot. The unit is kept in the repo as [`rustbot.service`](rustbot.service). + +```sh +cargo build --release +sudo cp rustbot.service /etc/systemd/system/rustbot.service +sudo systemctl daemon-reload +sudo systemctl enable --now rustbot # start now + on boot + +systemctl status rustbot # health +journalctl -u rustbot -f # live logs (<< in, >> out) +sudo systemctl restart rustbot # after a rebuild +``` + +One-off overrides without touching `rustbot.conf`: put `IRC_*` lines in +`/etc/default/rustbot` (env wins over the config file), then restart. + +## IRCv3 + +The bot negotiates capabilities (`CAP LS 302` → `CAP REQ` → `CAP END`) and +enables everything useful the server offers: `message-tags`, `server-time`, +`batch`, `echo-message`, `account-tag`, `extended-join`, `multi-prefix`, +`away-notify`, `chghost`, `setname`, `userhost-in-names`, `cap-notify`, +`labeled-response`. + +Two of these do real work: + +- **`server-time` + `batch`** let the bot recognise **replayed history**. On + join, InspIRCd replays recent channel lines (wrapped in a `chathistory` + batch). Without this the bot re-answered old commands on every reconnect; + now such messages are ignored (see `Bot::is_historical`). +- **`sasl`** (PLAIN) authenticates to services during negotiation. Set + `sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`). + base64 is hand-rolled, so this stays dependency-free. + +## 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. +- Split commands into a registry/trait once there are many of them. diff --git a/src/bot.rs b/src/bot.rs new file mode 100644 index 0000000..2cdf7fa --- /dev/null +++ b/src/bot.rs @@ -0,0 +1,343 @@ +//! 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 { + pub server: String, + pub port: u16, + 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 { + server: "irc.tchatou.fr".to_string(), + port: 6667, + 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/irc.rs b/src/irc.rs new file mode 100644 index 0000000..33f3c7e --- /dev/null +++ b/src/irc.rs @@ -0,0 +1,421 @@ +//! 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, Write}; +use std::net::TcpStream; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 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 +} + +/// A blocking IRC connection over plain TCP. +/// +/// Reading and writing use separate clones of the same socket, so the event +/// loop can block on `read_message` while senders write independently. +pub struct Connection { + reader: BufReader, + writer: TcpStream, +} + +impl Connection { + /// Open a plaintext TCP connection to `host:port`. + /// + /// 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, + }) + } + + /// 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.reader.read_line(&mut line)? == 0 { + return Ok(None); // EOF + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + continue; + } + eprintln!("<< {trimmed}"); + 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}"); + write!(self.writer, "{clean}\r\n")?; + self.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/main.rs b/src/main.rs new file mode 100644 index 0000000..8ea097f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,166 @@ +//! 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. + +mod bot; +mod irc; + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use bot::{Bot, Config}; +use irc::Connection; + +fn main() { + let config = load_config(); + log(&format!( + "starting rustbot as {} -> {}:{}", + config.nick, config.server, config.port + )); + + // Supervisor: keep the bot alive across disconnects with capped backoff. + let mut backoff = 1u64; + loop { + match run_once(&config) { + Ok(()) => { + log("server closed the connection; reconnecting shortly"); + backoff = 1; + } + Err(e) => log(&format!("session error: {e}; retrying in {backoff}s")), + } + std::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)?; + 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(); + + 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())), + } + } + apply_env(&mut c); + + c +} + +/// 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) +} + +/// Parse a `key = value` config file onto `c`. +/// +/// 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)), + }, + "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`. +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_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); + } +} + +/// 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}"); +}