diff --git a/api/src/lib.rs b/api/src/lib.rs index e250a13..5d15d22 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -178,6 +178,16 @@ impl ServiceCtx { }); } + // A channel/target message sourced from one of our pseudo-clients (e.g. a + // bot answering a fantasy command, or BotServ SAY). + pub fn privmsg(&mut self, from: &str, to: &str, text: impl Into) { + self.actions.push(NetAction::Privmsg { + from: from.to_string(), + to: to.to_string(), + text: text.into(), + }); + } + // Hand a registration to the engine to finish: its password derivation runs // off the reactor, then the engine commits it and answers `reply`. pub fn defer_register(&mut self, account: impl Into, password: impl Into, email: Option, reply: RegReply) { diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index f374512..9abb503 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -10,6 +10,8 @@ mod bot; mod assign; #[path = "info.rs"] mod info; +#[path = "say.rs"] +mod say; pub struct BotServ { pub uid: String, @@ -26,14 +28,16 @@ impl Service for BotServ { "Bot Services" } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &dyn NetView, db: &mut dyn Store) { + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { Some("BOT") => bot::handle(me, from, args, ctx, db), Some("ASSIGN") => assign::handle(me, from, args, ctx, db, true), Some("UNASSIGN") => assign::handle(me, from, args, ctx, db, false), Some("INFO") => info::handle(me, from, args, ctx, db), - Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details. Operators also have \x02BOT\x02 ADD|DEL|LIST."), + Some("SAY") => say::handle(me, from, args, ctx, net, db, false), + Some("ACT") => say::handle(me, from, args, ctx, net, db, true), + Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot. Operators also have \x02BOT\x02 ADD|DEL|LIST."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } } diff --git a/botserv/src/say.rs b/botserv/src/say.rs new file mode 100644 index 0000000..e652a37 --- /dev/null +++ b/botserv/src/say.rs @@ -0,0 +1,35 @@ +use fedserv_api::{NetView, Priv, Sender, ServiceCtx, Store}; + +// SAY <#channel> — make the channel's assigned bot say something. +// ACT <#channel> — the same, as a CTCP ACTION (/me). +// Requires channel-operator access (founder, op, or a services admin). +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &dyn Store, action: bool) { + let verb = if action { "ACT" } else { "SAY" }; + if args.len() < 3 { + ctx.notice(me, from.uid, format!("Syntax: {verb} <#channel> ")); + return; + } + let chan = args[1]; + let text = args[2..].join(" "); + + let Some(info) = db.channel(chan) else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + return; + }; + let allowed = from.privs.has(Priv::Admin) || from.account.is_some_and(|a| info.is_op(a)); + if !allowed { + ctx.notice(me, from.uid, format!("Access denied — you need operator access in \x02{chan}\x02.")); + return; + } + let Some(bot) = info.assigned_bot else { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no bot assigned. Assign one with \x02ASSIGN\x02 {chan} .")); + return; + }; + let Some(botuid) = net.uid_by_nick(&bot) else { + ctx.notice(me, from.uid, format!("Bot \x02{bot}\x02 isn't on the network right now.")); + return; + }; + let botuid = botuid.to_string(); + let payload = if action { format!("\x01ACTION {text}\x01") } else { text }; + ctx.privmsg(&botuid, chan, payload); +} diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 35c19f5..81525fe 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -152,6 +152,7 @@ impl Engine { 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); } } @@ -161,6 +162,7 @@ impl Engine { if let Some(uid) = self.bot_uids.remove(&k) { out.push(NetAction::QuitUser { uid, reason: "Bot removed".to_string() }); } + self.network.bot_forget(&k); self.bot_channels.retain(|(b, _)| *b != k); // its channel memberships go with it } // Join assigned channels, part unassigned ones (for still-live bots). @@ -451,6 +453,7 @@ impl Engine { // Fresh link: forget bot uids minted on any previous connection. self.bot_uids.clear(); self.bot_channels.clear(); + self.network.clear_bots(); self.next_bot_index = 0; let mut out = vec![NetAction::Burst]; for svc in &self.services { @@ -1739,6 +1742,57 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}"); } + // SAY/ACT speak through the channel's assigned bot, sourced from the bot's uid. + #[test] + fn botserv_say_speaks_through_bot() { + use fedserv_botserv::BotServ; + use fedserv_nickserv::NickServ; + let path = std::env::temp_dir().join("fedserv-bssay.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("rando", "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, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() }); + let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() }); + + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into() }); + ns(&mut e, "000AAAAAB", "IDENTIFY password1"); + ns(&mut e, "000AAAAAR", "IDENTIFY password1"); + bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper"); + bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy"); + + // Founder SAY: a PRIVMSG to the channel sourced from the bot's uid. + let out = bs(&mut e, "000AAAAAB", "SAY #c hello there"); + assert!( + out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text == "hello there")), + "bot says: {out:?}" + ); + // ACT wraps the text in a CTCP ACTION. + let out = bs(&mut e, "000AAAAAB", "ACT #c waves"); + assert!( + out.iter().any(|a| matches!(a, NetAction::Privmsg { text, .. } if text == "\x01ACTION waves\x01")), + "bot acts: {out:?}" + ); + // A non-op can't drive the bot. + let out = bs(&mut e, "000AAAAAR", "SAY #c nope"); + assert!(!out.iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "non-op blocked: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "denied notice: {out:?}"); + } + // MemoServ delivers a memo to an offline account and notifies them on login. #[test] fn memoserv_delivers_and_notifies_on_login() { diff --git a/src/engine/state.rs b/src/engine/state.rs index c26a2f3..866f982 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -13,6 +13,10 @@ pub struct Network { channels: HashMap, // keyed by lowercase name accounts: HashMap, // UID -> logged-in account, until logout/quit seen: HashMap, // lowercase nick -> last activity + // Our own service bots (lowercase nick -> live uid). Kept separate from + // `users` so a reconnect can clear them wholesale without disturbing the + // uplink-sourced user map. + bots: HashMap, } pub struct User { @@ -49,9 +53,27 @@ impl Network { self.users.insert(uid.clone(), User { uid, nick, host }); } - // Resolve a nick to its uid (case-insensitive). + // Resolve a nick to its uid (case-insensitive). Checks real users first, + // then our own service bots. pub fn uid_by_nick(&self, nick: &str) -> Option<&str> { - self.users.values().find(|u| u.nick.eq_ignore_ascii_case(nick)).map(|u| u.uid.as_str()) + self.users + .values() + .find(|u| u.nick.eq_ignore_ascii_case(nick)) + .map(|u| u.uid.as_str()) + .or_else(|| self.bots.get(&nick.to_ascii_lowercase()).map(String::as_str)) + } + + // Track / forget one of our service bots by lowercase nick. + pub fn bot_connect(&mut self, nick: &str, uid: &str) { + self.bots.insert(nick.to_ascii_lowercase(), uid.to_string()); + } + + pub fn bot_forget(&mut self, nick_lc: &str) { + self.bots.remove(nick_lc); + } + + pub fn clear_bots(&mut self) { + self.bots.clear(); } pub fn host_of(&self, uid: &str) -> Option<&str> {