config: >rehash command to reload rustbot.conf live (channels/nick/prefix/verbose/modules) without a restart
All checks were successful
ci / check (push) Successful in 47s

This commit is contained in:
Jean Chevronnet 2026-07-30 01:40:18 +00:00
parent 6af92c1217
commit 3c2154bbc1
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
7 changed files with 226 additions and 20 deletions

View file

@ -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

View file

@ -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(());
};

View file

@ -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<String> {
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<String> {
let mut changes: Vec<String> = Vec::new();
let newset: HashSet<&String> = new.channels.iter().collect();
let oldset: HashSet<&String> = self.config.channels.iter().collect();
let joined: Vec<String> = new
.channels
.iter()
.filter(|c| !oldset.contains(c))
.cloned()
.collect();
let parted: Vec<String> = 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,

View file

@ -21,6 +21,7 @@ pub struct Config {
pub locale: String,
pub karma_url: String,
pub webchat: String,
pub admin: Vec<String>,
}
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 {

View file

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

View file

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

View file

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