ChanServ: add SET AUTOOP
All checks were successful
CI / check (push) Successful in 3m35s

SET AUTOOP {ON|OFF} controls whether access members are auto-opped/voiced
on join (on by default). Stored inverted as a settings flag so the
default stays on, and gated in the join handler.
This commit is contained in:
Jean Chevronnet 2026-07-16 00:36:19 +00:00
parent 2c282d036f
commit 2850620be1
No known key found for this signature in database
7 changed files with 46 additions and 3 deletions

View file

@ -756,6 +756,8 @@ pub enum ChanSetting {
TopicLock, TopicLock,
// Kick users without channel access when they join. // Kick users without channel access when they join.
Restricted, Restricted,
// Auto-op/voice access members on join (on by default).
AutoOp,
// BotServ: show members' personal greets on join. // BotServ: show members' personal greets on join.
BotGreet, BotGreet,
// BotServ: forbid the founder from (un)assigning a bot (admin override only). // BotServ: forbid the founder from (un)assigning a bot (admin override only).

View file

@ -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: "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: "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: "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 <account> | DESC <text> | SUCCESSOR <account>|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 <account> | DESC <text> | SUCCESSOR <account>|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 <account> <op|voice> | DEL <account>\x02\nManages the channel access list." }, HelpEntry { cmd: "ACCESS", summary: "manage the access list", detail: "Syntax: \x02ACCESS <#channel> LIST | ADD <account> <op|voice> | DEL <account>\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: "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 <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." }, HelpEntry { cmd: "AOP/SOP/VOP", summary: "tiered access shortcuts", detail: "Syntax: \x02AOP|SOP|VOP <#channel> ADD <account> | DEL <account> | LIST\x02\nTiered shortcuts over ACCESS: AOP and SOP grant op, VOP grants voice." },

View file

@ -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("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("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("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("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()), Some("TOPICLOCK") => toggle(me, from, ctx, db, chan, ChanSetting::TopicLock, args.get(3).copied()),
_ => ctx.notice(me, from.uid, "Syntax: SET <#channel> FOUNDER <account> | SUCCESSOR <account>|OFF | DESC <text> | 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 <account> | SUCCESSOR <account>|OFF | DESC <text> | 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::Peace => "PEACE",
ChanSetting::SecureOps => "SECUREOPS", ChanSetting::SecureOps => "SECUREOPS",
ChanSetting::Restricted => "RESTRICTED", ChanSetting::Restricted => "RESTRICTED",
ChanSetting::AutoOp => "AUTOOP",
ChanSetting::KeepTopic => "KEEPTOPIC", ChanSetting::KeepTopic => "KEEPTOPIC",
ChanSetting::TopicLock => "TOPICLOCK", ChanSetting::TopicLock => "TOPICLOCK",
ChanSetting::BotGreet => "GREET", ChanSetting::BotGreet => "GREET",

View file

@ -198,6 +198,7 @@ impl Db {
ChanSetting::Peace => settings.peace = on, ChanSetting::Peace => settings.peace = on,
ChanSetting::SecureOps => settings.secureops = on, ChanSetting::SecureOps => settings.secureops = on,
ChanSetting::Restricted => settings.restricted = on, ChanSetting::Restricted => settings.restricted = on,
ChanSetting::AutoOp => settings.noautoop = !on, // stored inverted (default on)
ChanSetting::KeepTopic => settings.keeptopic = on, ChanSetting::KeepTopic => settings.keeptopic = on,
ChanSetting::TopicLock => settings.topiclock = on, ChanSetting::TopicLock => settings.topiclock = on,
ChanSetting::BotGreet => settings.bot_greet = on, ChanSetting::BotGreet => settings.bot_greet = on,

View file

@ -355,6 +355,10 @@ pub struct ChanSettings {
// Kick anyone without channel access when they join. // Kick anyone without channel access when they join.
#[serde(default)] #[serde(default)]
pub restricted: bool, 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. // Remember the topic and restore it when the channel is recreated.
#[serde(default)] #[serde(default)]
pub keeptopic: bool, pub keeptopic: bool,

View file

@ -826,13 +826,17 @@ impl Engine {
return out; 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 { match mode {
// A user with access gets their status mode, plus the entry message. // A user with access gets their status mode, plus the entry message.
Some(m) => { Some(m) => {
if let Some(msg) = entrymsg { if let Some(msg) = entrymsg {
out.push(NetAction::Notice { from: from.clone(), to: uid.clone(), text: msg }); 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. // No access: an auto-kick match is banned and kicked, else greeted.
None => { None => {

View file

@ -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:?}"); 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. // BotServ BOT ADD/LIST is oper-gated (Priv::Admin) and the bot is remembered.
#[test] #[test]
fn botserv_bot_add_list_is_oper_gated() { fn botserv_bot_add_list_is_oper_gated() {