BotServ: KICK <#channel> TEST <message> dry-run

Reports which content kicker (caps/formatting/badwords) a line would trip,
without kicking anyone — so an op can tune caps% or a badword regex safely.
Reuses the typed KickerSettings::violation() and the shared badword-set
builder (moved to db so the test and the live path share it).
This commit is contained in:
Jean Chevronnet 2026-07-13 17:53:13 +00:00
parent 9f5f0ed0d2
commit 0edc4d4e87
No known key found for this signature in database
4 changed files with 60 additions and 12 deletions

View file

@ -5,6 +5,17 @@ use std::path::PathBuf;
// Ceiling on a single compiled badword regex, so one pattern can't consume huge
// memory. Shared by add-time validation and the engine's match-time build.
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.
pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
regex::RegexSetBuilder::new(patterns)
.case_insensitive(true)
.size_limit(BADWORD_SIZE_LIMIT)
.build()
.unwrap_or_else(|_| regex::RegexSet::empty())
}
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use argon2::password_hash::rand_core::{OsRng, RngCore};
@ -1515,6 +1526,19 @@ impl Db {
self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice())
}
/// Dry-run the content kickers against a line: the reason it would be kicked,
/// or None. Flood/repeat are stateful/time-based and are not simulated here.
pub fn kicker_test(&self, channel: &str, text: &str) -> Option<String> {
let c = self.channels.get(&key(channel))?;
if let Some(reason) = c.kickers.violation(text) {
return Some(reason.to_string());
}
if c.kickers.badwords && !c.badwords.is_empty() && build_badword_set(&c.badwords).is_match(text) {
return Some("matches a badword".to_string());
}
None
}
// Persist a new badword list (whole-list event) and apply it.
fn write_badwords(&mut self, channel: &str, k: &str, list: Vec<String>) -> Result<(), ChanError> {
self.log
@ -2194,6 +2218,9 @@ impl Store for Db {
fn badwords(&self, channel: &str) -> Vec<String> {
Db::badwords(self, channel).to_vec()
}
fn kicker_test(&self, channel: &str, text: &str) -> Option<String> {
Db::kicker_test(self, channel, text)
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic)
}

View file

@ -1013,7 +1013,7 @@ impl Engine {
if reason.is_none() && k.badwords && !c.badwords.is_empty() {
let rev = c.badwords_rev;
if self.badword_cache.get(channel).map(|cb| cb.rev) != Some(rev) {
let set = build_badword_set(&c.badwords);
let set = db::build_badword_set(&c.badwords);
self.badword_cache.insert(channel.to_string(), CachedBadwords { rev, set });
}
if self.badword_cache.get(channel).unwrap().set.is_match(text) {
@ -1193,17 +1193,6 @@ fn ci_hash(text: &str) -> u64 {
h.finish()
}
// 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.
fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
regex::RegexSetBuilder::new(patterns)
.case_insensitive(true)
.size_limit(db::BADWORD_SIZE_LIMIT)
.build()
.unwrap_or_else(|_| regex::RegexSet::empty())
}
fn login_plain(b64: &str, db: &Db) -> Option<String> {
let raw = STANDARD.decode(b64).ok()?;
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
@ -2365,6 +2354,21 @@ mod tests {
assert!(!say(&mut e, "[unclosed bracket text").iter().any(|a| matches!(a, NetAction::Kick { .. })), "bad pattern not stored");
}
// KICK TEST dry-runs the content kickers against a line without kicking.
#[test]
fn botserv_kick_test_dry_run() {
let (mut e, _p) = kicker_fixture("bskicktest");
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
bs(&mut e, "KICK #c CAPS ON");
bs(&mut e, "BADWORDS #c ADD fr[ao]g");
bs(&mut e, "KICK #c BADWORDS ON");
let says = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
assert!(says(&bs(&mut e, "KICK #c TEST HELLO EVERYONE LOUD"), "would be kicked"), "caps flagged");
assert!(says(&bs(&mut e, "KICK #c TEST i love frogs"), "would be kicked"), "badword flagged");
assert!(says(&bs(&mut e, "KICK #c TEST hello there"), "trips no content kicker"), "clean line ok");
}
// 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]