BotServ kickers: caps + formatting, with DONTKICKOPS

The assigned bot now enforces kickers on channel lines: KICK <#channel>
CAPS ON [min [percent]], and BOLDS/COLORS/UNDERLINES/REVERSES/ITALICS
for control-code formatting. DONTKICKOPS exempts channel operators. A
tripping line is kicked by the bot itself.

Kicker config is a typed KickerSettings struct on the channel (event-
sourced like ChanSettings) with a violation() check; the engine runs it
on every channel PRIVMSG. Factored the founder-or-admin gate into a
shared require_channel_admin helper (assign/set/kick).
This commit is contained in:
Jean Chevronnet 2026-07-13 14:54:14 +00:00
parent ad8041bd9d
commit ed7da9c713
No known key found for this signature in database
8 changed files with 310 additions and 21 deletions

View file

@ -15,7 +15,7 @@ use super::scram::{self, Hash};
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{
AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, RegError, Store,
AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -98,6 +98,7 @@ pub enum Event {
ChannelDescSet { channel: String, desc: String },
ChannelEntryMsgSet { channel: String, msg: String },
ChannelSettingsSet { channel: String, settings: ChanSettings },
ChannelKickerSet { channel: String, kickers: KickerSettings },
ChannelTopicSet { channel: String, topic: String },
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
ChannelUnsuspended { channel: String },
@ -148,6 +149,7 @@ impl Event {
| Event::ChannelDescSet { .. }
| Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. }
| Event::ChannelKickerSet { .. }
| Event::ChannelTopicSet { .. }
| Event::ChannelSuspended { .. }
| Event::ChannelUnsuspended { .. }
@ -272,6 +274,74 @@ pub struct ChannelInfo {
// BotServ bot assigned to sit in this channel, if any (its nick).
#[serde(default)]
pub assigned_bot: Option<String>,
// BotServ kicker configuration (the bot kicks on caps/formatting/etc.).
#[serde(default)]
pub kickers: KickerSettings,
}
// BotServ's per-channel "kickers": the assigned bot kicks a message that trips
// an enabled rule. Typed, like ChanSettings — a new kicker is one field.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KickerSettings {
#[serde(default)]
pub caps: bool,
// Only messages at least this long are caps-checked (0 = the default, 10).
#[serde(default)]
pub caps_min: u16,
// Percentage of letters that must be uppercase to trip (0 = default, 25).
#[serde(default)]
pub caps_percent: u16,
#[serde(default)]
pub bolds: bool,
#[serde(default)]
pub colors: bool,
#[serde(default)]
pub underlines: bool,
#[serde(default)]
pub reverses: bool,
#[serde(default)]
pub italics: bool,
// Don't kick channel operators, whatever they send.
#[serde(default)]
pub dontkickops: bool,
}
impl KickerSettings {
// Any kicker enabled? (dontkickops alone doesn't count.)
pub fn any(&self) -> bool {
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics
}
// The reason to kick `text` for, or None if it trips no enabled kicker.
pub fn violation(&self, text: &str) -> Option<&'static str> {
if self.bolds && text.contains('\x02') {
return Some("Don't use bold formatting on this channel.");
}
if self.colors && text.contains('\x03') {
return Some("Don't use colour codes on this channel.");
}
if self.underlines && text.contains('\x1f') {
return Some("Don't use underline formatting on this channel.");
}
if self.reverses && text.contains('\x16') {
return Some("Don't use reverse formatting on this channel.");
}
if self.italics && text.contains('\x1d') {
return Some("Don't use italic formatting on this channel.");
}
if self.caps {
let min = if self.caps_min == 0 { 10 } else { self.caps_min } as usize;
let percent = if self.caps_percent == 0 { 25 } else { self.caps_percent } as u32;
if text.chars().count() >= min {
let upper = text.chars().filter(|c| c.is_ascii_uppercase()).count() as u32;
let lower = text.chars().filter(|c| c.is_ascii_lowercase()).count() as u32;
if upper as usize >= min && upper + lower > 0 && upper * 100 / (upper + lower) >= percent {
return Some("Turn caps lock off!");
}
}
}
None
}
}
impl ChannelInfo {
@ -690,6 +760,9 @@ impl Db {
if let Some(bot) = &c.assigned_bot {
snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() });
}
if c.kickers.any() || c.kickers.dontkickops {
snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() });
}
}
for (nick, account) in &self.grouped {
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
@ -1132,7 +1205,7 @@ impl Db {
self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?;
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None });
self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() });
Ok(())
}
@ -1289,6 +1362,42 @@ impl Db {
Ok(())
}
/// Turn one BotServ kicker on or off. Caps thresholds are set via
/// `set_caps_kicker`; passing `Kicker::Caps` here only toggles it off.
pub fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError> {
self.update_kickers(channel, |k| match kicker {
Kicker::Caps => k.caps = on,
Kicker::Bolds => k.bolds = on,
Kicker::Colors => k.colors = on,
Kicker::Underlines => k.underlines = on,
Kicker::Reverses => k.reverses = on,
Kicker::Italics => k.italics = on,
Kicker::DontKickOps => k.dontkickops = on,
})
}
/// Enable the caps kicker with its length/percentage thresholds (0 = default).
pub fn set_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: u16) -> Result<(), ChanError> {
self.update_kickers(channel, |k| {
k.caps = true;
k.caps_min = caps_min;
k.caps_percent = caps_percent;
})
}
// Read-modify-write the whole kicker struct through one event.
fn update_kickers(&mut self, channel: &str, f: impl FnOnce(&mut KickerSettings)) -> Result<(), ChanError> {
let k = key(channel);
let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) };
let mut kickers = c.kickers.clone();
f(&mut kickers);
self.log
.append(Event::ChannelKickerSet { channel: channel.to_string(), kickers: kickers.clone() })
.map_err(|_| ChanError::Internal)?;
self.channels.get_mut(&k).unwrap().kickers = kickers;
Ok(())
}
/// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
let k = key(channel);
@ -1574,7 +1683,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
grouped.remove(&key(&nick));
}
Event::ChannelRegistered { name, founder, ts } => {
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None });
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() });
}
Event::ChannelDropped { name } => {
channels.remove(&key(&name));
@ -1622,6 +1731,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.settings = settings;
}
}
Event::ChannelKickerSet { channel, kickers } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.kickers = kickers;
}
}
Event::ChannelTopicSet { channel, topic } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.topic = topic;
@ -1843,6 +1957,12 @@ impl Store for Db {
fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError> {
Db::set_channel_setting(self, channel, setting, on)
}
fn set_kicker(&mut self, channel: &str, kicker: Kicker, on: bool) -> Result<(), ChanError> {
Db::set_kicker(self, channel, kicker, on)
}
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)
}
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
Db::set_channel_topic(self, channel, topic)
}