diff --git a/api/src/lib.rs b/api/src/lib.rs index 3cd10f7..34819f7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -570,6 +570,7 @@ pub trait Store { fn channel_suspension(&self, channel: &str) -> Option; // BotServ registry (oper-only at the command layer). fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; + fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError>; fn bot_del(&mut self, nick: &str) -> Result; fn bots(&self) -> Vec; fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>; diff --git a/botserv/src/bot.rs b/botserv/src/bot.rs index 842c750..56f7bc7 100644 --- a/botserv/src/bot.rs +++ b/botserv/src/bot.rs @@ -1,4 +1,4 @@ -use fedserv_api::{Priv, Sender, ServiceCtx, Store}; +use fedserv_api::{ChanError, Priv, Sender, ServiceCtx, Store}; // BOT ADD [gecos] | BOT DEL | BOT LIST — manage the // bot registry. Administering bots is oper-only (Priv::Admin). @@ -19,6 +19,25 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Err(_) => ctx.notice(me, from.uid, format!("A bot named \x02{nick}\x02 already exists, or that didn't work.")), } } + Some("CHANGE") => { + let (Some(&old), Some(&newnick)) = (args.get(2), args.get(3)) else { + ctx.notice(me, from.uid, "Syntax: BOT CHANGE [user [host [gecos]]]"); + return; + }; + // Omitted fields keep the bot's current values. + let Some(cur) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(old)) else { + ctx.notice(me, from.uid, format!("There's no bot named \x02{old}\x02.")); + return; + }; + let user = args.get(4).map(|s| s.to_string()).unwrap_or(cur.user); + let host = args.get(5).map(|s| s.to_string()).unwrap_or(cur.host); + let gecos = if args.len() > 6 { args[6..].join(" ") } else { cur.gecos }; + match db.bot_change(old, newnick, &user, &host, &gecos) { + Ok(()) => ctx.notice(me, from.uid, format!("Bot \x02{old}\x02 is now \x02{newnick}\x02 (\x02{user}@{host}\x02).")), + Err(ChanError::Exists) => ctx.notice(me, from.uid, format!("A bot named \x02{newnick}\x02 already exists.")), + Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), + } + } Some("DEL") => { let Some(&nick) = args.get(2) else { ctx.notice(me, from.uid, "Syntax: BOT DEL "); @@ -41,6 +60,6 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: ctx.notice(me, from.uid, format!(" \x02{}\x02 ({}@{}) — {}", b.nick, b.user, b.host, b.gecos)); } } - Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02DEL\x02 or \x02LIST\x02.")), + Some(other) => ctx.notice(me, from.uid, format!("Unknown BOT command \x02{other}\x02. Use \x02ADD\x02, \x02CHANGE\x02, \x02DEL\x02 or \x02LIST\x02.")), } } diff --git a/src/engine/db.rs b/src/engine/db.rs index 87e4b90..ddb8d01 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -1603,6 +1603,43 @@ impl Db { Ok(true) } + /// Change a bot's nick and/or identity. `NoChannel` = no such bot, + /// `Exists` = the new nick belongs to a different bot. On a rename, every + /// channel the old bot was assigned to is moved to the new nick. + pub fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { + let ok = key(old); + if !self.bots.contains_key(&ok) { + return Err(ChanError::NoChannel); + } + let nk = key(new_nick); + let renaming = ok != nk; + if renaming && self.bots.contains_key(&nk) { + return Err(ChanError::Exists); + } + let bot = Bot { nick: new_nick.to_string(), user: user.to_string(), host: host.to_string(), gecos: gecos.to_string() }; + if renaming { + let chans: Vec = self + .channels + .values() + .filter(|c| c.assigned_bot.as_deref().is_some_and(|b| key(b) == ok)) + .map(|c| c.name.clone()) + .collect(); + self.log.append(Event::BotRemoved { nick: old.to_string() }).map_err(|_| ChanError::Internal)?; + self.bots.remove(&ok); + self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?; + self.bots.insert(nk, bot); + for chan in chans { + self.log.append(Event::ChannelBotAssigned { channel: chan.clone(), bot: new_nick.to_string() }).map_err(|_| ChanError::Internal)?; + self.channels.get_mut(&key(&chan)).unwrap().assigned_bot = Some(new_nick.to_string()); + } + } else { + // Same nick, new identity: re-emit BotAdded to overwrite the fields. + self.log.append(Event::BotAdded(bot.clone())).map_err(|_| ChanError::Internal)?; + self.bots.insert(nk, bot); + } + Ok(()) + } + /// All registered bots. pub fn bots(&self) -> impl Iterator { self.bots.values() @@ -2145,6 +2182,9 @@ impl Store for Db { fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { Db::bot_add(self, nick, user, host, gecos) } + fn bot_change(&mut self, old: &str, new_nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> { + Db::bot_change(self, old, new_nick, user, host, gecos) + } fn bot_del(&mut self, nick: &str) -> Result { Db::bot_del(self, nick) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 37cdc2b..8da31c2 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -96,6 +96,7 @@ pub struct Engine { opers: HashMap, // casefolded account -> privileges (from [[oper]] config) sid: String, // our services SID, for minting bot uids bot_uids: HashMap, // casefolded bot nick -> live uid (per connection) + bot_idents: HashMap, // casefolded bot nick -> hash of user/host/gecos (to spot BOT CHANGE) next_bot_index: u32, bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined // Ephemeral per-channel, per-user chatter tracking for the FLOOD and REPEAT @@ -153,6 +154,7 @@ impl Engine { opers: HashMap::new(), sid: String::new(), bot_uids: HashMap::new(), + bot_idents: HashMap::new(), next_bot_index: 0, bot_channels: std::collections::HashSet::new(), chatter: HashMap::new(), @@ -198,12 +200,23 @@ impl Engine { self.db.bots().map(|b| (b.nick.clone(), b.user.clone(), b.host.clone(), b.gecos.clone())).collect(); for (nick, user, host, gecos) in &live { let k = nick.to_ascii_lowercase(); + let ident = ident_hash(user, host, gecos); + // A BOT CHANGE that kept the nick but altered the identity: quit the + // stale client so it is reintroduced (and rejoins its channels) below. + if self.bot_uids.contains_key(&k) && self.bot_idents.get(&k) != Some(&ident) { + if let Some(uid) = self.bot_uids.remove(&k) { + out.push(NetAction::QuitUser { uid, reason: "Bot changed".to_string() }); + } + self.network.bot_forget(&k); + self.bot_channels.retain(|(b, _)| *b != k); + } if !self.bot_uids.contains_key(&k) { let uid = format!("{}B{:05}", self.sid, self.next_bot_index); self.next_bot_index += 1; out.push(NetAction::IntroduceUser { uid: uid.clone(), nick: nick.clone(), ident: user.clone(), host: host.clone(), gecos: gecos.clone() }); self.network.bot_connect(nick, &uid); - self.bot_uids.insert(k, uid); + self.bot_uids.insert(k.clone(), uid); + self.bot_idents.insert(k, ident); } } let live_keys: std::collections::HashSet = live.iter().map(|(n, ..)| n.to_ascii_lowercase()).collect(); @@ -212,6 +225,7 @@ impl Engine { if let Some(uid) = self.bot_uids.remove(&k) { out.push(NetAction::QuitUser { uid, reason: "Bot removed".to_string() }); } + self.bot_idents.remove(&k); self.network.bot_forget(&k); self.bot_channels.retain(|(b, _)| *b != k); // its channel memberships go with it } @@ -502,6 +516,7 @@ impl Engine { pub fn startup_actions(&mut self) -> Vec { // Fresh link: forget bot uids minted on any previous connection. self.bot_uids.clear(); + self.bot_idents.clear(); self.bot_channels.clear(); self.network.clear_bots(); self.next_bot_index = 0; @@ -1158,6 +1173,17 @@ fn resource_action(a: &mut NetAction, old: &str, new: &str) { // A case-insensitive 64-bit hash of a line, for the REPEAT kicker. Allocation- // free (folds each byte to lowercase in place) so it stays cheap on the hot // path; a 64-bit collision causing a spurious kick is astronomically unlikely. +// A hash of a bot's identity (user/host/gecos), so reconcile can tell when a +// BOT CHANGE altered it without changing the nick. +fn ident_hash(user: &str, host: &str, gecos: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + user.hash(&mut h); + host.hash(&mut h); + gecos.hash(&mut h); + h.finish() +} + fn ci_hash(text: &str) -> u64 { use std::hash::Hasher; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -2052,6 +2078,49 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}"); } + // BOT CHANGE renames a bot (moving its channel assignments) and reintroduces + // it when only the identity changes; the network client is refreshed both ways. + #[test] + fn botserv_bot_change_renames_and_reidentifies() { + use fedserv_botserv::BotServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-bschange.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("boss", "password1", None).unwrap(); + db.register_channel("#c", "boss").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin)); + e.set_opers(opers); + let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() }); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() }); + ns(&mut e, "IDENTIFY password1"); + bs(&mut e, "BOT ADD Bendy bot serv.host Helper"); + bs(&mut e, "ASSIGN #c Bendy"); + + // Rename: the old client quits, a Roger client is introduced and rejoins #c. + let out = bs(&mut e, "BOT CHANGE Bendy Roger"); + assert!(out.iter().any(|a| matches!(a, NetAction::QuitUser { .. })), "old client quit: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, .. } if nick == "Roger")), "Roger introduced"); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "Roger rejoins #c"); + + // Same nick, new identity: the client is refreshed with the new host. + let out = bs(&mut e, "BOT CHANGE Roger Roger newbot new.host A New Bot"); + assert!(out.iter().any(|a| matches!(a, NetAction::QuitUser { .. })), "stale client quit"); + assert!(out.iter().any(|a| matches!(a, NetAction::IntroduceUser { nick, host, .. } if nick == "Roger" && host == "new.host")), "reintroduced with new host: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "rejoins #c after re-identify"); + } + // SAY/ACT speak through the channel's assigned bot, sourced from the bot's uid. #[test] fn botserv_say_speaks_through_bot() {