BotServ kickers: FLOOD and REPEAT

KICK <#channel> FLOOD ON [lines [secs]] kicks a user who sends too many
lines too fast (default 6 in 10s); KICK <#channel> REPEAT ON [times]
kicks one who repeats the same line (default 3, case-insensitive).

These are stateful, so the engine keeps per-channel/per-user counters in
an ephemeral map that is never event-logged. Hot-path conscious: a channel
with no kickers costs one lookup and returns; a returning speaker allocates
nothing (get_mut, not entry); repeat comparison is an allocation-free
case-folding 64-bit hash. Counters are dropped when a user parts or quits,
so the map stays bounded. Flood time is injectable for deterministic tests.
This commit is contained in:
Jean Chevronnet 2026-07-13 15:18:39 +00:00
parent ed7da9c713
commit 43def8ee23
No known key found for this signature in database
4 changed files with 264 additions and 12 deletions

View file

@ -301,6 +301,20 @@ pub struct KickerSettings {
pub reverses: bool,
#[serde(default)]
pub italics: bool,
// Kick users who send too many lines too fast.
#[serde(default)]
pub flood: bool,
// Lines within `flood_secs` that trip the flood kicker (0 = default, 6).
#[serde(default)]
pub flood_lines: u16,
#[serde(default)]
pub flood_secs: u16,
// Kick users who repeat the same line.
#[serde(default)]
pub repeat: bool,
// Consecutive repeats that trip the repeat kicker (0 = default, 3).
#[serde(default)]
pub repeat_times: u16,
// Don't kick channel operators, whatever they send.
#[serde(default)]
pub dontkickops: bool,
@ -309,7 +323,16 @@ pub struct KickerSettings {
impl KickerSettings {
// Any kicker enabled? (dontkickops alone doesn't count.)
pub fn any(&self) -> bool {
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat
}
// Resolved thresholds, applying Anope's 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 })
}
pub fn repeat_threshold(&self) -> u16 {
if self.repeat_times == 0 { 3 } else { self.repeat_times }
}
// The reason to kick `text` for, or None if it trips no enabled kicker.
@ -1372,6 +1395,8 @@ impl Db {
Kicker::Underlines => k.underlines = on,
Kicker::Reverses => k.reverses = on,
Kicker::Italics => k.italics = on,
Kicker::Flood => k.flood = on,
Kicker::Repeat => k.repeat = on,
Kicker::DontKickOps => k.dontkickops = on,
})
}
@ -1385,6 +1410,23 @@ impl Db {
})
}
/// Enable the flood kicker with its lines/seconds thresholds (0 = default).
pub fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> {
self.update_kickers(channel, |k| {
k.flood = true;
k.flood_lines = lines;
k.flood_secs = secs;
})
}
/// Enable the repeat kicker with its repeat-count threshold (0 = default).
pub fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
self.update_kickers(channel, |k| {
k.repeat = true;
k.repeat_times = times;
})
}
// Read-modify-write the whole kicker struct through one event.
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
let k = key(channel);
@ -1963,6 +2005,12 @@ impl Store for Db {
fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError> {
Db::set_caps_kicker(self, channel, caps_min, caps_percent)
}
fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> {
Db::set_flood_kicker(self, channel, lines, secs)
}
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
Db::set_repeat_kicker(self, channel, times)
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic)
}