Add multi-network support

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.
This commit is contained in:
Jean Chevronnet 2026-07-28 16:12:57 +00:00
parent ab02a5436e
commit b9e95430f5
4 changed files with 318 additions and 74 deletions

View file

@ -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<Config> {
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<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
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<Entry>, Vec<(String, Vec<Entry>)>) {
let mut toplevel: Vec<Entry> = Vec::new();
let mut sections: Vec<(String, Vec<Entry>)> = 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<PathBuf>, 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<PathBuf> {
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<String> {
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<Config> {
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");
}
}