BotServ: DONTKICKVOICES, plus neutral code comments

KICK <#channel> DONTKICKVOICES <on|off> 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.
This commit is contained in:
Jean Chevronnet 2026-07-13 18:19:37 +00:00
parent 929a4cdd92
commit 5d1bfb3144
No known key found for this signature in database
9 changed files with 99 additions and 23 deletions

View file

@ -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,
})
}

View file

@ -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() {

View file

@ -30,6 +30,7 @@ pub struct User {
pub struct Channel {
pub members: HashSet<String>, // uids
pub ops: HashSet<String>, // uids holding channel-operator status
pub voices: HashSet<String>, // uids holding +v
pub key: Option<String>,
}
@ -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<Item = &str> {
self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))