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
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
77
botserv/src/kick.rs
Normal 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."));
|
||||||
|
}
|
||||||
|
|
@ -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.")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
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
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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 { .. }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue