BotServ: DONTKICKVOICES, plus neutral code comments
KICK <#channel> DONTKICKVOICES <on|off> exempts voiced users from the kickers. The network view now tracks +v the same way it tracks +o: a new ChannelVoice event is parsed from FMODE +v/-v and from +v join prefixes, and set_voice/is_voiced mirror set_op/is_op (voice is cleared on part and quit). scan_ops generalised to scan_status for any status letter. Also scrubbed third-party project name-drops from code comments.
This commit is contained in:
parent
929a4cdd92
commit
5d1bfb3144
9 changed files with 99 additions and 23 deletions
|
|
@ -28,6 +28,8 @@ pub enum NetEvent {
|
||||||
Part { uid: String, channel: String },
|
Part { uid: String, channel: String },
|
||||||
// A user's channel-operator status changed (FMODE +o/-o), for live op tracking.
|
// A user's channel-operator status changed (FMODE +o/-o), for live op tracking.
|
||||||
ChannelOp { channel: String, uid: String, op: bool },
|
ChannelOp { channel: String, uid: String, op: bool },
|
||||||
|
// A user's voice status changed (FMODE +v/-v, or a +v join prefix).
|
||||||
|
ChannelVoice { channel: String, uid: String, voice: bool },
|
||||||
// A channel's modes changed (FMODE), for enforcing mode locks. Our own
|
// A channel's modes changed (FMODE), for enforcing mode locks. Our own
|
||||||
// changes are filtered out by the protocol layer.
|
// changes are filtered out by the protocol layer.
|
||||||
ChannelModeChange { channel: String, modes: String },
|
ChannelModeChange { channel: String, modes: String },
|
||||||
|
|
@ -120,9 +122,8 @@ pub trait Protocol: Send {
|
||||||
// Service vocabulary
|
// Service vocabulary
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// A services-operator privilege. Coarse and typed (not a string namespace like
|
// A services-operator privilege. Coarse and typed (not a string namespace), so
|
||||||
// Anope's "nickserv/suspend"), so the compiler checks every use and there is one
|
// the compiler checks every use and there is one gating mechanism, not two.
|
||||||
// gating mechanism, not two.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Priv {
|
pub enum Priv {
|
||||||
// See other users' hidden info (INFO fields, LIST filters).
|
// See other users' hidden info (INFO fields, LIST filters).
|
||||||
|
|
@ -435,6 +436,8 @@ pub enum Kicker {
|
||||||
Warn,
|
Warn,
|
||||||
// Exemption, not a rule: never kick channel operators.
|
// Exemption, not a rule: never kick channel operators.
|
||||||
DontKickOps,
|
DontKickOps,
|
||||||
|
// Exemption: never kick voiced users.
|
||||||
|
DontKickVoices,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelView {
|
impl ChannelView {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ use fedserv_api::{ChanError, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
// BADWORDS <#channel> ADD <regex> | DEL <regex> | LIST | CLEAR: manage the
|
// BADWORDS <#channel> ADD <regex> | DEL <regex> | LIST | CLEAR: manage the
|
||||||
// channel's badword patterns. Each entry is a regular expression, so a channel
|
// channel's badword patterns. Each entry is a regular expression, so a channel
|
||||||
// can match whatever it likes (improving on Anope's fixed ANY/SINGLE/START/END).
|
// can match whatever it likes. Enable the kicker itself with
|
||||||
// Enable the kicker itself with KICK <#channel> BADWORDS ON. Founder-or-admin.
|
// KICK <#channel> BADWORDS ON. 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) = args.get(1) else {
|
let Some(&chan) = args.get(1) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]");
|
ctx.notice(me, from.uid, "Syntax: BADWORDS <#channel> ADD|DEL|LIST|CLEAR [pattern]");
|
||||||
|
|
|
||||||
|
|
@ -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|FLOOD|REPEAT|BADWORDS|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|DONTKICKOPS> <ON|OFF> [params], or KICK <#channel> TTB <n>");
|
ctx.notice(me, from.uid, "Syntax: KICK <#channel> <CAPS|FLOOD|REPEAT|BADWORDS|BOLDS|COLORS|UNDERLINES|REVERSES|ITALICS|WARN|DONTKICKOPS|DONTKICKVOICES> <ON|OFF> [params], or KICK <#channel> TTB <n> / TEST <message>");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
if !super::require_channel_admin(me, from, chan, ctx, db) {
|
||||||
|
|
@ -98,8 +98,9 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
"BADWORDS" => Kicker::Badwords,
|
"BADWORDS" => Kicker::Badwords,
|
||||||
"WARN" => Kicker::Warn,
|
"WARN" => Kicker::Warn,
|
||||||
"DONTKICKOPS" => Kicker::DontKickOps,
|
"DONTKICKOPS" => Kicker::DontKickOps,
|
||||||
|
"DONTKICKVOICES" => Kicker::DontKickVoices,
|
||||||
other => {
|
other => {
|
||||||
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS or DONTKICKOPS."));
|
ctx.notice(me, from.uid, format!("Unknown kicker \x02{other}\x02. Try CAPS, FLOOD, REPEAT, BADWORDS, BOLDS, COLORS, UNDERLINES, REVERSES, ITALICS, WARN, DONTKICKOPS or DONTKICKVOICES."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -119,6 +120,7 @@ fn label(kicker: Kicker) -> &'static str {
|
||||||
Kicker::Badwords => "badwords",
|
Kicker::Badwords => "badwords",
|
||||||
Kicker::Warn => "warn",
|
Kicker::Warn => "warn",
|
||||||
Kicker::DontKickOps => "dontkickops",
|
Kicker::DontKickOps => "dontkickops",
|
||||||
|
Kicker::DontKickVoices => "dontkickvoices",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +128,8 @@ fn toggle(me: &str, from: &Sender, chan: &str, kicker: Kicker, on: bool, ctx: &m
|
||||||
match db.set_kicker(chan, kicker, on) {
|
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 && 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 kicker == Kicker::DontKickOps => ctx.notice(me, from.uid, format!("The bot may again kick channel operators in \x02{chan}\x02.")),
|
||||||
|
Ok(()) if kicker == Kicker::DontKickVoices && on => ctx.notice(me, from.uid, format!("The bot will no longer kick voiced users in \x02{chan}\x02.")),
|
||||||
|
Ok(()) if kicker == Kicker::DontKickVoices => ctx.notice(me, from.uid, format!("The bot may again kick voiced users in \x02{chan}\x02.")),
|
||||||
Ok(()) if kicker == Kicker::Warn && on => ctx.notice(me, from.uid, format!("The bot will warn once before the first kick in \x02{chan}\x02.")),
|
Ok(()) if kicker == Kicker::Warn && on => ctx.notice(me, from.uid, format!("The bot will warn once before the first kick in \x02{chan}\x02.")),
|
||||||
Ok(()) if kicker == Kicker::Warn => ctx.notice(me, from.uid, format!("The bot will kick without warning in \x02{chan}\x02.")),
|
Ok(()) if kicker == Kicker::Warn => ctx.notice(me, from.uid, format!("The bot will kick without warning 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(()) if on => ctx.notice(me, from.uid, format!("The \x02{}\x02 kicker is now on in \x02{chan}\x02.", label(kicker))),
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ impl Protocol for InspIrcd {
|
||||||
let uid = member.split(':').next().unwrap_or("");
|
let uid = member.split(':').next().unwrap_or("");
|
||||||
if !uid.is_empty() {
|
if !uid.is_empty() {
|
||||||
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: prefix.contains('o') });
|
out.push(NetEvent::Join { uid: uid.to_string(), channel: chan.to_string(), op: prefix.contains('o') });
|
||||||
|
if prefix.contains('v') {
|
||||||
|
out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid: uid.to_string(), voice: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -157,9 +160,12 @@ impl Protocol for InspIrcd {
|
||||||
if let Some(key) = scan_key(modes, params) {
|
if let Some(key) = scan_key(modes, params) {
|
||||||
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
|
out.push(NetEvent::ChannelKey { channel: chan.to_string(), key });
|
||||||
}
|
}
|
||||||
for (op, uid) in scan_ops(modes, params) {
|
for (op, uid) in scan_status(modes, params, 'o') {
|
||||||
out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op });
|
out.push(NetEvent::ChannelOp { channel: chan.to_string(), uid, op });
|
||||||
}
|
}
|
||||||
|
for (voice, uid) in scan_status(modes, params, 'v') {
|
||||||
|
out.push(NetEvent::ChannelVoice { channel: chan.to_string(), uid, voice });
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
|
|
@ -345,9 +351,10 @@ fn scan_key(modes: &str, params: &[&str]) -> Option<Option<String>> {
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Walk a mode change and its params for op grants/revokes (+o/-o). Returns
|
// Walk a mode change and its params for grants/revokes of one status mode
|
||||||
// (is_op, uid) per `o` in the change, using the same param cursor as scan_key.
|
// (`want`, e.g. 'o' or 'v'). Returns (is_set, uid) per match, using the same
|
||||||
fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> {
|
// param cursor as scan_key.
|
||||||
|
fn scan_status(modes: &str, params: &[&str], want: char) -> Vec<(bool, String)> {
|
||||||
let mut adding = true;
|
let mut adding = true;
|
||||||
let mut pi = 0;
|
let mut pi = 0;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
@ -355,7 +362,7 @@ fn scan_ops(modes: &str, params: &[&str]) -> Vec<(bool, String)> {
|
||||||
match m {
|
match m {
|
||||||
'+' => adding = true,
|
'+' => adding = true,
|
||||||
'-' => adding = false,
|
'-' => adding = false,
|
||||||
'o' => {
|
c if c == want => {
|
||||||
if let Some(p) = params.get(pi) {
|
if let Some(p) = params.get(pi) {
|
||||||
out.push((adding, p.to_string()));
|
out.push((adding, p.to_string()));
|
||||||
}
|
}
|
||||||
|
|
@ -426,6 +433,21 @@ mod tests {
|
||||||
assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}");
|
assert!(!ev.iter().any(|e| matches!(e, NetEvent::ChannelModeChange { .. })), "own change filtered: {ev:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FMODE and an FJOIN prefix both surface voice status changes.
|
||||||
|
#[test]
|
||||||
|
fn parses_voice_status() {
|
||||||
|
// +o then +v with two params: op for the first uid, voice for the second.
|
||||||
|
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 +ov 0IRAAAAAB 0IRAAAAAC");
|
||||||
|
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelOp { uid, op: true, .. } if uid == "0IRAAAAAB")), "op: {ev:?}");
|
||||||
|
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAC")), "voice: {ev:?}");
|
||||||
|
// A -v revokes voice.
|
||||||
|
let ev = proto().parse(":0IRAAAAAB FMODE #chan 1783845132 -v 0IRAAAAAC");
|
||||||
|
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: false, .. } if uid == "0IRAAAAAC")), "devoice: {ev:?}");
|
||||||
|
// A +v join prefix surfaces a voice event alongside the Join.
|
||||||
|
let ev = proto().parse(":0IR FJOIN #chan 1783845132 +tn :v,0IRAAAAAD");
|
||||||
|
assert!(ev.iter().any(|e| matches!(e, NetEvent::ChannelVoice { uid, voice: true, .. } if uid == "0IRAAAAAD")), "join voice: {ev:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_ftopic_and_filters_own() {
|
fn parses_ftopic_and_filters_own() {
|
||||||
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
|
let ev = proto().parse(":0IRAAAAAB FTOPIC #chan 1783845132 1783845140 :Welcome all");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! MemoServ delivers short messages ("memos") to registered accounts whether or
|
//! MemoServ delivers short messages ("memos") to registered accounts whether or
|
||||||
//! not they are online — they read them next time they identify. Memos are typed
|
//! not they are online — they read them next time they identify. Memos are typed
|
||||||
//! and event-logged on the account, so they persist and federate like any other
|
//! and event-logged on the account, so they persist and federate like any other
|
||||||
//! account data (no Anope-style flat-file serialization). `lib.rs` holds the
|
//! account data. `lib.rs` holds the dispatcher; each command lives in its own
|
||||||
//! dispatcher; each command lives in its own file, matching NickServ/ChanServ.
|
//! file, matching NickServ/ChanServ.
|
||||||
|
|
||||||
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
|
use fedserv_api::{NetView, Sender, Service, ServiceCtx, Store};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Password policy for REGISTER and SET PASSWORD. Anope enforces a minimum
|
// Password policy for REGISTER and SET PASSWORD: a minimum length and forbidding
|
||||||
// length and forbids the password matching the nick; we do the same, plus an
|
// the password matching the nick, plus an upper bound so a huge password can't
|
||||||
// upper bound so a huge password can't be used to burn CPU in the key
|
// be used to burn CPU in the key
|
||||||
// derivation. Length is measured in characters, the cap in bytes (what the
|
// derivation. Length is measured in characters, the cap in bytes (what the
|
||||||
// hasher actually processes).
|
// hasher actually processes).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use std::path::PathBuf;
|
||||||
pub const BADWORD_SIZE_LIMIT: usize = 1 << 20;
|
pub const BADWORD_SIZE_LIMIT: usize = 1 << 20;
|
||||||
|
|
||||||
/// Compile a channel's badword patterns into one RegexSet. Case-insensitive by
|
/// Compile a channel's badword patterns into one RegexSet. Case-insensitive by
|
||||||
/// default (Anope's default; a pattern can opt out with `(?-i)`), size-limited,
|
/// default (a pattern can opt out with `(?-i)`), size-limited, and tolerant of a
|
||||||
/// and tolerant of a bad entry so one pattern can't disable the whole set.
|
/// bad entry so one pattern can't disable the whole set.
|
||||||
pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
|
pub fn build_badword_set(patterns: &[String]) -> regex::RegexSet {
|
||||||
regex::RegexSetBuilder::new(patterns)
|
regex::RegexSetBuilder::new(patterns)
|
||||||
.case_insensitive(true)
|
.case_insensitive(true)
|
||||||
|
|
@ -376,6 +376,9 @@ pub struct KickerSettings {
|
||||||
// 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,
|
||||||
|
// Don't kick voiced (+v) users.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dontkickvoices: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KickerSettings {
|
impl KickerSettings {
|
||||||
|
|
@ -384,7 +387,7 @@ impl KickerSettings {
|
||||||
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords
|
self.caps || self.bolds || self.colors || self.underlines || self.reverses || self.italics || self.flood || self.repeat || self.badwords
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved thresholds, applying Anope's defaults for a 0 (unset) value.
|
// Resolved thresholds, applying the defaults for a 0 (unset) value.
|
||||||
pub fn flood_thresholds(&self) -> (u16, u16) {
|
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 })
|
(if self.flood_lines < 2 { 6 } else { self.flood_lines }, if self.flood_secs == 0 { 10 } else { self.flood_secs })
|
||||||
}
|
}
|
||||||
|
|
@ -1465,6 +1468,7 @@ impl Db {
|
||||||
Kicker::Badwords => k.badwords = on,
|
Kicker::Badwords => k.badwords = on,
|
||||||
Kicker::Warn => k.warn = on,
|
Kicker::Warn => k.warn = on,
|
||||||
Kicker::DontKickOps => k.dontkickops = on,
|
Kicker::DontKickOps => k.dontkickops = on,
|
||||||
|
Kicker::DontKickVoices => k.dontkickvoices = on,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use state::Network;
|
||||||
// `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept.
|
// `sasl=` capability value (IRCv3 SASL 3.2 mechanism list) and the set we accept.
|
||||||
const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN";
|
const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN";
|
||||||
|
|
||||||
// The in-channel fantasy trigger, e.g. `!op`. Matches Anope's default.
|
// The in-channel fantasy trigger, e.g. `!op`.
|
||||||
const FANTASY_PREFIX: &str = "!";
|
const FANTASY_PREFIX: &str = "!";
|
||||||
|
|
||||||
// A client's base64 response is split into chunks of this length; a chunk
|
// A client's base64 response is split into chunks of this length; a chunk
|
||||||
|
|
@ -669,6 +669,10 @@ impl Engine {
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
NetEvent::ChannelVoice { channel, uid, voice } => {
|
||||||
|
self.network.set_voice(&channel, &uid, voice);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
NetEvent::ChannelKey { channel, key } => {
|
NetEvent::ChannelKey { channel, key } => {
|
||||||
self.network.set_channel_key(&channel, key);
|
self.network.set_channel_key(&channel, key);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|
@ -1021,6 +1025,9 @@ impl Engine {
|
||||||
if k.dontkickops && self.network.is_op(channel, from) {
|
if k.dontkickops && self.network.is_op(channel, from) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
if k.dontkickvoices && self.network.is_voiced(channel, from) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
let Some(bot) = c.assigned_bot.as_deref() else { return Vec::new() };
|
let Some(bot) = c.assigned_bot.as_deref() else { return Vec::new() };
|
||||||
let Some(botuid) = self.network.uid_by_nick(bot).map(str::to_string) else { return Vec::new() };
|
let Some(botuid) = self.network.uid_by_nick(bot).map(str::to_string) else { return Vec::new() };
|
||||||
|
|
||||||
|
|
@ -1174,7 +1181,7 @@ impl Engine {
|
||||||
// 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
|
||||||
// argument — then re-source the resulting actions from the bot, so the bot is
|
// argument — then re-source the resulting actions from the bot, so the bot is
|
||||||
// the visible actor (Anope routes fantasy through the assigned bot too).
|
// the visible actor.
|
||||||
fn fantasy(&mut self, sender: &Sender, chan: &str, text: &str, ctx: &mut ServiceCtx) {
|
fn fantasy(&mut self, sender: &Sender, chan: &str, text: &str, ctx: &mut ServiceCtx) {
|
||||||
let Some(rest) = text.strip_prefix(FANTASY_PREFIX) else { return };
|
let Some(rest) = text.strip_prefix(FANTASY_PREFIX) else { return };
|
||||||
let mut words = rest.split_whitespace();
|
let mut words = rest.split_whitespace();
|
||||||
|
|
@ -2124,7 +2131,7 @@ mod tests {
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOT DEL * removes every bot at once (Anope issue #315), quitting each.
|
// BOT DEL * removes every bot at once, quitting each.
|
||||||
#[test]
|
#[test]
|
||||||
fn botserv_mass_bot_removal() {
|
fn botserv_mass_bot_removal() {
|
||||||
use fedserv_botserv::BotServ;
|
use fedserv_botserv::BotServ;
|
||||||
|
|
@ -2469,6 +2476,24 @@ mod tests {
|
||||||
assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss");
|
assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DONTKICKVOICES exempts voiced users; removing the voice makes them kickable.
|
||||||
|
#[test]
|
||||||
|
fn botserv_dontkickvoices_exempts() {
|
||||||
|
let (mut e, _p) = kicker_fixture("bsdkv");
|
||||||
|
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() });
|
||||||
|
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAS"));
|
||||||
|
bs(&mut e, "KICK #c CAPS ON");
|
||||||
|
bs(&mut e, "KICK #c DONTKICKVOICES ON");
|
||||||
|
|
||||||
|
// Voiced: exempt.
|
||||||
|
e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: true });
|
||||||
|
assert!(!kicked(&say(&mut e, "SHOUTING WHILE VOICED")), "voiced user exempt");
|
||||||
|
// Devoiced: kickable again.
|
||||||
|
e.handle(NetEvent::ChannelVoice { channel: "#c".into(), uid: "000AAAAAS".into(), voice: false });
|
||||||
|
assert!(kicked(&say(&mut e, "SHOUTING WITHOUT VOICE")), "devoiced user kicked");
|
||||||
|
}
|
||||||
|
|
||||||
// WARN gives a one-time warning before the first real kick.
|
// WARN gives a one-time warning before the first real kick.
|
||||||
#[test]
|
#[test]
|
||||||
fn botserv_warn_before_kick() {
|
fn botserv_warn_before_kick() {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ pub struct User {
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub members: HashSet<String>, // uids
|
pub members: HashSet<String>, // uids
|
||||||
pub ops: HashSet<String>, // uids holding channel-operator status
|
pub ops: HashSet<String>, // uids holding channel-operator status
|
||||||
|
pub voices: HashSet<String>, // uids holding +v
|
||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +97,7 @@ impl Network {
|
||||||
for c in self.channels.values_mut() {
|
for c in self.channels.values_mut() {
|
||||||
c.members.remove(uid);
|
c.members.remove(uid);
|
||||||
c.ops.remove(uid);
|
c.ops.remove(uid);
|
||||||
|
c.voices.remove(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +145,7 @@ impl Network {
|
||||||
if let Some(c) = self.channels.get_mut(&lc(channel)) {
|
if let Some(c) = self.channels.get_mut(&lc(channel)) {
|
||||||
c.members.remove(uid);
|
c.members.remove(uid);
|
||||||
c.ops.remove(uid);
|
c.ops.remove(uid);
|
||||||
|
c.voices.remove(uid);
|
||||||
}
|
}
|
||||||
if let Some(nick) = self.nick_of(uid) {
|
if let Some(nick) = self.nick_of(uid) {
|
||||||
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") });
|
self.seen.insert(lc(nick), Seen { nick: nick.to_string(), ts: now(), what: format!("leaving {channel}") });
|
||||||
|
|
@ -164,6 +167,21 @@ impl Network {
|
||||||
self.channels.get(&lc(channel)).is_some_and(|c| c.ops.contains(uid))
|
self.channels.get(&lc(channel)).is_some_and(|c| c.ops.contains(uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set or clear a user's voice status (+v).
|
||||||
|
pub fn set_voice(&mut self, channel: &str, uid: &str, voice: bool) {
|
||||||
|
let c = self.channels.entry(lc(channel)).or_default();
|
||||||
|
if voice {
|
||||||
|
c.voices.insert(uid.to_string());
|
||||||
|
} else {
|
||||||
|
c.voices.remove(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether `uid` currently holds voice in `channel`.
|
||||||
|
pub fn is_voiced(&self, channel: &str, uid: &str) -> bool {
|
||||||
|
self.channels.get(&lc(channel)).is_some_and(|c| c.voices.contains(uid))
|
||||||
|
}
|
||||||
|
|
||||||
// Uids currently in `channel`.
|
// Uids currently in `channel`.
|
||||||
pub fn channel_members(&self, channel: &str) -> impl Iterator<Item = &str> {
|
pub fn channel_members(&self, channel: &str) -> impl Iterator<Item = &str> {
|
||||||
self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))
|
self.channels.get(&lc(channel)).into_iter().flat_map(|c| c.members.iter().map(String::as_str))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue