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:
parent
9f5f0ed0d2
commit
0edc4d4e87
4 changed files with 60 additions and 12 deletions
|
|
@ -572,6 +572,8 @@ pub trait Store {
|
|||
fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;
|
||||
fn badword_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
|
||||
fn badwords(&self, channel: &str) -> Vec<String>;
|
||||
// Dry-run the content kickers against a line; Some(reason) if it would kick.
|
||||
fn kicker_test(&self, channel: &str, text: &str) -> Option<String>;
|
||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
|
||||
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
|
||||
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
|||
return;
|
||||
}
|
||||
|
||||
// TEST dry-runs the content kickers against a line, kicking nobody — so an
|
||||
// op can tune caps%/badword patterns safely.
|
||||
if kind.eq_ignore_ascii_case("TEST") {
|
||||
if args.len() < 4 {
|
||||
ctx.notice(me, from.uid, "Syntax: KICK <#channel> TEST <message>");
|
||||
return;
|
||||
}
|
||||
let text = args[3..].join(" ");
|
||||
match db.kicker_test(chan, &text) {
|
||||
Some(reason) => ctx.notice(me, from.uid, format!("That line \x02would be kicked\x02: {reason}")),
|
||||
None => ctx.notice(me, from.uid, "That line trips no content kicker (flood/repeat depend on timing and repetition, so they aren't tested here)."),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
Some("ON") | Some("TRUE") => true,
|
||||
Some("OFF") | Some("FALSE") => false,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue