BotServ: BOT CHANGE
BOT CHANGE <oldnick> <newnick> [user [host [gecos]]] renames or re-identifies a bot; omitted fields keep the current values. A rename moves every channel the bot was assigned to onto the new nick. reconcile now tracks each live bot's identity hash, so a change that keeps the nick but alters user/host/gecos quits the stale pseudo-client and reintroduces it (rejoining its channels) — a rename does the same via the registry.
This commit is contained in:
parent
ec3205bf75
commit
1cc438f4ed
4 changed files with 132 additions and 3 deletions
|
|
@ -570,6 +570,7 @@ pub trait Store {
|
||||||
fn channel_suspension(&self, channel: &str) -> Option<SuspensionView>;
|
fn channel_suspension(&self, channel: &str) -> Option<SuspensionView>;
|
||||||
// BotServ registry (oper-only at the command layer).
|
// 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_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<bool, ChanError>;
|
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError>;
|
||||||
fn bots(&self) -> Vec<BotView>;
|
fn bots(&self) -> Vec<BotView>;
|
||||||
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>;
|
fn assign_bot(&mut self, channel: &str, bot: &str) -> Result<(), ChanError>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use fedserv_api::{Priv, Sender, ServiceCtx, Store};
|
use fedserv_api::{ChanError, Priv, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
// BOT ADD <nick> <user> <host> [gecos] | BOT DEL <nick> | BOT LIST — manage the
|
// BOT ADD <nick> <user> <host> [gecos] | BOT DEL <nick> | BOT LIST — manage the
|
||||||
// bot registry. Administering bots is oper-only (Priv::Admin).
|
// 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.")),
|
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 <oldnick> <newnick> [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") => {
|
Some("DEL") => {
|
||||||
let Some(&nick) = args.get(2) else {
|
let Some(&nick) = args.get(2) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: BOT DEL <nick>");
|
ctx.notice(me, from.uid, "Syntax: BOT DEL <nick>");
|
||||||
|
|
@ -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));
|
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.")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1603,6 +1603,43 @@ impl Db {
|
||||||
Ok(true)
|
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<String> = 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.
|
/// All registered bots.
|
||||||
pub fn bots(&self) -> impl Iterator<Item = &Bot> {
|
pub fn bots(&self) -> impl Iterator<Item = &Bot> {
|
||||||
self.bots.values()
|
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> {
|
fn bot_add(&mut self, nick: &str, user: &str, host: &str, gecos: &str) -> Result<(), ChanError> {
|
||||||
Db::bot_add(self, nick, user, host, gecos)
|
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<bool, ChanError> {
|
fn bot_del(&mut self, nick: &str) -> Result<bool, ChanError> {
|
||||||
Db::bot_del(self, nick)
|
Db::bot_del(self, nick)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ pub struct Engine {
|
||||||
opers: HashMap<String, Privs>, // casefolded account -> privileges (from [[oper]] config)
|
opers: HashMap<String, Privs>, // casefolded account -> privileges (from [[oper]] config)
|
||||||
sid: String, // our services SID, for minting bot uids
|
sid: String, // our services SID, for minting bot uids
|
||||||
bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection)
|
bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection)
|
||||||
|
bot_idents: HashMap<String, u64>, // casefolded bot nick -> hash of user/host/gecos (to spot BOT CHANGE)
|
||||||
next_bot_index: u32,
|
next_bot_index: u32,
|
||||||
bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined
|
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
|
// Ephemeral per-channel, per-user chatter tracking for the FLOOD and REPEAT
|
||||||
|
|
@ -153,6 +154,7 @@ impl Engine {
|
||||||
opers: HashMap::new(),
|
opers: HashMap::new(),
|
||||||
sid: String::new(),
|
sid: String::new(),
|
||||||
bot_uids: HashMap::new(),
|
bot_uids: HashMap::new(),
|
||||||
|
bot_idents: HashMap::new(),
|
||||||
next_bot_index: 0,
|
next_bot_index: 0,
|
||||||
bot_channels: std::collections::HashSet::new(),
|
bot_channels: std::collections::HashSet::new(),
|
||||||
chatter: HashMap::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();
|
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 {
|
for (nick, user, host, gecos) in &live {
|
||||||
let k = nick.to_ascii_lowercase();
|
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) {
|
if !self.bot_uids.contains_key(&k) {
|
||||||
let uid = format!("{}B{:05}", self.sid, self.next_bot_index);
|
let uid = format!("{}B{:05}", self.sid, self.next_bot_index);
|
||||||
self.next_bot_index += 1;
|
self.next_bot_index += 1;
|
||||||
out.push(NetAction::IntroduceUser { uid: uid.clone(), nick: nick.clone(), ident: user.clone(), host: host.clone(), gecos: gecos.clone() });
|
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.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<String> = live.iter().map(|(n, ..)| n.to_ascii_lowercase()).collect();
|
let live_keys: std::collections::HashSet<String> = live.iter().map(|(n, ..)| n.to_ascii_lowercase()).collect();
|
||||||
|
|
@ -212,6 +225,7 @@ impl Engine {
|
||||||
if let Some(uid) = self.bot_uids.remove(&k) {
|
if let Some(uid) = self.bot_uids.remove(&k) {
|
||||||
out.push(NetAction::QuitUser { uid, reason: "Bot removed".to_string() });
|
out.push(NetAction::QuitUser { uid, reason: "Bot removed".to_string() });
|
||||||
}
|
}
|
||||||
|
self.bot_idents.remove(&k);
|
||||||
self.network.bot_forget(&k);
|
self.network.bot_forget(&k);
|
||||||
self.bot_channels.retain(|(b, _)| *b != k); // its channel memberships go with it
|
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<NetAction> {
|
pub fn startup_actions(&mut self) -> Vec<NetAction> {
|
||||||
// Fresh link: forget bot uids minted on any previous connection.
|
// Fresh link: forget bot uids minted on any previous connection.
|
||||||
self.bot_uids.clear();
|
self.bot_uids.clear();
|
||||||
|
self.bot_idents.clear();
|
||||||
self.bot_channels.clear();
|
self.bot_channels.clear();
|
||||||
self.network.clear_bots();
|
self.network.clear_bots();
|
||||||
self.next_bot_index = 0;
|
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-
|
// 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
|
// 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.
|
// 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 {
|
fn ci_hash(text: &str) -> u64 {
|
||||||
use std::hash::Hasher;
|
use std::hash::Hasher;
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
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:?}");
|
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.
|
// SAY/ACT speak through the channel's assigned bot, sourced from the bot's uid.
|
||||||
#[test]
|
#[test]
|
||||||
fn botserv_say_speaks_through_bot() {
|
fn botserv_say_speaks_through_bot() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue