rust bot
This commit is contained in:
commit
05c004d32d
6 changed files with 1062 additions and 0 deletions
166
src/main.rs
Normal file
166
src/main.rs
Normal 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}");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue