From 5d1bfb314487e2d94aad4fe51841c28472cd1469 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 18:19:37 +0000 Subject: [PATCH] BotServ: DONTKICKVOICES, plus neutral code comments KICK <#channel> DONTKICKVOICES exempts voiced users from the kickers. The network view now tracks +v the same way it tracks +o: a new ChannelVoice event is parsed from FMODE +v/-v and from +v join prefixes, and set_voice/is_voiced mirror set_op/is_op (voice is cleared on part and quit). scan_ops generalised to scan_status for any status letter. Also scrubbed third-party project name-drops from code comments. --- api/src/lib.rs | 9 ++++++--- botserv/src/badwords.rs | 4 ++-- botserv/src/kick.rs | 8 ++++++-- inspircd/src/lib.rs | 32 +++++++++++++++++++++++++++----- memoserv/src/lib.rs | 4 ++-- nickserv/src/password.rs | 6 +++--- src/engine/db.rs | 10 +++++++--- src/engine/mod.rs | 31 ++++++++++++++++++++++++++++--- src/engine/state.rs | 18 ++++++++++++++++++ 9 files changed, 99 insertions(+), 23 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 5ec62b1..14f9e49 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -28,6 +28,8 @@ pub enum NetEvent { Part { uid: String, channel: String }, // A user's channel-operator status changed (FMODE +o/-o), for live op tracking. ChannelOp { channel: String, uid: String, op: bool }, + // A user's voice status changed (FMODE +v/-v, or a +v join prefix). + ChannelVoice { channel: String, uid: String, voice: bool }, // A channel's modes changed (FMODE), for enforcing mode locks. Our own // changes are filtered out by the protocol layer. ChannelModeChange { channel: String, modes: String }, @@ -120,9 +122,8 @@ pub trait Protocol: Send { // Service vocabulary // --------------------------------------------------------------------------- -// A services-operator privilege. Coarse and typed (not a string namespace like -// Anope's "nickserv/suspend"), so the compiler checks every use and there is one -// gating mechanism, not two. +// A services-operator privilege. Coarse and typed (not a string namespace), so +// the compiler checks every use and there is one gating mechanism, not two. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Priv { // See other users' hidden info (INFO fields, LIST filters). @@ -435,6 +436,8 @@ pub enum Kicker { Warn, // Exemption, not a rule: never kick channel operators. DontKickOps, + // Exemption: never kick voiced users. + DontKickVoices, } impl ChannelView { diff --git a/botserv/src/badwords.rs b/botserv/src/badwords.rs index 17f2f14..52d942c 100644 --- a/botserv/src/badwords.rs +++ b/botserv/src/badwords.rs @@ -2,8 +2,8 @@ use fedserv_api::{ChanError, Sender, ServiceCtx, Store}; // BADWORDS <#channel> ADD | DEL | LIST | CLEAR: manage the // channel's badword patterns. Each entry is a regular expression, so a channel -// can match whatever it likes (improving on Anope's fixed ANY/SINGLE/START/END). -// Enable the kicker itself with KICK <#channel> BADWORDS ON. Founder-or-admin. +// can match whatever it likes. Enable the kicker itself with +// KICK <#channel> BADWORDS ON. Founder-or-admin. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { let Some(&chan) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]"); diff --git a/botserv/src/kick.rs b/botserv/src/kick.rs index 6199fb8..f67e0c0 100644 --- a/botserv/src/kick.rs +++ b/botserv/src/kick.rs @@ -5,7 +5,7 @@ use fedserv_api::{Kicker, Sender, ServiceCtx, Store}; // and the exemption DONTKICKOPS. Founder-or-admin. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { let (Some(&chan), Some(kind)) = (args.get(1), args.get(2)) else { - ctx.notice(me, from.uid, "Syntax: KICK <#channel> [params], or KICK <#channel> TTB "); + ctx.notice(me, from.uid, "Syntax: KICK <#channel> [params], or KICK <#channel> TTB / TEST "); return; }; if !super::require_channel_admin(me, from, chan, ctx, db) { @@ -98,8 +98,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: "BADWORDS" => Kicker::Badwords, "WARN" => Kicker::Warn, "DONTKICKOPS" => Kicker::DontKickOps, + "DONTKICKVOICES" => Kicker::DontKickVoices, other => { - ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS.")); + ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BADWORDS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS, WARN, DONTKICKOPS or DONTKICKVOICES.")); return; } }; @@ -119,6 +120,7 @@ fn label(kicker: Kicker) -> &'static str { Kicker::Badwords => "badwords", Kicker::Warn => "warn", Kicker::DontKickOps => "dontkickops", + Kicker::DontKickVoices => "dontkickvoices", } } @@ -126,6 +128,8 @@ fn toggle(me: &str, from: &Sender, chan: &str, kicker: Kicker, on: bool, ctx: &m match db.set_kicker(chan, kicker, on) { Ok(()) if kicker == Kicker::DontKickOps && on => ctx.notice(me, from.uid, format!("The bot will no longer kick channel operators in \x02{chan}\x02.")), Ok(()) if kicker == Kicker::DontKickOps => ctx.notice(me, from.uid, format!("The bot may again kick channel operators in \x02{chan}\x02.")), + Ok(()) if kicker == Kicker::DontKickVoices && on => ctx.notice(me, from.uid, format!("The bot will no longer kick voiced users in \x02{chan}\x02.")), + Ok(()) if kicker == Kicker::DontKickVoices => ctx.notice(me, from.uid, format!("The bot may again kick voiced users in \x02{chan}\x02.")), Ok(()) if kicker == Kicker::Warn && on => ctx.notice(me, from.uid, format!("The bot will warn once before the first kick in \x02{chan}\x02.")), Ok(()) if kicker == Kicker::Warn => ctx.notice(me, from.uid, format!("The bot will kick without warning in \x02{chan}\x02.")), Ok(()) if on => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now on in \x02{chan}\x02.", label(kicker))), diff --git a/inspircd/src/lib.rs b/inspircd/src/lib.rs index f3e1d85..2737300 100644 --- a/inspircd/src/lib.rs +++ b/inspircd/src/lib.rs @@ -115,6 +115,9 @@ impl Protocol for InspIrcd { let uid = member.split(':').next().unwrap_or(""); if !uid.is_empty() { out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: prefix.contains('o') }); + if prefix.contains('v') { + out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid: uid.to_string(), voice: true }); + } } } } @@ -157,9 +160,12 @@ impl Protocol for InspIrcd { if let Some(key) = scan_key(modes, params) { out.push(NetEvent::ChannelKey { channel: chan.to_string(), key }); } - for (op, uid) in scan_ops(modes, params) { + for (op, uid) in scan_status(modes, params, 'o') { out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op }); } + for (voice, uid) in scan_status(modes, params, 'v') { + out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid, voice }); + } out } _ => vec![], @@ -345,9 +351,10 @@ fn scan_key(modes: &str, params: &[&str]) -> Option> { result } -// Walk a mode change and its params for op grants/revokes (+o/-o). Returns -// (is_op, uid) per `o` in the change, using the same param cursor as scan_key. -fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> { +// Walk a mode change and its params for grants/revokes of one status mode +// (`want`, e.g. 'o' or 'v'). Returns (is_set, uid) per match, using the same +// param cursor as scan_key. +fn scan_status(modes: &str, params: &[&str], want: char) -> Vec<(bool, String)> { let mut adding = true; let mut pi = 0; let mut out = Vec::new(); @@ -355,7 +362,7 @@ fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> { match m { '+' => adding = true, '-' => adding = false, - 'o' => { + c if c == want => { if let Some(p) = params.get(pi) { out.push((adding, p.to_string())); } @@ -426,6 +433,21 @@ mod tests { assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}"); } + // FMODE and an FJOIN prefix both surface voice status changes. + #[test] + fn parses_voice_status() { + // +o then +v with two params: op for the first uid, voice for the second. + let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +ov 0IRAAAAAB 0IRAAAAAC"); + assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelOp { uid, op: true, .. } if uid == "0IRAAAAAB")), "op: {ev:?}"); + assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAC")), "voice: {ev:?}"); + // A -v revokes voice. + let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 -v 0IRAAAAAC"); + assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: false, .. } if uid == "0IRAAAAAC")), "devoice: {ev:?}"); + // A +v join prefix surfaces a voice event alongside the Join. + let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :v,0IRAAAAAD"); + assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAD")), "join voice: {ev:?}"); + } + #[test] fn parses_ftopic_and_filters_own() { let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all"); diff --git a/memoserv/src/lib.rs b/memoserv/src/lib.rs index d915636..37e6a3f 100644 --- a/memoserv/src/lib.rs +++ b/memoserv/src/lib.rs @@ -1,8 +1,8 @@ //! MemoServ delivers short messages ("memos") to registered accounts whether or //! not they are online — they read them next time they identify. Memos are typed //! and event-logged on the account, so they persist and federate like any other -//! account data (no Anope-style flat-file serialization). `lib.rs` holds the -//! dispatcher; each command lives in its own file, matching NickServ/ChanServ. +//! account data. `lib.rs` holds the dispatcher; each command lives in its own +//! file, matching NickServ/ChanServ. use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; diff --git a/nickserv/src/password.rs b/nickserv/src/password.rs index 2f8a710..ff5f555 100644 --- a/nickserv/src/password.rs +++ b/nickserv/src/password.rs @@ -1,6 +1,6 @@ -// Password policy for REGISTER and SET PASSWORD. Anope enforces a minimum -// length and forbids the password matching the nick; we do the same, plus an -// upper bound so a huge password can't be used to burn CPU in the key +// Password policy for REGISTER and SET PASSWORD: a minimum length and forbidding +// the password matching the nick, plus an upper bound so a huge password can't +// be used to burn CPU in the key // derivation. Length is measured in characters, the cap in bytes (what the // hasher actually processes). diff --git a/src/engine/db.rs b/src/engine/db.rs index b3555e0..ac92fd3 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -7,8 +7,8 @@ use std::path::PathBuf; pub const BADWORD_SIZE_LIMIT: usize = 1 << 20; /// Compile a channel's badword patterns into one RegexSet. Case-insensitive by -/// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited, -/// and tolerant of a bad entry so one pattern can't disable the whole set. +/// default (a pattern can opt out with `(?-i)`), size-limited, and tolerant of a +/// bad entry so one pattern can't disable the whole set. pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet { regex::RegexSetBuilder::new(patterns) .case_insensitive(true) @@ -376,6 +376,9 @@ pub struct KickerSettings { // Don't kick channel operators, whatever they send. #[serde(default)] pub dontkickops: bool, + // Don't kick voiced (+v) users. + #[serde(default)] + pub dontkickvoices: bool, } impl KickerSettings { @@ -384,7 +387,7 @@ impl KickerSettings { self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords } - // Resolved thresholds, applying Anope's defaults for a 0 (unset) value. + // Resolved thresholds, applying the defaults for a 0 (unset) value. pub fn flood_thresholds(&self) -> (u16, u16) { (if self.flood_lines < 2 { 6 } else { self.flood_lines }, if self.flood_secs == 0 { 10 } else { self.flood_secs }) } @@ -1465,6 +1468,7 @@ impl Db { Kicker::Badwords => k.badwords = on, Kicker::Warn => k.warn = on, Kicker::DontKickOps => k.dontkickops = on, + Kicker::DontKickVoices => k.dontkickvoices = on, }) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index bb48ef3..086b946 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -20,7 +20,7 @@ use state::Network; // `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept. const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN"; -// The in-channel fantasy trigger, e.g. `!op`. Matches Anope's default. +// The in-channel fantasy trigger, e.g. `!op`. const FANTASY_PREFIX: &str = "!"; // A client's base64 response is split into chunks of this length; a chunk @@ -669,6 +669,10 @@ impl Engine { } Vec::new() } + NetEvent::ChannelVoice { channel, uid, voice } => { + self.network.set_voice(&channel, &uid, voice); + Vec::new() + } NetEvent::ChannelKey { channel, key } => { self.network.set_channel_key(&channel, key); Vec::new() @@ -1021,6 +1025,9 @@ impl Engine { if k.dontkickops && self.network.is_op(channel, from) { return Vec::new(); } + if k.dontkickvoices && self.network.is_voiced(channel, from) { + return Vec::new(); + } let Some(bot) = c.assigned_bot.as_deref() else { return Vec::new() }; let Some(botuid) = self.network.uid_by_nick(bot).map(str::to_string) else { return Vec::new() }; @@ -1174,7 +1181,7 @@ impl Engine { // that has a bot assigned. We reuse ChanServ's real command handlers — the // fantasy word becomes the command and the channel is injected as its first // argument — then re-source the resulting actions from the bot, so the bot is - // the visible actor (Anope routes fantasy through the assigned bot too). + // the visible actor. fn fantasy(&mut self, sender: &Sender, chan: &str, text: &str, ctx: &mut ServiceCtx) { let Some(rest) = text.strip_prefix(FANTASY_PREFIX) else { return }; let mut words = rest.split_whitespace(); @@ -2124,7 +2131,7 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}"); } - // BOT DEL * removes every bot at once (Anope issue #315), quitting each. + // BOT DEL * removes every bot at once, quitting each. #[test] fn botserv_mass_bot_removal() { use fedserv_botserv::BotServ; @@ -2469,6 +2476,24 @@ mod tests { assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss"); } + // DONTKICKVOICES exempts voiced users; removing the voice makes them kickable. + #[test] + fn botserv_dontkickvoices_exempts() { + let (mut e, _p) = kicker_fixture("bsdkv"); + let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() }); + let say = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAS".into(), to: "#c".into(), text: t.into() }); + let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS")); + bs(&mut e, "KICK #c CAPS ON"); + bs(&mut e, "KICK #c DONTKICKVOICES ON"); + + // Voiced: exempt. + e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: true }); + assert!(!kicked(&say(&mut e, "SHOUTING WHILE VOICED")), "voiced user exempt"); + // Devoiced: kickable again. + e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: false }); + assert!(kicked(&say(&mut e, "SHOUTING WITHOUT VOICE")), "devoiced user kicked"); + } + // WARN gives a one-time warning before the first real kick. #[test] fn botserv_warn_before_kick() { diff --git a/src/engine/state.rs b/src/engine/state.rs index 866f982..87c09d0 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -30,6 +30,7 @@ pub struct User { pub struct Channel { pub members: HashSet, // uids pub ops: HashSet, // uids holding channel-operator status + pub voices: HashSet, // uids holding +v pub key: Option, } @@ -96,6 +97,7 @@ impl Network { for c in self.channels.values_mut() { c.members.remove(uid); c.ops.remove(uid); + c.voices.remove(uid); } } @@ -143,6 +145,7 @@ impl Network { if let Some(c) = self.channels.get_mut(&lc(channel)) { c.members.remove(uid); c.ops.remove(uid); + c.voices.remove(uid); } if let Some(nick) = self.nick_of(uid) { self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") }); @@ -164,6 +167,21 @@ impl Network { self.channels.get(&lc(channel)).is_some_and(|c| c.ops.contains(uid)) } + // Set or clear a user's voice status (+v). + pub fn set_voice(&mut self, channel: &str, uid: &str, voice: bool) { + let c = self.channels.entry(lc(channel)).or_default(); + if voice { + c.voices.insert(uid.to_string()); + } else { + c.voices.remove(uid); + } + } + + // Whether `uid` currently holds voice in `channel`. + pub fn is_voiced(&self, channel: &str, uid: &str) -> bool { + self.channels.get(&lc(channel)).is_some_and(|c| c.voices.contains(uid)) + } + // Uids currently in `channel`. pub fn channel_members(&self, channel: &str) -> impl Iterator { self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))