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:
parent
ad8041bd9d
commit
ed7da9c713
8 changed files with 310 additions and 21 deletions
126
src/engine/db.rs
126
src/engine/db.rs
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -860,9 +860,12 @@ impl Engine {
|
|||
let mut ctx = ServiceCtx::default();
|
||||
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };
|
||||
if to.starts_with('#') || to.starts_with('&') {
|
||||
// In-channel: the only thing we act on is a fantasy command (!op …)
|
||||
// spoken in a channel that has a bot assigned.
|
||||
// In-channel: a fantasy command (!op …) if a bot is assigned, plus a
|
||||
// BotServ kicker check on every line.
|
||||
self.fantasy(&sender, to, text, &mut ctx);
|
||||
if let Some(k) = self.kicker_check(from, to, text) {
|
||||
ctx.actions.push(k);
|
||||
}
|
||||
} else {
|
||||
let Self { services, network, db, .. } = self;
|
||||
for svc in services.iter_mut() {
|
||||
|
|
@ -897,6 +900,23 @@ impl Engine {
|
|||
Some(NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("[{acc}] {greet}") })
|
||||
}
|
||||
|
||||
// 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
|
||||
// assigned bot kicks them.
|
||||
fn kicker_check(&self, from: &str, channel: &str, text: &str) -> Option<NetAction> {
|
||||
let c = self.db.channel(channel)?;
|
||||
if !c.kickers.any() {
|
||||
return None;
|
||||
}
|
||||
if c.kickers.dontkickops && self.network.is_op(channel, from) {
|
||||
return None;
|
||||
}
|
||||
let reason = c.kickers.violation(text)?;
|
||||
let bot = c.assigned_bot.clone()?;
|
||||
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() })
|
||||
}
|
||||
|
||||
// Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel
|
||||
// that has a bot assigned. We reuse ChanServ's real command handlers — the
|
||||
// fantasy word becomes the command and the channel is injected as its first
|
||||
|
|
@ -1879,6 +1899,55 @@ mod tests {
|
|||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Access denied"))), "denied notice: {out:?}");
|
||||
}
|
||||
|
||||
// The caps kicker makes the assigned bot kick a shouting message, exempts
|
||||
// operators when DONTKICKOPS is on, and leaves ordinary lines alone.
|
||||
#[test]
|
||||
fn botserv_caps_kicker_kicks() {
|
||||
use fedserv_botserv::BotServ;
|
||||
use fedserv_nickserv::NickServ;
|
||||
let path = std::env::temp_dir().join("fedserv-bskick.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);
|
||||
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||||
let bs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAD".into(), text: t.into() });
|
||||
let say = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "#c".into(), text: t.into() });
|
||||
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "boss".into(), host: "h".into() });
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAR".into(), nick: "rando".into(), host: "h".into() });
|
||||
ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||||
bs(&mut e, "000AAAAAB", "BOT ADD Bendy bot serv.host Helper");
|
||||
bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy");
|
||||
bs(&mut e, "000AAAAAB", "KICK #c CAPS ON");
|
||||
e.handle(NetEvent::Join { uid: "000AAAAAR".into(), channel: "#c".into(), op: false });
|
||||
|
||||
// Shouting is kicked, sourced from the bot.
|
||||
let out = say(&mut e, "000AAAAAR", "HELLO EVERYONE LISTEN UP");
|
||||
assert!(
|
||||
out.iter().any(|a| matches!(a, NetAction::Kick { from, channel, uid, .. } if from.starts_with("42SB") && channel == "#c" && uid == "000AAAAAR")),
|
||||
"caps kicked: {out:?}"
|
||||
);
|
||||
// A normal line is fine.
|
||||
assert!(!say(&mut e, "000AAAAAR", "hello everyone").iter().any(|a| matches!(a, NetAction::Kick { .. })), "quiet line ok");
|
||||
// With DONTKICKOPS, an operator can shout.
|
||||
bs(&mut e, "000AAAAAB", "KICK #c DONTKICKOPS ON");
|
||||
e.network.set_op("#c", "000AAAAAR", true);
|
||||
assert!(!say(&mut e, "000AAAAAR", "SHOUTING BUT I AM OP").iter().any(|a| matches!(a, NetAction::Kick { .. })), "op exempt");
|
||||
}
|
||||
|
||||
// A member's personal greet is shown by the assigned bot on join, once the
|
||||
// channel enables greets and the member has access.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
|||
| Event::ChannelAkickDel { .. }
|
||||
| Event::ChannelEntryMsgSet { .. }
|
||||
| Event::ChannelSettingsSet { .. }
|
||||
| Event::ChannelKickerSet { .. }
|
||||
| Event::ChannelTopicSet { .. }
|
||||
| Event::ChannelSuspended { .. }
|
||||
| Event::ChannelUnsuspended { .. }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue