From 3c2154bbc1ecfabaad07e06c820b124661e9908f Mon Sep 17 00:00:00 2001 From: reverse Date: Thu, 30 Jul 2026 01:40:18 +0000 Subject: [PATCH] config: >rehash command to reload rustbot.conf live (channels/nick/prefix/verbose/modules) without a restart --- README.md | 17 ++++- src/bot/commands.rs | 16 +++++ src/bot/mod.rs | 140 ++++++++++++++++++++++++++++++++++++------ src/config.rs | 6 ++ src/irc/connection.rs | 8 +++ tests/bot.rs | 49 +++++++++++++++ tests/config.rs | 10 +++ 7 files changed, 226 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4c60a63..357687f 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,24 @@ on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and for the board's channel "join" links, e.g. `https://web.libera.chat/`), and `karma_url` (public web board — enables the `karmaweb` command and a link on the `karma`/`karmabottom` top -lists; empty disables both). Whole-line `#`/`;` comments only — +lists; empty disables both), and `admin` (nicks allowed to run `rehash`; see +[Live reload](#live-reload-rehash)). Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. +### Live reload (rehash) + +Editing `rustbot.conf` doesn't require a restart. An admin sends **`rehash`** +(the command prefix + `rehash`) in a channel or PM and the bot re-reads the +file and hot-applies this network's section: + +- **live**: `channels` (JOIN new / PART removed), `nick`, `prefix`, `verbose`, + and `locale` / `karma_url` / `webchat` (modules are re-instantiated). +- **needs a restart**: `server`, `port`, `tls`, SASL — the summary says so. + +Set `admin = yournick` (comma/space list) to authorize; unauthorized attempts +are ignored (and logged). Adding a *new* module or a whole new `[network]` +still needs a rebuild/restart — new compiled code can't load at runtime. + ### Locales `locale` sets a network's language, so a community sees the bot in its own diff --git a/src/bot/commands.rs b/src/bot/commands.rs index 1bb30e2..3ba6a23 100644 --- a/src/bot/commands.rs +++ b/src/bot/commands.rs @@ -113,6 +113,22 @@ impl Bot { return self.conn.privmsg(reply_to, &text); } + if command == "rehash" { + // admin-only; unauthorized attempts are ignored (logged, no reply) + let ok = !self.config.admin.is_empty() + && self + .config + .admin + .iter() + .any(|a| a.eq_ignore_ascii_case(sender)); + if !ok { + crate::log(&format!("rehash denied for {sender}")); + return Ok(()); + } + let summary = self.reload_from_file()?; + return self.conn.privmsg(reply_to, &summary); + } + let Some(&idx) = self.routes.get(command) else { return Ok(()); }; diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 5bce0fd..5b57fca 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -31,13 +31,34 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); - let mods = modules::all( + let modules = modules::all( &config.name, crate::i18n::Locale::from_code(&config.locale), (!config.karma_url.is_empty()).then(|| config.karma_url.clone()), ); + let mut bot = Bot { + config, + conn, + current_nick, + registered: false, + available_caps: HashSet::new(), + enabled_caps: HashSet::new(), + batches: HashMap::new(), + sasl_started: false, + modules, + routes: HashMap::new(), + topics: HashMap::new(), + users: HashMap::new(), + names_building: HashMap::new(), + }; + bot.rebuild_routes(); + bot.write_source_meta(); + bot + } + + fn rebuild_routes(&mut self) { let mut routes: HashMap<&'static str, usize> = HashMap::new(); - for (i, m) in mods.iter().enumerate() { + for (i, m) in self.modules.iter().enumerate() { for spec in m.commands() { if routes.insert(spec.name, i).is_some() { crate::log(&format!( @@ -48,24 +69,105 @@ impl Bot { } } } + self.routes = routes; + } - let bot = Bot { - config, - conn, - current_nick, - registered: false, - available_caps: HashSet::new(), - enabled_caps: HashSet::new(), - batches: HashMap::new(), - sasl_started: false, - modules: mods, - routes, - topics: HashMap::new(), - users: HashMap::new(), - names_building: HashMap::new(), - }; - bot.write_source_meta(); - bot + /// Re-read the config file and hot-apply this network's `[section]`. + pub(super) fn reload_from_file(&mut self) -> io::Result { + let name = self.config.name.clone(); + match crate::config::load().into_iter().find(|c| c.name == name) { + Some(new) => self.apply_config(new), + None => Ok(format!("rehash: no section '{name}' in the config")), + } + } + + /// Diff `new` against the running config and hot-apply what can change + /// without reconnecting (channels, nick, prefix, verbose, modules), noting + /// anything that still needs a restart. Returns a one-line summary. + pub fn apply_config(&mut self, new: Config) -> io::Result { + let mut changes: Vec = Vec::new(); + + let newset: HashSet<&String> = new.channels.iter().collect(); + let oldset: HashSet<&String> = self.config.channels.iter().collect(); + let joined: Vec = new + .channels + .iter() + .filter(|c| !oldset.contains(c)) + .cloned() + .collect(); + let parted: Vec = self + .config + .channels + .iter() + .filter(|c| !newset.contains(c)) + .cloned() + .collect(); + for c in &joined { + self.conn.join(c)?; + } + for c in &parted { + self.conn.part(c)?; + let key = c.to_lowercase(); + self.topics.remove(&key); + self.users.remove(&key); + self.names_building.remove(&key); + } + if !joined.is_empty() { + changes.push(format!("+{}", joined.join(" "))); + } + if !parted.is_empty() { + changes.push(format!("-{}", parted.join(" "))); + } + + if !new.nick.eq_ignore_ascii_case(&self.config.nick) { + self.conn.nick(&new.nick)?; + self.current_nick = new.nick.clone(); + changes.push(format!("nick {}", new.nick)); + } + if new.verbose != self.config.verbose { + self.conn.set_verbose(new.verbose); + changes.push(format!("verbose {}", new.verbose)); + } + if new.command_prefix != self.config.command_prefix { + changes.push(format!("prefix {}", new.command_prefix)); + } + + let modules_changed = + new.locale != self.config.locale || new.karma_url != self.config.karma_url; + let mut restart: Vec<&str> = Vec::new(); + if new.server != self.config.server { + restart.push("server"); + } + if new.port != self.config.port { + restart.push("port"); + } + if new.tls != self.config.tls { + restart.push("tls"); + } + if new.sasl_user != self.config.sasl_user || new.sasl_pass != self.config.sasl_pass { + restart.push("sasl"); + } + + self.config = new; + self.modules = modules::all( + &self.config.name, + crate::i18n::Locale::from_code(&self.config.locale), + (!self.config.karma_url.is_empty()).then(|| self.config.karma_url.clone()), + ); + self.rebuild_routes(); + if modules_changed { + changes.push("modules".to_string()); + } + self.write_source_meta(); + + if !restart.is_empty() { + changes.push(format!("(restart for: {})", restart.join("/"))); + } + Ok(if changes.is_empty() { + "rehash: reloaded (no changes)".to_string() + } else { + format!("rehash: {}", changes.join(", ")) + }) } /// Rewrite the per-network source sidecar the web board reads: server, diff --git a/src/config.rs b/src/config.rs index b89e8ca..7dc5ee6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub struct Config { pub locale: String, pub karma_url: String, pub webchat: String, + pub admin: Vec, } impl Default for Config { @@ -42,6 +43,7 @@ impl Default for Config { locale: "en".to_string(), karma_url: String::new(), webchat: String::new(), + admin: Vec::new(), } } } @@ -169,6 +171,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { "locale" | "lang" => c.locale = value.to_string(), "karma_url" | "web_url" => c.karma_url = value.to_string(), "webchat" => c.webchat = value.to_string(), + "admin" | "owner" => c.admin = split_list(value), other => log(&format!("{where_}: unknown key '{other}'")), } } @@ -235,6 +238,9 @@ fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_WEBCHAT") { c.webchat = v; } + if let Ok(v) = std::env::var("IRC_ADMIN") { + c.admin = split_list(&v); + } } fn parse_bool(value: &str) -> bool { diff --git a/src/irc/connection.rs b/src/irc/connection.rs index ada8eda..039e8f0 100644 --- a/src/irc/connection.rs +++ b/src/irc/connection.rs @@ -116,6 +116,14 @@ impl Connection { self.send_raw(&format!("JOIN {channel}")) } + pub fn part(&mut self, channel: &str) -> io::Result<()> { + self.send_raw(&format!("PART {channel}")) + } + + pub fn set_verbose(&mut self, verbose: bool) { + self.verbose = verbose; + } + pub fn pong(&mut self, token: &str) -> io::Result<()> { self.send_raw(&format!("PONG :{token}")) } diff --git a/tests/bot.rs b/tests/bot.rs index fd6d040..20104f3 100644 --- a/tests/bot.rs +++ b/tests/bot.rs @@ -76,3 +76,52 @@ fn ignores_own_echoed_message() { let out = run(":rubot!r@h PRIVMSG #chan :!ping\r\n"); assert!(!out.contains(":pong"), "{out}"); } + +#[test] +fn rehash_applies_channel_nick_and_prefix() { + let output = Arc::new(Mutex::new(Vec::new())); + let mock = Mock { + input: Cursor::new(Vec::new()), + output: output.clone(), + }; + let conn = Connection::from_stream(mock); + let cfg = Config { + name: "testnet".to_string(), + channels: vec!["#a".to_string(), "#keep".to_string()], + ..Config::default() + }; + let mut bot = Bot::new(cfg, conn); + + let new = Config { + name: "testnet".to_string(), + channels: vec!["#keep".to_string(), "#b".to_string()], + command_prefix: ">".to_string(), + nick: "rubot2".to_string(), + ..Config::default() + }; + let summary = bot.apply_config(new).unwrap(); + + let out = String::from_utf8_lossy(&output.lock().unwrap()).into_owned(); + assert!(out.contains("JOIN #b"), "{out}"); + assert!(out.contains("PART #a"), "{out}"); + assert!(!out.contains("PART #keep"), "{out}"); + assert!(out.contains("NICK rubot2"), "{out}"); + assert!( + summary.contains("+#b") && summary.contains("-#a"), + "{summary}" + ); + assert!( + summary.contains("prefix") && summary.contains("nick"), + "{summary}" + ); +} + +#[test] +fn rehash_command_requires_admin() { + // no admin configured -> ignored (default prefix is !) + let out = run(":alice!a@h PRIVMSG #chan :!rehash\r\n"); + assert!( + !out.contains("rehash:"), + "unauthorized rehash replied: {out}" + ); +} diff --git a/tests/config.rs b/tests/config.rs index 39112f1..2f44683 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -48,3 +48,13 @@ fn comments_and_blanks_are_ignored() { assert_eq!(cfgs.len(), 1); assert_eq!(cfgs[0].server, "irc.tchatou.fr"); } + +#[test] +fn admin_list_is_parsed_and_inherited() { + let cfgs = parse_str("admin = reverse, alice\nwebchat = https://w/\n[net]\nserver = x\n"); + assert_eq!( + cfgs[0].admin, + vec!["reverse".to_string(), "alice".to_string()] + ); + assert_eq!(cfgs[0].webchat, "https://w/"); +}