diff --git a/api/src/lib.rs b/api/src/lib.rs index cdf6dc0..30e5a0a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -756,6 +756,8 @@ pub enum ChanSetting { TopicLock, // Kick users without channel access when they join. Restricted, + // Auto-op/voice access members on join (on by default). + AutoOp, // BotServ: show members' personal greets on join. BotGreet, // BotServ: forbid the founder from (un)assigning a bot (admin override only). diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index e624e4a..92fcaf9 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -51,7 +51,7 @@ const TOPICS: &[HelpEntry] = &[ HelpEntry { cmd: "REGISTER", summary: "register a channel", detail: "Syntax: \x02REGISTER <#channel>\x02\nRegisters a channel to you. You must currently hold ops in it." }, HelpEntry { cmd: "INFO", summary: "show channel information", detail: "Syntax: \x02INFO <#channel>\x02\nShows a channel's registration and settings." }, HelpEntry { cmd: "LIST", summary: "list registered channels", detail: "Syntax: \x02LIST\x02\nLists registered channels." }, - HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER | DESC | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|RESTRICTED|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." }, + HelpEntry { cmd: "SET", summary: "change founder or settings", detail: "Syntax: \x02SET <#channel> FOUNDER | DESC | SUCCESSOR |OFF | SIGNKICK|PRIVATE|PEACE|SECUREOPS|RESTRICTED|AUTOOP|KEEPTOPIC|TOPICLOCK {ON|OFF}\x02\nTransfers the founder or changes a channel setting." }, HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD | DEL \x02\nManages the channel access list." }, HelpEntry { cmd: "FLAGS", summary: "granular per-account flags", detail: "Syntax: \x02FLAGS <#channel> [account [+/-flags]]\x02\nViews or changes granular per-account channel flags." }, HelpEntry { cmd: "AOP/SOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02AOP|SOP|VOP <#channel> ADD | DEL | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." }, diff --git a/modules/chanserv/src/set.rs b/modules/chanserv/src/set.rs index 096976c..85d9992 100644 --- a/modules/chanserv/src/set.rs +++ b/modules/chanserv/src/set.rs @@ -69,9 +69,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Some("PEACE") => toggle(me, from, ctx, db, chan, ChanSetting::Peace, args.get(3).copied()), Some("SECUREOPS") => toggle(me, from, ctx, db, chan, ChanSetting::SecureOps, args.get(3).copied()), Some("RESTRICTED") => toggle(me, from, ctx, db, chan, ChanSetting::Restricted, args.get(3).copied()), + Some("AUTOOP") => toggle(me, from, ctx, db, chan, ChanSetting::AutoOp, args.get(3).copied()), Some("KEEPTOPIC") => toggle(me, from, ctx, db, chan, ChanSetting::KeepTopic, args.get(3).copied()), Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()), - _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | RESTRICTED {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), + _ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER | SUCCESSOR |OFF | DESC | SIGNKICK {ON|OFF} | PRIVATE {ON|OFF} | PEACE {ON|OFF} | SECUREOPS {ON|OFF} | RESTRICTED {ON|OFF} | AUTOOP {ON|OFF} | KEEPTOPIC {ON|OFF} | TOPICLOCK {ON|OFF}"), } } @@ -83,6 +84,7 @@ fn label(setting: ChanSetting) -> &'static str { ChanSetting::Peace => "PEACE", ChanSetting::SecureOps => "SECUREOPS", ChanSetting::Restricted => "RESTRICTED", + ChanSetting::AutoOp => "AUTOOP", ChanSetting::KeepTopic => "KEEPTOPIC", ChanSetting::TopicLock => "TOPICLOCK", ChanSetting::BotGreet => "GREET", diff --git a/src/engine/db/channel.rs b/src/engine/db/channel.rs index e08e727..548142c 100644 --- a/src/engine/db/channel.rs +++ b/src/engine/db/channel.rs @@ -198,6 +198,7 @@ impl Db { ChanSetting::Peace => settings.peace = on, ChanSetting::SecureOps => settings.secureops = on, ChanSetting::Restricted => settings.restricted = on, + ChanSetting::AutoOp => settings.noautoop = !on, // stored inverted (default on) ChanSetting::KeepTopic => settings.keeptopic = on, ChanSetting::TopicLock => settings.topiclock = on, ChanSetting::BotGreet => settings.bot_greet = on, diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 10135a9..0ae2228 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -355,6 +355,10 @@ pub struct ChanSettings { // Kick anyone without channel access when they join. #[serde(default)] pub restricted: bool, + // Stored inverted so it defaults to auto-op ON: when set, access members are + // NOT auto-opped on join (they must UP). SET AUTOOP toggles it. + #[serde(default)] + pub noautoop: bool, // Remember the topic and restore it when the channel is recreated. #[serde(default)] pub keeptopic: bool, diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 37aadc3..ba81991 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -826,13 +826,17 @@ impl Engine { return out; } } + // AUTOOP (on by default): whether access members are auto-opped here. + let autoop = self.db.channel(&channel).is_none_or(|c| !c.settings.noautoop); match mode { // A user with access gets their status mode, plus the entry message. Some(m) => { if let Some(msg) = entrymsg { out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg }); } - out.push(NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }); + if autoop { + out.push(NetAction::ChannelMode { from, channel, modes: format!("{m} {uid}") }); + } } // No access: an auto-kick match is banned and kicked, else greeted. None => { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 75e937d..6658b64 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -1162,6 +1162,36 @@ assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, channel, .. } if uid == "000AAAAAC" && channel == "#c")), "no-access user kicked: {out:?}"); } + // ChanServ SET AUTOOP OFF suppresses auto-op on join (on by default). + #[test] + fn chanserv_autoop_off_suppresses_status() { + use echo_chanserv::ChanServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-autoop.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register_channel("#c", "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() }), + ], + db, + ); + 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() }); + let opped = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes == "+o 000AAAAAB")); + + // Default: the founder is auto-opped on join. + assert!(opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "auto-op on by default"); + e.handle(NetEvent::Part { uid: "000AAAAAB".into(), channel: "#c".into() }); + // Turn AUTOOP off; now a join is not auto-opped. + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAB".into(), text: "SET #c AUTOOP OFF".into() }); + assert!(!opped(&e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#c".into(), op: false })), "no auto-op when AUTOOP is off"); + } + // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered. #[test] fn botserv_bot_add_list_is_oper_gated() {