BotServ: KICK <#channel> WARN — one warning before the first kick

With WARN on, a user's first kicker offence gets a private warning from
the bot instead of a kick; the next one is kicked (and then TTB can ban).
The warned flag rides the existing per-user chatter state.
This commit is contained in:
Jean Chevronnet 2026-07-13 17:57:40 +00:00
parent c8be5b4e95
commit 47b1fd351e
No known key found for this signature in database
4 changed files with 44 additions and 1 deletions

View file

@ -425,6 +425,8 @@ pub enum Kicker {
Repeat,
// Kick lines matching a configured badword regex (patterns via BADWORDS).
Badwords,
// Modifier: warn once before the first kick.
Warn,
// Exemption, not a rule: never kick channel operators.
DontKickOps,
}

View file

@ -96,6 +96,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
"REVERSES" => Kicker::Reverses,
"ITALICS" => Kicker::Italics,
"BADWORDS" => Kicker::Badwords,
"WARN" => Kicker::Warn,
"DONTKICKOPS" => Kicker::DontKickOps,
other => {
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
@ -116,6 +117,7 @@ fn label(kicker: Kicker) -> &'static str {
Kicker::Flood => "flood",
Kicker::Repeat => "repeat",
Kicker::Badwords => "badwords",
Kicker::Warn => "warn",
Kicker::DontKickOps => "dontkickops",
}
}
@ -124,6 +126,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::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))),
Ok(()) => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now off in \x02{chan}\x02.", label(kicker))),
Err(_) => reg_error(me, from, chan, ctx),

View file

@ -355,6 +355,9 @@ pub struct KickerSettings {
// How long such a ban lasts, in seconds. 0 = until manually removed.
#[serde(default)]
pub ban_expire: u32,
// Warn a user (once) before kicking them the first time.
#[serde(default)]
pub warn: bool,
// Don't kick channel operators, whatever they send.
#[serde(default)]
pub dontkickops: bool,
@ -1442,6 +1445,7 @@ impl Db {
Kicker::Flood => k.flood = on,
Kicker::Repeat => k.repeat = on,
Kicker::Badwords => k.badwords = on,
Kicker::Warn => k.warn = on,
Kicker::DontKickOps => k.dontkickops = on,
})
}

View file

@ -128,6 +128,7 @@ struct ChatterState {
last_hash: u64, // case-insensitive hash of the previous line
repeats: u16, // consecutive identical lines seen so far (0 on the first)
kicks: u16, // times kicked so far (reset when it triggers a ban)
warned: bool, // whether the one-time warning has been given
}
// A kicker ban awaiting automatic removal (BANEXPIRE).
@ -1024,7 +1025,7 @@ impl Engine {
// the counter map below.
let (flood_lines, flood_secs) = k.flood_thresholds();
let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold());
let (ttb, ban_expire) = (k.ttb, k.ban_expire);
let (ttb, ban_expire, warn) = (k.ttb, k.ban_expire, k.warn);
// FLOOD/REPEAT (only if nothing has tripped yet), updating counters.
if reason.is_none() && (flood || repeat) {
@ -1056,6 +1057,20 @@ impl Engine {
let Some(reason) = reason else { return Vec::new() };
// WARN: the first offence gets a private warning from the bot instead of a
// kick; the next one is kicked for real.
if warn {
let already = {
let state = self.chatter_entry(channel, from);
let w = state.warned;
state.warned = true;
w
};
if !already {
return vec![NetAction::Notice { from: botuid, to: from.to_string(), text: format!("Please mind the channel rules — {reason} Next time you'll be kicked.") }];
}
}
// 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();
@ -2393,6 +2408,24 @@ mod tests {
assert!(says(&bs(&mut e, "KICK #d TEST i love frogs"), "would be kicked"), "badwords copied");
}
// WARN gives a one-time warning before the first real kick.
#[test]
fn botserv_warn_before_kick() {
let (mut e, _p) = kicker_fixture("bswarn");
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 WARN ON");
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
// First offence: a warning notice from the bot, no kick.
let out = say(&mut e, "SHOUTING THE FIRST TIME");
assert!(!kicked(&out), "no kick on first offence");
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { from, to, text } if from.starts_with("42SB") && to == "000AAAAAS" && text.contains("Next time"))), "warned: {out:?}");
// Second offence: kicked for real.
assert!(kicked(&say(&mut e, "SHOUTING THE SECOND TIME")), "kicked on second offence");
}
// KICK TEST dry-runs the content kickers against a line without kicking.
#[test]
fn botserv_kick_test_dry_run() {