diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 74de2f8..ed0f268 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1049,7 +1049,7 @@ impl Engine { // creation and on the uplink burst, so registered channels regain the // mode after emptying, and after a services relink. NetEvent::ChannelCreate { channel } => { - let from = self.chan_service.clone().unwrap_or_default(); + let from = self.channel_mode_source(&channel); match self.db.channel(&channel) { Some(info) => { let mut out = vec![NetAction::ChannelMode { from: from.clone(), channel: channel.clone(), modes: info.lock_modes() }]; @@ -1064,7 +1064,7 @@ impl Engine { } // Enforce the mode lock: revert any change that broke it. NetEvent::ChannelModeChange { channel, modes } => { - let from = self.chan_service.clone().unwrap_or_default(); + let from = self.channel_mode_source(&channel); match self.db.channel(&channel).and_then(|info| info.enforce(&modes)) { Some(revert) => vec![NetAction::ChannelMode { from, channel, modes: revert }], None => Vec::new(), @@ -1083,7 +1083,7 @@ impl Engine { return Vec::new(); } let account = self.network.account_of(&uid).map(str::to_string); - let from = self.chan_service.clone().unwrap_or_default(); + let from = self.channel_mode_source(&channel); // Group-aware: a member of a group that holds channel access gets // the group's status mode too. let mode = account.as_deref().and_then(|a| self.db.channel_join_mode(&channel, a)); @@ -1382,6 +1382,18 @@ impl Engine { // The greet a channel's bot shows when a member logged into `account` joins: // only when greets are enabled for the channel, the member holds channel // access, and has set a non-empty personal greet. + // The uid a channel's own actions are sourced from: its assigned bot if one is + // live, else ChanServ — so in-channel actions (auto-op, mode lock, entry + // message) front as the bot when the channel has one, like Anope. + fn channel_mode_source(&self, channel: &str) -> String { + self.db.channel(channel) + .and_then(|c| c.assigned_bot.clone()) + .map(|b| b.to_ascii_lowercase()) + .and_then(|b| self.bot_uids.get(&b).cloned()) + .or_else(|| self.chan_service.clone()) + .unwrap_or_default() + } + fn greet_on_join(&self, channel: &str, account: Option<&str>) -> Option { let acc = account?; let c = self.db.channel(channel)?; diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 88e3235..350d540 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -486,6 +486,50 @@ assert!(parted, "the assigned bot parts the released channel"); } + // A channel with an assigned bot: the bot (not ChanServ) fronts the in-channel + // auto-op, so it reads as the bot setting the mode. + #[test] + fn assigned_bot_fronts_channel_auto_op() { + use echo_botserv::BotServ; + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-botfront.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "test"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register_channel("#room", "alice").unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(ChanServ { uid: "42SAAAAAB".into() }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin)); + e.set_opers(opers); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() }); + let botuid = out.iter().find_map(|a| match a { + NetAction::ServiceJoin { uid, channel } if channel == "#room" => Some(uid.clone()), + _ => None, + }).expect("bot joins #room"); + + // Founder alice joins: the auto-op is sourced from the bot, not ChanServ. + let join = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: false }); + let src = join.iter().find_map(|a| match a { + NetAction::ChannelMode { from, modes, .. } if modes.contains("+o") => Some(from.clone()), + _ => None, + }).expect("alice auto-opped"); + assert_eq!(src, botuid, "auto-op fronts as the assigned bot"); + assert_ne!(src, "42SAAAAAB", "not sourced from ChanServ"); + } + // A server split (SQUIT) forgets exactly the users behind it (uids carrying // that SID), leaving users on other servers untouched. #[test]