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
|
for the board's channel "join" links, e.g. `https://web.libera.chat/`), and
|
||||||
`karma_url` (public web board —
|
`karma_url` (public web board —
|
||||||
enables the `karmaweb` command and a link on the `karma`/`karmabottom` top
|
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.
|
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
|
### Locales
|
||||||
|
|
||||||
`locale` sets a network's language, so a community sees the bot in its own
|
`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);
|
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 {
|
let Some(&idx) = self.routes.get(command) else {
|
||||||
return Ok(());
|
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 {
|
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||||
let current_nick = config.nick.clone();
|
let current_nick = config.nick.clone();
|
||||||
|
|
||||||
let mods = modules::all(
|
let modules = modules::all(
|
||||||
&config.name,
|
&config.name,
|
||||||
crate::i18n::Locale::from_code(&config.locale),
|
crate::i18n::Locale::from_code(&config.locale),
|
||||||
(!config.karma_url.is_empty()).then(|| config.karma_url.clone()),
|
(!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();
|
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() {
|
for spec in m.commands() {
|
||||||
if routes.insert(spec.name, i).is_some() {
|
if routes.insert(spec.name, i).is_some() {
|
||||||
crate::log(&format!(
|
crate::log(&format!(
|
||||||
|
|
@ -48,24 +69,105 @@ impl Bot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.routes = routes;
|
||||||
|
}
|
||||||
|
|
||||||
let bot = Bot {
|
/// Re-read the config file and hot-apply this network's `[section]`.
|
||||||
config,
|
pub(super) fn reload_from_file(&mut self) -> io::Result<String> {
|
||||||
conn,
|
let name = self.config.name.clone();
|
||||||
current_nick,
|
match crate::config::load().into_iter().find(|c| c.name == name) {
|
||||||
registered: false,
|
Some(new) => self.apply_config(new),
|
||||||
available_caps: HashSet::new(),
|
None => Ok(format!("rehash: no section '{name}' in the config")),
|
||||||
enabled_caps: HashSet::new(),
|
}
|
||||||
batches: HashMap::new(),
|
}
|
||||||
sasl_started: false,
|
|
||||||
modules: mods,
|
/// Diff `new` against the running config and hot-apply what can change
|
||||||
routes,
|
/// without reconnecting (channels, nick, prefix, verbose, modules), noting
|
||||||
topics: HashMap::new(),
|
/// anything that still needs a restart. Returns a one-line summary.
|
||||||
users: HashMap::new(),
|
pub fn apply_config(&mut self, new: Config) -> io::Result<String> {
|
||||||
names_building: HashMap::new(),
|
let mut changes: Vec<String> = Vec::new();
|
||||||
};
|
|
||||||
bot.write_source_meta();
|
let newset: HashSet<&String> = new.channels.iter().collect();
|
||||||
bot
|
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,
|
/// Rewrite the per-network source sidecar the web board reads: server,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub struct Config {
|
||||||
pub locale: String,
|
pub locale: String,
|
||||||
pub karma_url: String,
|
pub karma_url: String,
|
||||||
pub webchat: String,
|
pub webchat: String,
|
||||||
|
pub admin: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -42,6 +43,7 @@ impl Default for Config {
|
||||||
locale: "en".to_string(),
|
locale: "en".to_string(),
|
||||||
karma_url: String::new(),
|
karma_url: String::new(),
|
||||||
webchat: 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(),
|
"locale" | "lang" => c.locale = value.to_string(),
|
||||||
"karma_url" | "web_url" => c.karma_url = value.to_string(),
|
"karma_url" | "web_url" => c.karma_url = value.to_string(),
|
||||||
"webchat" => c.webchat = value.to_string(),
|
"webchat" => c.webchat = value.to_string(),
|
||||||
|
"admin" | "owner" => c.admin = split_list(value),
|
||||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
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") {
|
if let Ok(v) = std::env::var("IRC_WEBCHAT") {
|
||||||
c.webchat = v;
|
c.webchat = v;
|
||||||
}
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_ADMIN") {
|
||||||
|
c.admin = split_list(&v);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> bool {
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,14 @@ impl Connection {
|
||||||
self.send_raw(&format!("JOIN {channel}"))
|
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<()> {
|
pub fn pong(&mut self, token: &str) -> io::Result<()> {
|
||||||
self.send_raw(&format!("PONG :{token}"))
|
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");
|
let out = run(":rubot!r@h PRIVMSG #chan :!ping\r\n");
|
||||||
assert!(!out.contains(":pong"), "{out}");
|
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.len(), 1);
|
||||||
assert_eq!(cfgs[0].server, "irc.tchatou.fr");
|
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