BotServ kickers: FLOOD and REPEAT
KICK <#channel> FLOOD ON [lines [secs]] kicks a user who sends too many lines too fast (default 6 in 10s); KICK <#channel> REPEAT ON [times] kicks one who repeats the same line (default 3, case-insensitive). These are stateful, so the engine keeps per-channel/per-user counters in an ephemeral map that is never event-logged. Hot-path conscious: a channel with no kickers costs one lookup and returns; a returning speaker allocates nothing (get_mut, not entry); repeat comparison is an allocation-free case-folding 64-bit hash. Counters are dropped when a user parts or quits, so the map stays bounded. Flood time is injectable for deterministic tests.
This commit is contained in:
parent
ed7da9c713
commit
43def8ee23
4 changed files with 264 additions and 12 deletions
|
|
@ -412,6 +412,8 @@ pub enum Kicker {
|
||||||
Underlines,
|
Underlines,
|
||||||
Reverses,
|
Reverses,
|
||||||
Italics,
|
Italics,
|
||||||
|
Flood,
|
||||||
|
Repeat,
|
||||||
// Exemption, not a rule: never kick channel operators.
|
// Exemption, not a rule: never kick channel operators.
|
||||||
DontKickOps,
|
DontKickOps,
|
||||||
}
|
}
|
||||||
|
|
@ -549,6 +551,8 @@ pub trait Store {
|
||||||
fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError>;
|
fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError>;
|
||||||
fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError>;
|
fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError>;
|
||||||
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_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>;
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
|
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 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>;
|
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ 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|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params]");
|
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|FLOOD|REPEAT|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params]");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
||||||
|
|
@ -20,7 +20,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// CAPS carries optional length/percentage thresholds when turned on.
|
// CAPS/FLOOD/REPEAT carry optional thresholds when turned on.
|
||||||
if kind.eq_ignore_ascii_case("CAPS") {
|
if kind.eq_ignore_ascii_case("CAPS") {
|
||||||
if on {
|
if on {
|
||||||
let min = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
let min = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||||
|
|
@ -34,6 +34,31 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if kind.eq_ignore_ascii_case("FLOOD") {
|
||||||
|
if on {
|
||||||
|
let lines = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||||
|
let secs = args.get(5).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||||
|
match db.set_flood_kicker(chan, lines, secs) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02flooding\x02 in \x02{chan}\x02.")),
|
||||||
|
Err(_) => reg_error(me, from, chan, ctx),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggle(me, from, chan, Kicker::Flood, false, ctx, db);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if kind.eq_ignore_ascii_case("REPEAT") {
|
||||||
|
if on {
|
||||||
|
let times = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
|
||||||
|
match db.set_repeat_kicker(chan, times) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02repeating\x02 in \x02{chan}\x02.")),
|
||||||
|
Err(_) => reg_error(me, from, chan, ctx),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggle(me, from, chan, Kicker::Repeat, false, ctx, db);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let kicker = match kind.to_ascii_uppercase().as_str() {
|
let kicker = match kind.to_ascii_uppercase().as_str() {
|
||||||
"BOLDS" => Kicker::Bolds,
|
"BOLDS" => Kicker::Bolds,
|
||||||
|
|
@ -43,7 +68,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
"ITALICS" => Kicker::Italics,
|
"ITALICS" => Kicker::Italics,
|
||||||
"DONTKICKOPS" => Kicker::DontKickOps,
|
"DONTKICKOPS" => Kicker::DontKickOps,
|
||||||
other => {
|
other => {
|
||||||
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
|
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -58,6 +83,8 @@ fn label(kicker: Kicker) -> &'static str {
|
||||||
Kicker::Underlines => "underlines",
|
Kicker::Underlines => "underlines",
|
||||||
Kicker::Reverses => "reverses",
|
Kicker::Reverses => "reverses",
|
||||||
Kicker::Italics => "italics",
|
Kicker::Italics => "italics",
|
||||||
|
Kicker::Flood => "flood",
|
||||||
|
Kicker::Repeat => "repeat",
|
||||||
Kicker::DontKickOps => "dontkickops",
|
Kicker::DontKickOps => "dontkickops",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,20 @@ pub struct KickerSettings {
|
||||||
pub reverses: bool,
|
pub reverses: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub italics: bool,
|
pub italics: bool,
|
||||||
|
// Kick users who send too many lines too fast.
|
||||||
|
#[serde(default)]
|
||||||
|
pub flood: bool,
|
||||||
|
// Lines within `flood_secs` that trip the flood kicker (0 = default, 6).
|
||||||
|
#[serde(default)]
|
||||||
|
pub flood_lines: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flood_secs: u16,
|
||||||
|
// Kick users who repeat the same line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub repeat: bool,
|
||||||
|
// Consecutive repeats that trip the repeat kicker (0 = default, 3).
|
||||||
|
#[serde(default)]
|
||||||
|
pub repeat_times: u16,
|
||||||
// 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,
|
||||||
|
|
@ -309,7 +323,16 @@ pub struct KickerSettings {
|
||||||
impl KickerSettings {
|
impl KickerSettings {
|
||||||
// Any kicker enabled? (dontkickops alone doesn't count.)
|
// Any kicker enabled? (dontkickops alone doesn't count.)
|
||||||
pub fn any(&self) -> bool {
|
pub fn any(&self) -> bool {
|
||||||
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics
|
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved thresholds, applying Anope's defaults for a 0 (unset) value.
|
||||||
|
pub fn flood_thresholds(&self) -> (u16, u16) {
|
||||||
|
(if self.flood_lines < 2 { 6 } else { self.flood_lines }, if self.flood_secs == 0 { 10 } else { self.flood_secs })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repeat_threshold(&self) -> u16 {
|
||||||
|
if self.repeat_times == 0 { 3 } else { self.repeat_times }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reason to kick `text` for, or None if it trips no enabled kicker.
|
// The reason to kick `text` for, or None if it trips no enabled kicker.
|
||||||
|
|
@ -1372,6 +1395,8 @@ impl Db {
|
||||||
Kicker::Underlines => k.underlines = on,
|
Kicker::Underlines => k.underlines = on,
|
||||||
Kicker::Reverses => k.reverses = on,
|
Kicker::Reverses => k.reverses = on,
|
||||||
Kicker::Italics => k.italics = on,
|
Kicker::Italics => k.italics = on,
|
||||||
|
Kicker::Flood => k.flood = on,
|
||||||
|
Kicker::Repeat => k.repeat = on,
|
||||||
Kicker::DontKickOps => k.dontkickops = on,
|
Kicker::DontKickOps => k.dontkickops = on,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1385,6 +1410,23 @@ impl Db {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable the flood kicker with its lines/seconds thresholds (0 = default).
|
||||||
|
pub fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> {
|
||||||
|
self.update_kickers(channel, |k| {
|
||||||
|
k.flood = true;
|
||||||
|
k.flood_lines = lines;
|
||||||
|
k.flood_secs = secs;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable the repeat kicker with its repeat-count threshold (0 = default).
|
||||||
|
pub fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
|
||||||
|
self.update_kickers(channel, |k| {
|
||||||
|
k.repeat = true;
|
||||||
|
k.repeat_times = times;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Read-modify-write the whole kicker struct through one event.
|
// Read-modify-write the whole kicker struct through one event.
|
||||||
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
|
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
|
||||||
let k = key(channel);
|
let k = key(channel);
|
||||||
|
|
@ -1963,6 +2005,12 @@ impl Store for Db {
|
||||||
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> {
|
||||||
Db::set_caps_kicker(self, channel, caps_min, caps_percent)
|
Db::set_caps_kicker(self, channel, caps_min, caps_percent)
|
||||||
}
|
}
|
||||||
|
fn set_flood_kicker(&mut self, channel: &str, lines: u16, secs: u16) -> Result<(), ChanError> {
|
||||||
|
Db::set_flood_kicker(self, channel, lines, secs)
|
||||||
|
}
|
||||||
|
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError> {
|
||||||
|
Db::set_repeat_kicker(self, channel, times)
|
||||||
|
}
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
||||||
Db::set_channel_topic(self, channel, topic)
|
Db::set_channel_topic(self, channel, topic)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,21 @@ pub struct Engine {
|
||||||
bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection)
|
bot_uids: HashMap<String, String>, // casefolded bot nick -> live uid (per connection)
|
||||||
next_bot_index: u32,
|
next_bot_index: u32,
|
||||||
bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined
|
bot_channels: std::collections::HashSet<(String, String)>, // (bot nick lc, channel) the bot has joined
|
||||||
|
// Ephemeral per-channel, per-user chatter tracking for the FLOOD and REPEAT
|
||||||
|
// kickers. Only channels with one of those kickers ever get an entry; a
|
||||||
|
// user's entry is dropped when they part or quit, so this stays bounded.
|
||||||
|
// Keyed by the uplink-canonical channel string (never event-logged).
|
||||||
|
chatter: HashMap<String, HashMap<String, ChatterState>>,
|
||||||
|
now_override: Option<u64>, // test clock (unix secs); None = real wall time
|
||||||
|
}
|
||||||
|
|
||||||
|
// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ChatterState {
|
||||||
|
flood_start: u64, // window start (unix secs)
|
||||||
|
flood_lines: u16, // lines since flood_start
|
||||||
|
last_hash: u64, // case-insensitive hash of the previous line
|
||||||
|
repeats: u16, // consecutive identical lines seen so far (0 on the first)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
|
|
@ -118,9 +133,17 @@ impl Engine {
|
||||||
bot_uids: HashMap::new(),
|
bot_uids: HashMap::new(),
|
||||||
next_bot_index: 0,
|
next_bot_index: 0,
|
||||||
bot_channels: std::collections::HashSet::new(),
|
bot_channels: std::collections::HashSet::new(),
|
||||||
|
chatter: HashMap::new(),
|
||||||
|
now_override: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wall-clock seconds, overridable in tests so the time-based FLOOD kicker is
|
||||||
|
// deterministic.
|
||||||
|
fn now_secs(&self) -> u64 {
|
||||||
|
self.now_override.unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
// Wire the channel the link layer drains, so the engine can push actions to the
|
// Wire the channel the link layer drains, so the engine can push actions to the
|
||||||
// uplink that no ircd event asked for (here: a forced logout after losing a
|
// uplink that no ircd event asked for (here: a forced logout after losing a
|
||||||
// registration conflict).
|
// registration conflict).
|
||||||
|
|
@ -577,6 +600,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
NetEvent::Part { uid, channel } => {
|
NetEvent::Part { uid, channel } => {
|
||||||
self.network.channel_part(&channel, &uid);
|
self.network.channel_part(&channel, &uid);
|
||||||
|
self.forget_chatter(&channel, &uid);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
NetEvent::ChannelOp { channel, uid, op } => {
|
NetEvent::ChannelOp { channel, uid, op } => {
|
||||||
|
|
@ -617,6 +641,7 @@ impl Engine {
|
||||||
NetEvent::Quit { uid } => {
|
NetEvent::Quit { uid } => {
|
||||||
self.network.user_quit(&uid);
|
self.network.user_quit(&uid);
|
||||||
self.sasl_sessions.remove(&uid); // drop any half-finished exchange
|
self.sasl_sessions.remove(&uid); // drop any half-finished exchange
|
||||||
|
self.forget_chatter_everywhere(&uid);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text),
|
NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text),
|
||||||
|
|
@ -902,19 +927,87 @@ 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.
|
// assigned bot kicks them. `&mut self` because the FLOOD/REPEAT kickers keep
|
||||||
fn kicker_check(&self, from: &str, channel: &str, text: &str) -> Option<NetAction> {
|
// per-user counters. Hot path: one HashMap lookup for a channel with no
|
||||||
|
// kickers, and no heap allocation for a returning speaker.
|
||||||
|
fn kicker_check(&mut self, from: &str, channel: &str, text: &str) -> Option<NetAction> {
|
||||||
|
// `c` borrows self.db; self.network and self.chatter are disjoint fields,
|
||||||
|
// so they can be touched while it is alive.
|
||||||
let c = self.db.channel(channel)?;
|
let c = self.db.channel(channel)?;
|
||||||
if !c.kickers.any() {
|
let k = &c.kickers;
|
||||||
|
if !k.any() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if c.kickers.dontkickops && self.network.is_op(channel, from) {
|
if k.dontkickops && self.network.is_op(channel, from) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let reason = c.kickers.violation(text)?;
|
let botuid = self.network.uid_by_nick(c.assigned_bot.as_deref()?)?.to_string();
|
||||||
let bot = c.assigned_bot.clone()?;
|
let kick = |reason: &str| Some(NetAction::Kick { from: botuid.clone(), channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() });
|
||||||
let botuid = self.network.uid_by_nick(&bot)?.to_string();
|
|
||||||
Some(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: from.to_string(), reason: reason.to_string() })
|
// Stateless kickers first (cheapest, and they don't touch counters).
|
||||||
|
if let Some(reason) = k.violation(text) {
|
||||||
|
return kick(reason);
|
||||||
|
}
|
||||||
|
if !k.flood && !k.repeat {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (flood_lines, flood_secs) = k.flood_thresholds();
|
||||||
|
let (flood, repeat, repeat_times) = (k.flood, k.repeat, k.repeat_threshold());
|
||||||
|
let now = self.now_secs();
|
||||||
|
|
||||||
|
// Get (or lazily create) this speaker's counters. get_mut avoids any
|
||||||
|
// allocation for a channel/user already being tracked.
|
||||||
|
if !self.chatter.contains_key(channel) {
|
||||||
|
self.chatter.insert(channel.to_string(), HashMap::new());
|
||||||
|
}
|
||||||
|
let bucket = self.chatter.get_mut(channel).unwrap();
|
||||||
|
if !bucket.contains_key(from) {
|
||||||
|
bucket.insert(from.to_string(), ChatterState::default());
|
||||||
|
}
|
||||||
|
let state = 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
|
||||||
|
// bucket once empty, so kicker state never outlives the people it tracks.
|
||||||
|
fn forget_chatter(&mut self, channel: &str, uid: &str) {
|
||||||
|
if let Some(bucket) = self.chatter.get_mut(channel) {
|
||||||
|
bucket.remove(uid);
|
||||||
|
if bucket.is_empty() {
|
||||||
|
self.chatter.remove(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop a user's chatter counters across every channel (on quit).
|
||||||
|
fn forget_chatter_everywhere(&mut self, uid: &str) {
|
||||||
|
self.chatter.retain(|_, bucket| {
|
||||||
|
bucket.remove(uid);
|
||||||
|
!bucket.is_empty()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel
|
// Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel
|
||||||
|
|
@ -974,6 +1067,18 @@ fn resource_action(a: &mut NetAction, old: &str, new: &str) {
|
||||||
|
|
||||||
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate
|
// Decode a SASL PLAIN payload (authzid \0 authcid \0 passwd) and authenticate
|
||||||
// it, returning the canonical account name on success.
|
// it, returning the canonical account name on success.
|
||||||
|
// A case-insensitive 64-bit hash of a line, for the REPEAT kicker. Allocation-
|
||||||
|
// free (folds each byte to lowercase in place) so it stays cheap on the hot
|
||||||
|
// path; a 64-bit collision causing a spurious kick is astronomically unlikely.
|
||||||
|
fn ci_hash(text: &str) -> u64 {
|
||||||
|
use std::hash::Hasher;
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
for b in text.bytes() {
|
||||||
|
h.write_u8(b.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
fn login_plain(b64: &str, db: &Db) -> Option<String> {
|
||||||
let raw = STANDARD.decode(b64).ok()?;
|
let raw = STANDARD.decode(b64).ok()?;
|
||||||
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
let parts: Vec<&[u8]> = raw.split(|&b| b == 0).collect();
|
||||||
|
|
@ -1948,6 +2053,74 @@ mod tests {
|
||||||
assert!(!say(&mut e, "000AAAAAR", "SHOUTING BUT I AM OP").iter().any(|a| matches!(a, NetAction::Kick { .. })), "op exempt");
|
assert!(!say(&mut e, "000AAAAAR", "SHOUTING BUT I AM OP").iter().any(|a| matches!(a, NetAction::Kick { .. })), "op exempt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The repeat kicker counts consecutive identical lines (case-insensitively)
|
||||||
|
// and kicks once the threshold is reached; a different line resets it.
|
||||||
|
#[test]
|
||||||
|
fn botserv_repeat_kicker_kicks() {
|
||||||
|
let (mut e, _p) = kicker_fixture("bsrepeat");
|
||||||
|
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 REPEAT ON 2"); // kick on the third identical line
|
||||||
|
|
||||||
|
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAS"));
|
||||||
|
assert!(!kicked(&say(&mut e, "spam")), "1st ok");
|
||||||
|
assert!(!kicked(&say(&mut e, "SPAM")), "2nd (case-fold) ok");
|
||||||
|
assert!(kicked(&say(&mut e, "Spam")), "3rd identical kicks");
|
||||||
|
// A different line clears the streak.
|
||||||
|
assert!(!kicked(&say(&mut e, "something else entirely")), "reset on new line");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The flood kicker counts lines within a window; the window resets after it
|
||||||
|
// elapses. Uses the injectable clock so it is deterministic.
|
||||||
|
#[test]
|
||||||
|
fn botserv_flood_kicker_kicks() {
|
||||||
|
let (mut e, _p) = kicker_fixture("bsflood");
|
||||||
|
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 FLOOD ON 3 10"); // 3 lines in 10 seconds
|
||||||
|
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||||||
|
|
||||||
|
e.now_override = Some(1000);
|
||||||
|
assert!(!kicked(&say(&mut e, "a")), "line 1");
|
||||||
|
assert!(!kicked(&say(&mut e, "b")), "line 2");
|
||||||
|
// The window elapses before the 3rd line, so it resets instead of kicking.
|
||||||
|
e.now_override = Some(1011);
|
||||||
|
assert!(!kicked(&say(&mut e, "c")), "window reset");
|
||||||
|
// Three lines inside the window now trip it.
|
||||||
|
assert!(!kicked(&say(&mut e, "d")), "line 2 of new window");
|
||||||
|
assert!(kicked(&say(&mut e, "e")), "3rd in-window line kicks");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registers #c with founder boss (admin), a live assigned bot Bendy, and
|
||||||
|
// returns the engine ready for KICK configuration + a spammer uid 000AAAAAS.
|
||||||
|
fn kicker_fixture(tag: &str) -> (Engine, std::path::PathBuf) {
|
||||||
|
use fedserv_botserv::BotServ;
|
||||||
|
use fedserv_nickserv::NickServ;
|
||||||
|
let path = std::env::temp_dir().join(format!("fedserv-{tag}.jsonl"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.register("boss", "password1", None).unwrap();
|
||||||
|
db.register_channel("#c", "boss").unwrap();
|
||||||
|
let mut e = Engine::new(
|
||||||
|
vec![
|
||||||
|
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||||
|
Box::new(BotServ { uid: "42SAAAAAD".into() }),
|
||||||
|
],
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
e.set_sid("42S".into());
|
||||||
|
let mut opers = std::collections::HashMap::new();
|
||||||
|
opers.insert("boss".to_string(), Privs::default().with(fedserv_api::Priv::Admin));
|
||||||
|
e.set_opers(opers);
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAS".into(), nick: "spammer".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #c Bendy".into() });
|
||||||
|
(e, path)
|
||||||
|
}
|
||||||
|
|
||||||
// A member's personal greet is shown by the assigned bot on join, once the
|
// A member's personal greet is shown by the assigned bot on join, once the
|
||||||
// channel enables greets and the member has access.
|
// channel enables greets and the member has access.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue