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

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}");
}