This commit is contained in:
Jean Chevronnet 2026-07-28 14:29:34 +00:00
commit 05c004d32d
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 1062 additions and 0 deletions

343
src/bot.rs Normal file
View file

@ -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<String>,
/// 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<String>,
/// SASL PLAIN credentials. When both are set (and the server offers `sasl`),
/// the bot authenticates during capability negotiation.
pub sasl_user: Option<String>,
pub sasl_pass: Option<String>,
}
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<String>,
/// Capabilities we successfully negotiated (`CAP ACK`).
enabled_caps: HashSet<String>,
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
batches: HashMap<String, String>,
/// 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 <target> <subcommand> [*] :<space-separated caps>
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 +<ref> <type> [params] opens; BATCH -<ref> closes.
let Some(reference) = msg.param(0) else { return };
if let Some(name) = reference.strip_prefix('+') {
self.batches
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
} else if let Some(name) = reference.strip_prefix('-') {
self.batches.remove(name);
}
}
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 <text>, {p}hello, {p}help");
self.conn.privmsg(reply_to, &text)?;
}
_ => {} // Unknown command: stay silent rather than spam channels.
}
Ok(())
}
}
/// Channels start with one of the standard channel-type prefixes.
fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}

421
src/irc.rs Normal file
View file

@ -0,0 +1,421 @@
//! Minimal IRC protocol layer — the "IRC library".
//!
//! Implements just enough of the modern IRC client protocol
//! (<https://modern.ircdocs.horse/>) 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<String>,
pub user: Option<String>,
pub host: Option<String>,
}
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<String, String>,
/// The message source, if the server included one.
pub prefix: Option<Prefix>,
/// 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<String>,
}
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<Message> {
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 <https://ircv3.net/specs/extensions/message-tags>.
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<TcpStream>,
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<Connection> {
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<Option<Message>> {
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<u64> {
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: <http://howardhinnant.github.io/date_algorithms.html>
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);
}
}

166
src/main.rs Normal file
View file

@ -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<PathBuf> {
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<String> {
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}");
}