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
All checks were successful
ci / check (push) Successful in 47s
This commit is contained in:
parent
6af92c1217
commit
3c2154bbc1
7 changed files with 226 additions and 20 deletions
17
README.md
17
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
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
};
|
||||
|
|
|
|||
140
src/bot/mod.rs
140
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<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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
|
|||
49
tests/bot.rs
49
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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue