BotServ kickers: times-to-ban (TTB) + BANEXPIRE

KICK <#channel> TTB <n> makes the bot ban a user after they've been
kicked n times (by any kicker) instead of only kicking; SET <#channel>
BANEXPIRE <duration|off> controls how long that ban lasts. One clear
per-channel knob rather than Anope's per-kicker ttb array.

The kick count rides in the existing ephemeral per-user chatter state, so
it's dropped when the user parts or quits. On the ban the bot sets +b on
an ideal host mask and, if BANEXPIRE is set, queues an unban that is swept
off the next event — no timer thread. Ban time is injectable for tests.
This commit is contained in:
Jean Chevronnet 2026-07-13 17:12:12 +00:00
parent ca95184359
commit ec3205bf75
No known key found for this signature in database
5 changed files with 225 additions and 63 deletions

View file

@ -556,6 +556,8 @@ pub trait Store {
fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>; fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError>;
fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>; fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError>;
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>; fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>;
fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError>;
fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError>;
// BADWORDS list (regex patterns). add validates the pattern compiles. // BADWORDS list (regex patterns). add validates the pattern compiles.
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>; fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;
fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>; fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;

View file

@ -5,12 +5,26 @@ use fedserv_api::{Kicker, Sender, ServiceCtx, Store};
// and the exemption DONTKICKOPS. Founder-or-admin. // and the exemption DONTKICKOPS. Founder-or-admin.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { 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 { let (Some(&chan), Some(kind)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|FLOOD|REPEAT|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params]"); ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|FLOOD|REPEAT|BADWORDS|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params], or KICK <#channel> TTB <n>");
return; return;
}; };
if !super::require_channel_admin(me, from, chan, ctx, db) { if !super::require_channel_admin(me, from, chan, ctx, db) {
return; return;
} }
// TTB takes a number, not ON/OFF: kicks-before-ban (0 = never ban).
if kind.eq_ignore_ascii_case("TTB") {
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_ttb(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, format!("The bot will only kick (not ban) in \x02{chan}\x02.")),
Ok(()) => ctx.notice(me, from.uid, format!("The bot will ban a user after \x02{n}\x02 kick(s) in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
},
None => ctx.notice(me, from.uid, "Syntax: KICK <#channel> TTB <number>"),
}
return;
}
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() { let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ON") | Some("TRUE") => true, Some("ON") | Some("TRUE") => true,
Some("OFF") | Some("FALSE") => false, Some("OFF") | Some("FALSE") => false,

View file

@ -1,15 +1,42 @@
use fedserv_api::{ChanSetting, Sender, ServiceCtx, Store}; use fedserv_api::{parse_duration, ChanSetting, Sender, ServiceCtx, Store};
// SET <#channel> <option> <on|off>: per-channel bot options. Founder-or-admin. // SET <#channel> <option> <value>: per-channel bot options. Founder-or-admin.
// Currently: GREET (show members' personal greets when they join). // GREET <on|off> (show members' greets on join), BANEXPIRE <duration|off> (how
// long kicker bans last).
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&chan), Some(option)) = (args.get(1), args.get(2)) else { let (Some(&chan), Some(option)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> GREET <ON|OFF>"); ctx.notice(me, from.uid, "Syntax: SET <#channel> <GREET <ON|OFF> | BANEXPIRE <duration|off>>");
return; return;
}; };
if !super::require_channel_admin(me, from, chan, ctx, db) { if !super::require_channel_admin(me, from, chan, ctx, db) {
return; return;
} }
// BANEXPIRE takes a duration, not ON/OFF.
if option.eq_ignore_ascii_case("BANEXPIRE") {
let Some(&arg) = args.get(3) else {
ctx.notice(me, from.uid, "Syntax: SET <#channel> BANEXPIRE <duration|off> (e.g. 30m, 2h, off).");
return;
};
let secs = if matches!(arg.to_ascii_lowercase().as_str(), "off" | "none" | "0") {
0
} else {
match parse_duration(arg) {
Some(s) => s.min(u32::MAX as u64) as u32,
None => {
ctx.notice(me, from.uid, format!("\x02{arg}\x02 isn't a valid duration. Try 30m, 2h, 7d or \x02off\x02."));
return;
}
}
};
match db.set_ban_expire(chan, secs) {
Ok(()) if secs == 0 => ctx.notice(me, from.uid, format!("Kicker bans in \x02{chan}\x02 won't expire automatically.")),
Ok(()) => ctx.notice(me, from.uid, format!("Kicker bans in \x02{chan}\x02 will expire after \x02{arg}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;
}
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() { let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {
Some("ON") | Some("TRUE") => true, Some("ON") | Some("TRUE") => true,
Some("OFF") | Some("FALSE") => false, Some("OFF") | Some("FALSE") => false,
@ -24,6 +51,6 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\x02 in \x02{chan}\x02.")), Ok(()) => ctx.notice(me, from.uid, format!("Greet messages are now \x02off\x02 in \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}, },
other => ctx.notice(me, from.uid, format!("Unknown option \x02{other}\x02. Available: \x02GREET\x02.")), other => ctx.notice(me, from.uid, format!("Unknown option \x02{other}\x02. Available: \x02GREET\x02, \x02BANEXPIRE\x02.")),
} }
} }

View file

@ -331,6 +331,13 @@ pub struct KickerSettings {
// Kick lines matching one of the channel's badword regexes. // Kick lines matching one of the channel's badword regexes.
#[serde(default)] #[serde(default)]
pub badwords: bool, pub badwords: bool,
// Times a user may be kicked (by any kicker) before the bot bans them
// instead. 0 = never ban, only kick.
#[serde(default)]
pub ttb: u16,
// How long such a ban lasts, in seconds. 0 = until manually removed.
#[serde(default)]
pub ban_expire: u32,
// Don't kick channel operators, whatever they send. // Don't kick channel operators, whatever they send.
#[serde(default)] #[serde(default)]
pub dontkickops: bool, pub dontkickops: bool,
@ -799,7 +806,7 @@ impl Db {
if let Some(bot) = &c.assigned_bot { if let Some(bot) = &c.assigned_bot {
snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() }); snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() });
} }
if c.kickers.any() || c.kickers.dontkickops { if c.kickers.any() || c.kickers.dontkickops || c.kickers.ttb > 0 || c.kickers.ban_expire > 0 {
snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() }); snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() });
} }
if !c.badwords.is_empty() { if !c.badwords.is_empty() {
@ -1447,6 +1454,16 @@ impl Db {
}) })
} }
/// Kicks-before-ban threshold (0 = never ban, only kick).
pub fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError> {
self.update_kickers(channel, |k| k.ttb = ttb)
}
/// How long a kicker ban lasts, in seconds (0 = until removed).
pub fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> {
self.update_kickers(channel, |k| k.ban_expire = secs)
}
/// Add a badword regex. Validates it compiles within the size limit; returns /// Add a badword regex. Validates it compiles within the size limit; returns
/// Ok(false) if the exact pattern is already present. /// Ok(false) if the exact pattern is already present.
pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> { pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
@ -2092,6 +2109,12 @@ impl Store for Db {
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> { fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
Db::set_repeat_kicker(self, channel, times) Db::set_repeat_kicker(self, channel, times)
} }
fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError> {
Db::set_ttb(self, channel, ttb)
}
fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> {
Db::set_ban_expire(self, channel, secs)
}
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> { fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
Db::badword_add(self, channel, pattern) Db::badword_add(self, channel, pattern)
} }

View file

@ -109,6 +109,8 @@ pub struct Engine {
// every pattern in a single linear pass (and the regex crate is backtracking- // every pattern in a single linear pass (and the regex crate is backtracking-
// free, so user patterns can't blow up). Never event-logged. // free, so user patterns can't blow up). Never event-logged.
badword_cache: HashMap<String, CachedBadwords>, badword_cache: HashMap<String, CachedBadwords>,
// Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event.
pending_unbans: Vec<PendingUnban>,
} }
struct CachedBadwords { struct CachedBadwords {
@ -116,13 +118,23 @@ struct CachedBadwords {
set: regex::RegexSet, set: regex::RegexSet,
} }
// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers). // One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers,
// plus the running kick count that drives times-to-ban).
#[derive(Default)] #[derive(Default)]
struct ChatterState { struct ChatterState {
flood_start: u64, // window start (unix secs) flood_start: u64, // window start (unix secs)
flood_lines: u16, // lines since flood_start flood_lines: u16, // lines since flood_start
last_hash: u64, // case-insensitive hash of the previous line last_hash: u64, // case-insensitive hash of the previous line
repeats: u16, // consecutive identical lines seen so far (0 on the first) repeats: u16, // consecutive identical lines seen so far (0 on the first)
kicks: u16, // times kicked so far (reset when it triggers a ban)
}
// A kicker ban awaiting automatic removal (BANEXPIRE).
struct PendingUnban {
at: u64, // unix secs when it should be lifted
from: String, // the bot uid that placed it, to source the -b from
channel: String,
mask: String,
} }
impl Engine { impl Engine {
@ -146,6 +158,7 @@ impl Engine {
chatter: HashMap::new(), chatter: HashMap::new(),
now_override: None, now_override: None,
badword_cache: HashMap::new(), badword_cache: HashMap::new(),
pending_unbans: Vec::new(),
} }
} }
@ -516,7 +529,10 @@ impl Engine {
} }
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> { pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
let out = match event { // Lift any kicker bans whose BANEXPIRE has elapsed (cheap when none are
// pending, which is the usual case).
let mut out = self.sweep_unbans();
let evout = match event {
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
NetEvent::UserConnect { uid, nick, host } => { NetEvent::UserConnect { uid, nick, host } => {
self.network.user_connect(uid, nick, host); self.network.user_connect(uid, nick, host);
@ -662,7 +678,26 @@ impl Engine {
NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data), NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data),
_ => Vec::new(), _ => Vec::new(),
}; };
self.track_accounts(&out); self.track_accounts(&evout);
out.extend(evout);
out
}
// Remove kicker bans whose BANEXPIRE has elapsed, sourced from ChanServ.
fn sweep_unbans(&mut self) -> Vec<NetAction> {
if self.pending_unbans.is_empty() {
return Vec::new();
}
let now = self.now_secs();
let mut out = Vec::new();
self.pending_unbans.retain(|u| {
if u.at <= now {
out.push(NetAction::ChannelMode { from: u.from.clone(), channel: u.channel.clone(), modes: format!("-b {}", u.mask) });
false
} else {
true
}
});
out out
} }
@ -899,9 +934,8 @@ impl Engine {
// In-channel: a fantasy command (!op …) if a bot is assigned, plus a // In-channel: a fantasy command (!op …) if a bot is assigned, plus a
// BotServ kicker check on every line. // BotServ kicker check on every line.
self.fantasy(&sender, to, text, &mut ctx); self.fantasy(&sender, to, text, &mut ctx);
if let Some(k) = self.kicker_check(from, to, text) { let kicks = self.kicker_check(from, to, text);
ctx.actions.push(k); ctx.actions.extend(kicks);
}
} else { } else {
let Self { services, network, db, .. } = self; let Self { services, network, db, .. } = self;
for svc in services.iter_mut() { for svc in services.iter_mut() {
@ -938,48 +972,103 @@ impl Engine {
// A BotServ kicker verdict for a channel line: if the channel has an active // A BotServ kicker verdict for a channel line: if the channel has an active
// kicker the message trips (and the sender isn't an exempt operator), the // kicker the message trips (and the sender isn't an exempt operator), the
// assigned bot kicks them. `&mut self` because the FLOOD/REPEAT kickers keep // assigned bot kicks them — and, once they've been kicked `ttb` times, bans
// per-user counters. Hot path: one HashMap lookup for a channel with no // them first. `&mut self` because FLOOD/REPEAT and times-to-ban keep per-user
// kickers, and no heap allocation for a returning speaker. // counters. Hot path: one HashMap lookup for a channel with no kickers, and
fn kicker_check(&mut self, from: &str, channel: &str, text: &str) -> Option<NetAction> { // no heap allocation for a returning speaker.
// `c` borrows self.db; self.network and self.chatter are disjoint fields, fn kicker_check(&mut self, from: &str, channel: &str, text: &str) -> Vec<NetAction> {
// so they can be touched while it is alive. // `c` borrows self.db; the other fields (network, chatter, badword_cache)
let c = self.db.channel(channel)?; // are disjoint, so they can be touched while it is alive — but we stop
// using `c` before mutating chatter, by copying its config into locals.
let Some(c) = self.db.channel(channel) else { return Vec::new() };
let k = &c.kickers; let k = &c.kickers;
if !k.any() { if !k.any() {
return None; return Vec::new();
} }
if k.dontkickops && self.network.is_op(channel, from) { if k.dontkickops && self.network.is_op(channel, from) {
return None; return Vec::new();
} }
let botuid = self.network.uid_by_nick(c.assigned_bot.as_deref()?)?.to_string(); let Some(bot) = c.assigned_bot.as_deref() else { return Vec::new() };
let kick = |reason: &str| Some(NetAction::Kick { from: botuid.clone(), channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() }); let Some(botuid) = self.network.uid_by_nick(bot).map(str::to_string) else { return Vec::new() };
// Stateless kickers first (cheapest, and they don't touch counters). // Stateless kickers first (cheapest, and they don't touch counters), then
if let Some(reason) = k.violation(text) { // badwords against the channel's compiled regex set (rebuilt only when the
return kick(reason); // list's revision changes).
} let mut reason: Option<&'static str> = k.violation(text);
// BADWORDS: match the line against the channel's compiled regex set, if reason.is_none() && k.badwords && !c.badwords.is_empty() {
// rebuilding the cache only when the badword list has changed.
if k.badwords && !c.badwords.is_empty() {
let rev = c.badwords_rev; let rev = c.badwords_rev;
if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) { if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) {
let set = build_badword_set(&c.badwords); let set = build_badword_set(&c.badwords);
self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set }); self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set });
} }
if self.badword_cache.get(channel).unwrap().set.is_match(text) { if self.badword_cache.get(channel).unwrap().set.is_match(text) {
return kick("Watch your language!"); reason = Some("Watch your language!");
} }
} }
if !k.flood && !k.repeat { // Copy the rest of the config out so `c`'s borrow ends before we mutate
return None; // the counter map below.
}
let (flood_lines, flood_secs) = k.flood_thresholds(); let (flood_lines, flood_secs) = k.flood_thresholds();
let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold()); let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold());
let now = self.now_secs(); let (ttb, ban_expire) = (k.ttb, k.ban_expire);
// Get (or lazily create) this speaker's counters. get_mut avoids any // FLOOD/REPEAT (only if nothing has tripped yet), updating counters.
// allocation for a channel/user already being tracked. if reason.is_none() && (flood || repeat) {
let now = self.now_secs();
let state = self.chatter_entry(channel, from);
if flood {
if now.saturating_sub(state.flood_start) > flood_secs as u64 {
state.flood_start = now;
state.flood_lines = 0;
}
state.flood_lines = state.flood_lines.saturating_add(1);
if state.flood_lines >= flood_lines {
reason = Some("Stop flooding!");
}
}
if reason.is_none() && repeat {
let h = ci_hash(text);
if h == state.last_hash {
state.repeats = state.repeats.saturating_add(1);
} else {
state.repeats = 0;
state.last_hash = h;
}
if state.repeats >= repeat_times {
reason = Some("Stop repeating yourself!");
}
}
}
let Some(reason) = reason else { return Vec::new() };
// Times-to-ban: after `ttb` kicks the bot bans (an ideal host mask) before
// kicking, and schedules the unban if BANEXPIRE is set.
let mut out = Vec::new();
if ttb > 0 {
let kicks = {
let state = self.chatter_entry(channel, from);
state.kicks = state.kicks.saturating_add(1);
state.kicks
};
if kicks >= ttb {
self.chatter_entry(channel, from).kicks = 0;
if let Some(host) = self.network.host_of(from).map(str::to_string) {
let mask = format!("*!*@{host}");
out.push(NetAction::ChannelMode { from: botuid.clone(), channel: channel.to_string(), modes: format!("+b {mask}") });
if ban_expire > 0 {
let at = self.now_secs() + ban_expire as u64;
self.pending_unbans.push(PendingUnban { at, from: botuid.clone(), channel: channel.to_string(), mask });
}
}
}
}
out.push(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() });
out
}
// Get (or lazily create) a speaker's counters in a channel. get_mut avoids
// any allocation for a channel/user already being tracked.
fn chatter_entry(&mut self, channel: &str, from: &str) -> &mut ChatterState {
if !self.chatter.contains_key(channel) { if !self.chatter.contains_key(channel) {
self.chatter.insert(channel.to_string(), HashMap::new()); self.chatter.insert(channel.to_string(), HashMap::new());
} }
@ -987,31 +1076,7 @@ impl Engine {
if !bucket.contains_key(from) { if !bucket.contains_key(from) {
bucket.insert(from.to_string(), ChatterState::default()); bucket.insert(from.to_string(), ChatterState::default());
} }
let state = bucket.get_mut(from).unwrap(); bucket.get_mut(from).unwrap()
if flood {
if now.saturating_sub(state.flood_start) > flood_secs as u64 {
state.flood_start = now;
state.flood_lines = 0;
}
state.flood_lines = state.flood_lines.saturating_add(1);
if state.flood_lines >= flood_lines {
return kick("Stop flooding!");
}
}
if repeat {
let h = ci_hash(text);
if h == state.last_hash {
state.repeats = state.repeats.saturating_add(1);
} else {
state.repeats = 0;
state.last_hash = h;
}
if state.repeats >= repeat_times {
return kick("Stop repeating yourself!");
}
}
None
} }
// Drop a user's chatter counters for a channel (on part), and the channel's // Drop a user's chatter counters for a channel (on part), and the channel's
@ -2150,6 +2215,37 @@ mod tests {
assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored"); assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored");
} }
// Times-to-ban: after TTB kicks the bot bans the user, and BANEXPIRE lifts
// the ban once it elapses (swept on the next event).
#[test]
fn botserv_ttb_bans_and_expires() {
let (mut e, _p) = kicker_fixture("bsttb");
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() });
bs(&mut e, "KICK #c CAPS ON");
bs(&mut e, "KICK #c TTB 2");
bs(&mut e, "SET #c BANEXPIRE 1m");
let banned = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::ChannelMode { from, channel, modes } if from.starts_with("42SB") && channel == "#c" && modes == "+b *!*@h"));
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
e.now_override = Some(1000);
// First offence: kick only.
let out = say(&mut e, "SHOUTING LOUDLY ONE");
assert!(kicked(&out) && !banned(&out), "1st: kick only: {out:?}");
// Second offence reaches TTB: ban then kick.
let out = say(&mut e, "SHOUTING LOUDLY TWO");
assert!(banned(&out) && kicked(&out), "2nd: ban + kick: {out:?}");
// Before the ban expires, an event lifts nothing.
e.now_override = Some(1059);
let out = e.handle(NetEvent::Ping { token: "t".into(), from: None });
assert!(!out.iter().any(|a| matches!(a, NetAction::ChannelMode { modes, .. } if modes.starts_with("-b"))), "not yet expired: {out:?}");
// After it expires, the next event lifts it.
e.now_override = Some(1061);
let out = e.handle(NetEvent::Ping { token: "t".into(), from: None });
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#c" && modes == "-b *!*@h")), "ban lifted: {out:?}");
}
// Registers #c with founder boss (admin), a live assigned bot Bendy, and // Registers #c with founder boss (admin), a live assigned bot Bendy, and
// returns the engine ready for KICK configuration + a spammer uid 000AAAAAS. // returns the engine ready for KICK configuration + a spammer uid 000AAAAAS.
fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) { fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) {