diff --git a/api/src/lib.rs b/api/src/lib.rs index e42b155..81f76bc 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -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, } diff --git a/botserv/src/kick.rs b/botserv/src/kick.rs index 02e983d..6199fb8 100644 --- a/botserv/src/kick.rs +++ b/botserv/src/kick.rs @@ -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), diff --git a/src/engine/db.rs b/src/engine/db.rs index d4f3f08..1a7ee1d 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -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, }) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index b77773b..394f935 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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() {