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

@ -403,6 +403,19 @@ pub enum ChanSetting {
BotGreet, BotGreet,
} }
// A BotServ kicker: the assigned bot kicks a message that trips an enabled one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kicker {
Caps,
Bolds,
Colors,
Underlines,
Reverses,
Italics,
// Exemption, not a rule: never kick channel operators.
DontKickOps,
}
impl ChannelView { impl ChannelView {
// The channel mode this account is entitled to on join (+o founder/op, +v // The channel mode this account is entitled to on join (+o founder/op, +v
// voice), or None if it holds no access. // voice), or None if it holds no access.
@ -534,6 +547,8 @@ pub trait Store {
fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>; fn set_mlock(&mut self, name: &str, on: &str, off: &str) -> Result<(), ChanError>;
fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>; fn set_desc(&mut self, channel: &str, desc: &str) -> Result<(), ChanError>;
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_caps_kicker(&mut self, channel: &str, caps_min: u16, caps_percent: 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>;

View file

@ -1,4 +1,4 @@
use fedserv_api::{Priv, Sender, ServiceCtx, Store}; use fedserv_api::{Sender, ServiceCtx, Store};
// ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take // ASSIGN <#channel> <bot> / UNASSIGN <#channel>: put a bot in a channel (or take
// it out). Channel founder only (or a services admin). // it out). Channel founder only (or a services admin).
@ -8,12 +8,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, syntax); ctx.notice(me, from.uid, syntax);
return; return;
}; };
let Some(founder) = db.channel(chan).map(|c| c.founder) else { if !super::require_channel_admin(me, from, chan, ctx, db) {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can assign a bot to it."));
return; return;
} }
if !assigning { if !assigning {

77
botserv/src/kick.rs Normal file
View file

@ -0,0 +1,77 @@
use fedserv_api::{Kicker, Sender, ServiceCtx, Store};
// KICK <#channel> <type> {ON|OFF} [params]: configure the bot's kickers.
// Types: CAPS [min [percent]], BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS,
// and the exemption DONTKICKOPS. Founder-or-admin.
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 {
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params]");
return;
};
if !super::require_channel_admin(me, from, chan, ctx, db) {
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,
_ => {
ctx.notice(me, from.uid, "Say \x02ON\x02 or \x02OFF\x02, e.g. KICK #channel CAPS ON.");
return;
}
};
// CAPS carries optional length/percentage thresholds when turned on.
if kind.eq_ignore_ascii_case("CAPS") {
if on {
let min = args.get(4).and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
let percent = args.get(5).and_then(|s| s.parse::<u16>().ok()).filter(|p| (1..=100).contains(p)).unwrap_or(0);
match db.set_caps_kicker(chan, min, percent) {
Ok(()) => ctx.notice(me, from.uid, format!("The bot will now kick for \x02caps\x02 in \x02{chan}\x02.")),
Err(_) => reg_error(me, from, chan, ctx),
}
} else {
toggle(me, from, chan, Kicker::Caps, false, ctx, db);
}
return;
}
let kicker = match kind.to_ascii_uppercase().as_str() {
"BOLDS" => Kicker::Bolds,
"COLORS" | "COLOURS" => Kicker::Colors,
"UNDERLINES" => Kicker::Underlines,
"REVERSES" => Kicker::Reverses,
"ITALICS" => Kicker::Italics,
"DONTKICKOPS" => Kicker::DontKickOps,
other => {
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
return;
}
};
toggle(me, from, chan, kicker, on, ctx, db);
}
fn label(kicker: Kicker) -> &'static str {
match kicker {
Kicker::Caps => "caps",
Kicker::Bolds => "bolds",
Kicker::Colors => "colours",
Kicker::Underlines => "underlines",
Kicker::Reverses => "reverses",
Kicker::Italics => "italics",
Kicker::DontKickOps => "dontkickops",
}
}
fn toggle(me: &str, from: &Sender, chan: &str, kicker: Kicker, on: bool, ctx: &mut ServiceCtx, db: &mut dyn Store) {
match db.set_kicker(chan, kicker, on) {
Ok(()) if kicker == Kicker::DontKickOps && on => ctx.notice(me, from.uid, format!("The bot will no longer kick channel operators in \x02{chan}\x02.")),
Ok(()) if kicker == Kicker::DontKickOps => ctx.notice(me, from.uid, format!("The bot may again kick channel operators in \x02{chan}\x02.")),
Ok(()) if on => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now on in \x02{chan}\x02.", label(kicker))),
Ok(()) => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now off in \x02{chan}\x02.", label(kicker))),
Err(_) => reg_error(me, from, chan, ctx),
}
}
fn reg_error(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx) {
ctx.notice(me, from.uid, format!("Couldn't update \x02{chan}\x02 — please try again in a moment."));
}

View file

@ -2,7 +2,7 @@
//! and (in later slices) run fantasy commands. `lib.rs` holds the dispatcher; //! and (in later slices) run fantasy commands. `lib.rs` holds the dispatcher;
//! each command lives in its own file, matching NickServ/ChanServ. //! each command lives in its own file, matching NickServ/ChanServ.
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store}; use fedserv_api::{NetView, Priv, Sender, Service, ServiceCtx, Store};
#[path = "bot.rs"] #[path = "bot.rs"]
mod bot; mod bot;
@ -14,6 +14,22 @@ mod info;
mod say; mod say;
#[path = "set.rs"] #[path = "set.rs"]
mod set; mod set;
#[path = "kick.rs"]
mod kick;
// Shared gate: the sender may administer <chan>'s bot options only as its
// founder or a services admin. Notices and returns false on failure.
fn require_channel_admin(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &dyn Store) -> bool {
let Some(founder) = db.channel(chan).map(|c| c.founder) else {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return false;
};
if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change that."));
return false;
}
true
}
pub struct BotServ { pub struct BotServ {
pub uid: String, pub uid: String,
@ -40,7 +56,8 @@ impl Service for BotServ {
Some("SAY") => say::handle(me, from, args, ctx, net, db, false), Some("SAY") => say::handle(me, from, args, ctx, net, db, false),
Some("ACT") => say::handle(me, from, args, ctx, net, db, true), Some("ACT") => say::handle(me, from, args, ctx, net, db, true),
Some("SET") => set::handle(me, from, args, ctx, db), Some("SET") => set::handle(me, from, args, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets. Operators also have \x02BOT\x02 ADD|DEL|LIST."), Some("KICK") => kick::handle(me, from, args, ctx, db),
Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> configures kickers. Operators also have \x02BOT\x02 ADD|DEL|LIST."),
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
} }
} }

View file

@ -1,4 +1,4 @@
use fedserv_api::{ChanSetting, Priv, Sender, ServiceCtx, Store}; use fedserv_api::{ChanSetting, Sender, ServiceCtx, Store};
// SET <#channel> <option> <on|off>: per-channel bot options. Founder-or-admin. // SET <#channel> <option> <on|off>: per-channel bot options. Founder-or-admin.
// Currently: GREET (show members' personal greets when they join). // Currently: GREET (show members' personal greets when they join).
@ -7,12 +7,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "Syntax: SET <#channel> GREET <ON|OFF>"); ctx.notice(me, from.uid, "Syntax: SET <#channel> GREET <ON|OFF>");
return; return;
}; };
let Some(founder) = db.channel(chan).map(|c| c.founder) else { if !super::require_channel_admin(me, from, chan, ctx, db) {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered."));
return;
};
if from.account != Some(founder.as_str()) && !from.privs.has(Priv::Admin) {
ctx.notice(me, from.uid, format!("Only \x02{chan}\x02's founder can change its bot options."));
return; return;
} }
let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() { let on = match args.get(3).map(|s| s.to_ascii_uppercase()).as_deref() {

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 // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected. // modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
pub use fedserv_api::{ 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -98,6 +98,7 @@ pub enum Event {
ChannelDescSet { channel: String, desc: String }, ChannelDescSet { channel: String, desc: String },
ChannelEntryMsgSet { channel: String, msg: String }, ChannelEntryMsgSet { channel: String, msg: String },
ChannelSettingsSet { channel: String, settings: ChanSettings }, ChannelSettingsSet { channel: String, settings: ChanSettings },
ChannelKickerSet { channel: String, kickers: KickerSettings },
ChannelTopicSet { channel: String, topic: String }, ChannelTopicSet { channel: String, topic: String },
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> }, ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
ChannelUnsuspended { channel: String }, ChannelUnsuspended { channel: String },
@ -148,6 +149,7 @@ impl Event {
| Event::ChannelDescSet { .. } | Event::ChannelDescSet { .. }
| Event::ChannelEntryMsgSet { .. } | Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. } | Event::ChannelSettingsSet { .. }
| Event::ChannelKickerSet { .. }
| Event::ChannelTopicSet { .. } | Event::ChannelTopicSet { .. }
| Event::ChannelSuspended { .. } | Event::ChannelSuspended { .. }
| Event::ChannelUnsuspended { .. } | Event::ChannelUnsuspended { .. }
@ -272,6 +274,74 @@ pub struct ChannelInfo {
// BotServ bot assigned to sit in this channel, if any (its nick). // BotServ bot assigned to sit in this channel, if any (its nick).
#[serde(default)] #[serde(default)]
pub assigned_bot: Option<String>, 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 { impl ChannelInfo {
@ -690,6 +760,9 @@ impl Db {
if let Some(bot) = &c.assigned_bot { if let Some(bot) = &c.assigned_bot {
snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() }); 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 { for (nick, account) in &self.grouped {
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
@ -1132,7 +1205,7 @@ impl Db {
self.log self.log
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
.map_err(|_| ChanError::Internal)?; .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(()) Ok(())
} }
@ -1289,6 +1362,42 @@ impl Db {
Ok(()) 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). /// Remember a channel's topic (KEEPTOPIC / TOPICLOCK).
pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { pub fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
let k = key(channel); let k = key(channel);
@ -1574,7 +1683,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
grouped.remove(&key(&nick)); grouped.remove(&key(&nick));
} }
Event::ChannelRegistered { name, founder, ts } => { 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 } => { Event::ChannelDropped { name } => {
channels.remove(&key(&name)); channels.remove(&key(&name));
@ -1622,6 +1731,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
c.settings = settings; c.settings = settings;
} }
} }
Event::ChannelKickerSet { channel, kickers } => {
if let Some(c) = channels.get_mut(&key(&channel)) {
c.kickers = kickers;
}
}
Event::ChannelTopicSet { channel, topic } => { Event::ChannelTopicSet { channel, topic } => {
if let Some(c) = channels.get_mut(&key(&channel)) { if let Some(c) = channels.get_mut(&key(&channel)) {
c.topic = topic; 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> { fn set_channel_setting(&mut self, channel: &str, setting: ChanSetting, on: bool) -> Result<(), ChanError> {
Db::set_channel_setting(self, channel, setting, on) 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> { 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)
} }

View file

@ -860,9 +860,12 @@ impl Engine {
let mut ctx = ServiceCtx::default(); let mut ctx = ServiceCtx::default();
let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs }; let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs };
if to.starts_with('#') || to.starts_with('&') { if to.starts_with('#') || to.starts_with('&') {
// In-channel: the only thing we act on is a fantasy command (!op …) // In-channel: a fantasy command (!op …) if a bot is assigned, plus a
// spoken in a channel that has a bot assigned. // BotServ kicker check on every line.
self.fantasy(&sender, to, text, &mut ctx); self.fantasy(&sender, to, text, &mut ctx);
if let Some(k) = self.kicker_check(from, to, text) {
ctx.actions.push(k);
}
} else { } else {
let Self { services, network, db, .. } = self; let Self { services, network, db, .. } = self;
for svc in services.iter_mut() { 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}") }) 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 // Fantasy commands: `!op nick`, `!kick nick`, `!topic …` spoken in a channel
// that has a bot assigned. We reuse ChanServ's real command handlers — the // 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 // 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:?}"); 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 // 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]

View file

@ -137,6 +137,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| Event::ChannelAkickDel { .. } | Event::ChannelAkickDel { .. }
| Event::ChannelEntryMsgSet { .. } | Event::ChannelEntryMsgSet { .. }
| Event::ChannelSettingsSet { .. } | Event::ChannelSettingsSet { .. }
| Event::ChannelKickerSet { .. }
| Event::ChannelTopicSet { .. } | Event::ChannelTopicSet { .. }
| Event::ChannelSuspended { .. } | Event::ChannelSuspended { .. }
| Event::ChannelUnsuspended { .. } | Event::ChannelUnsuspended { .. }