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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ef80b77760
commit
e7e805cb87
4 changed files with 318 additions and 74 deletions
35
README.md
35
README.md
|
|
@ -9,7 +9,7 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors
|
||||||
|---------------|------------------|-----------------------------------------------------------|
|
|---------------|------------------|-----------------------------------------------------------|
|
||||||
| `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport |
|
| `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/bot.rs` | behavior | Registration, event loop, command handling |
|
||||||
| `src/main.rs` | wiring | Config from env vars, connect/reconnect supervisor |
|
| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network |
|
||||||
|
|
||||||
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
||||||
`bot.rs` knows nothing about how the process is launched.
|
`bot.rs` knows nothing about how the process is launched.
|
||||||
|
|
@ -40,8 +40,37 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run
|
||||||
```
|
```
|
||||||
|
|
||||||
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
|
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
|
||||||
`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`, `sasl_pass`.
|
`channels` (comma/space-separated), `prefix`, `password`, `sasl_user`,
|
||||||
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
|
`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments
|
||||||
|
only — inline comments would clash with channel `#`s.
|
||||||
|
|
||||||
|
### Multiple networks
|
||||||
|
|
||||||
|
Group keys under `[name]` section headers to connect to several networks at
|
||||||
|
once — one supervisor thread each, with independent reconnect/backoff. Keys
|
||||||
|
*before* the first header are shared defaults every network inherits; each
|
||||||
|
section overrides them. A file with no headers is a single network (the
|
||||||
|
historical behaviour), and its logs stay unprefixed.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# shared defaults, inherited by every network below
|
||||||
|
nick = rubot
|
||||||
|
realname = rubot
|
||||||
|
|
||||||
|
[tchatou]
|
||||||
|
server = irc.tchatou.fr
|
||||||
|
tls = true
|
||||||
|
channels = #devs
|
||||||
|
|
||||||
|
[libera]
|
||||||
|
server = irc.libera.chat
|
||||||
|
tls = true
|
||||||
|
channels = #rust, #rust-beginners
|
||||||
|
nick = rubot_ # override just for this network
|
||||||
|
```
|
||||||
|
|
||||||
|
With more than one network, each network's log lines are tagged with its
|
||||||
|
section name (`[libera] << …`) so the interleaved output stays readable.
|
||||||
|
|
||||||
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`,
|
Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`,
|
||||||
`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`,
|
`IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ const HISTORY_GRACE_SECS: u64 = 60;
|
||||||
/// Everything the bot needs to know to connect and behave.
|
/// Everything the bot needs to know to connect and behave.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
/// Short label for this network, used in logs and as the thread name. Set
|
||||||
|
/// via an INI-style `[section]` header (or a `name =` key); defaults to the
|
||||||
|
/// server host. Only one network? It stays out of the logs entirely.
|
||||||
|
pub name: String,
|
||||||
pub server: String,
|
pub server: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Connect over TLS (typically port 6697) instead of plaintext.
|
/// Connect over TLS (typically port 6697) instead of plaintext.
|
||||||
|
|
@ -56,6 +60,7 @@ pub struct Config {
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Config {
|
fn default() -> Config {
|
||||||
Config {
|
Config {
|
||||||
|
name: String::new(),
|
||||||
server: "irc.tchatou.fr".to_string(),
|
server: "irc.tchatou.fr".to_string(),
|
||||||
port: 6667,
|
port: 6667,
|
||||||
tls: false,
|
tls: false,
|
||||||
|
|
|
||||||
20
src/irc.rs
20
src/irc.rs
|
|
@ -197,6 +197,9 @@ impl<T: Read + Write> Stream for T {}
|
||||||
/// session — which cannot be cloned into independent halves — work unchanged.
|
/// session — which cannot be cloned into independent halves — work unchanged.
|
||||||
pub struct Connection {
|
pub struct Connection {
|
||||||
inner: BufReader<Box<dyn Stream>>,
|
inner: BufReader<Box<dyn Stream>>,
|
||||||
|
/// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay
|
||||||
|
/// distinguishable. Empty for a single network, keeping its output terse.
|
||||||
|
log_prefix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Connection {
|
impl Connection {
|
||||||
|
|
@ -216,7 +219,18 @@ impl Connection {
|
||||||
} else {
|
} else {
|
||||||
Box::new(tcp)
|
Box::new(tcp)
|
||||||
};
|
};
|
||||||
Ok(Connection { inner: BufReader::new(stream) })
|
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a short label prepended to this connection's wire logs, so output
|
||||||
|
/// from multiple simultaneous networks stays distinguishable. An empty
|
||||||
|
/// label clears it (the terse single-network default).
|
||||||
|
pub fn set_label(&mut self, label: &str) {
|
||||||
|
self.log_prefix = if label.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("[{label}] ")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
||||||
|
|
@ -231,7 +245,7 @@ impl Connection {
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
eprintln!("<< {trimmed}");
|
eprintln!("{}<< {trimmed}", self.log_prefix);
|
||||||
if let Some(msg) = Message::parse(trimmed) {
|
if let Some(msg) = Message::parse(trimmed) {
|
||||||
return Ok(Some(msg));
|
return Ok(Some(msg));
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +257,7 @@ impl Connection {
|
||||||
/// any embedded CR/LF are neutralised to prevent command injection.
|
/// any embedded CR/LF are neutralised to prevent command injection.
|
||||||
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||||
let clean = line.replace(['\r', '\n'], " ");
|
let clean = line.replace(['\r', '\n'], " ");
|
||||||
eprintln!(">> {clean}");
|
eprintln!("{}>> {clean}", self.log_prefix);
|
||||||
let writer = self.inner.get_mut();
|
let writer = self.inner.get_mut();
|
||||||
write!(writer, "{clean}\r\n")?;
|
write!(writer, "{clean}\r\n")?;
|
||||||
writer.flush()
|
writer.flush()
|
||||||
|
|
|
||||||
332
src/main.rs
332
src/main.rs
|
|
@ -7,68 +7,233 @@
|
||||||
//!
|
//!
|
||||||
//! Configuration is layered (see [`load_config`]): built-in defaults, then a
|
//! Configuration is layered (see [`load_config`]): built-in defaults, then a
|
||||||
//! `key = value` config file, then environment variables — so the same binary
|
//! `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 bot;
|
||||||
mod irc;
|
mod irc;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bot::{Bot, Config};
|
use bot::{Bot, Config};
|
||||||
use irc::Connection;
|
use irc::Connection;
|
||||||
|
|
||||||
fn main() {
|
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!(
|
log(&format!(
|
||||||
"starting rustbot as {} -> {}:{}",
|
"starting rustbot across {} network(s): {}",
|
||||||
config.nick, config.server, config.port
|
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;
|
let mut backoff = 1u64;
|
||||||
loop {
|
loop {
|
||||||
match run_once(&config) {
|
match run_once(&config, labelled) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
log("server closed the connection; reconnecting shortly");
|
log(&format!("{tag}server closed the connection; reconnecting shortly"));
|
||||||
backoff = 1;
|
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);
|
backoff = (backoff * 2).min(60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single connection lifecycle: connect, register, loop until disconnect.
|
/// Run a single connection lifecycle for one network: connect, register, loop
|
||||||
fn run_once(config: &Config) -> std::io::Result<()> {
|
/// until disconnect.
|
||||||
let conn = Connection::connect(&config.server, config.port, config.tls)?;
|
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);
|
let mut bot = Bot::new(config.clone(), conn);
|
||||||
bot.run()
|
bot.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the [`Config`] by layering sources in increasing priority:
|
/// Build one [`Config`] per network by layering sources.
|
||||||
/// built-in defaults, then a config file, then environment variables.
|
///
|
||||||
fn load_config() -> Config {
|
/// The config file is INI-flavoured: `key = value` lines, optionally grouped
|
||||||
let mut c = Config::default();
|
/// 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() {
|
let (toplevel, sections) = match &content {
|
||||||
match apply_config_file(&mut c, &path) {
|
Some(text) => parse_sections(text),
|
||||||
Ok(()) => log(&format!("loaded config from {}", path.display())),
|
None => (Vec::new(), Vec::new()),
|
||||||
Err(e) => log(&format!("could not read config {}: {e}", path.display())),
|
};
|
||||||
|
|
||||||
|
// 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
|
/// Per-network finishing touches: default the label to the server host, and
|
||||||
// the plaintext default, switch to the conventional TLS port.
|
/// 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 {
|
if c.tls && c.port == 6667 {
|
||||||
c.port = 6697;
|
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
|
/// 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)
|
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
|
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`,
|
||||||
/// `;` is a comment — note that inline comments are *not* supported, because
|
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||||
/// IRC channel names legitimately contain `#`.
|
/// `IRC_SASL_USER`, `IRC_SASL_PASS`.
|
||||||
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`.
|
|
||||||
fn apply_env(c: &mut Config) {
|
fn apply_env(c: &mut Config) {
|
||||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||||
c.server = v;
|
c.server = v;
|
||||||
|
|
@ -182,3 +310,71 @@ fn split_list(value: &str) -> Vec<String> {
|
||||||
fn log(msg: &str) {
|
fn log(msg: &str) {
|
||||||
eprintln!("[rustbot] {msg}");
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue