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

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