BotServ: community !votekick / !voteban

SET <#channel> VOTEKICK <n> enables in-channel voting: members type
!votekick <nick> (or !voteban), each voter counts once, and when n votes
are reached within the window the assigned bot kicks (or bans then kicks)
the target. Votes are ephemeral and lapse after two minutes. Crowd
moderation with no op online.
This commit is contained in:
Jean Chevronnet 2026-07-13 19:32:25 +00:00
parent 374748b1f9
commit ad61addb84
No known key found for this signature in database
4 changed files with 122 additions and 11 deletions

View file

@ -589,6 +589,7 @@ pub trait Store {
fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>; fn set_repeat_kicker(&mut self, channel: &str, times: u16) -> Result<(), ChanError>;
fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError>; fn set_ttb(&mut self, channel: &str, ttb: u16) -> Result<(), ChanError>;
fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError>; fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError>;
fn set_votekick(&mut self, channel: &str, votes: u16) -> Result<(), ChanError>;
// BADWORDS list (regex patterns). add validates the pattern compiles. // BADWORDS list (regex patterns). add validates the pattern compiles.
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>; fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;
fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>; fn badword_del(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError>;

View file

@ -42,6 +42,19 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
return; return;
} }
// VOTEKICK takes a vote count (0 = off), not ON/OFF.
if option.eq_ignore_ascii_case("VOTEKICK") {
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_votekick(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, format!("\x02!votekick\x02 is now disabled in \x02{chan}\x02.")),
Ok(()) => ctx.notice(me, from.uid, format!("\x02{n}\x02 vote(s) will now carry a \x02!votekick\x02/\x02!voteban\x02 in \x02{chan}\x02.")),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
None => ctx.notice(me, from.uid, "Syntax: SET <#channel> VOTEKICK <number> (0 to disable)"),
}
return;
}
// BANEXPIRE takes a duration, not ON/OFF. // BANEXPIRE takes a duration, not ON/OFF.
if option.eq_ignore_ascii_case("BANEXPIRE") { if option.eq_ignore_ascii_case("BANEXPIRE") {
let Some(&arg) = args.get(3) else { let Some(&arg) = args.get(3) else {

View file

@ -376,6 +376,9 @@ pub struct KickerSettings {
// Warn a user (once) before kicking them the first time. // Warn a user (once) before kicking them the first time.
#[serde(default)] #[serde(default)]
pub warn: bool, pub warn: bool,
// Community !votekick/!voteban: votes needed to act (0 = disabled).
#[serde(default)]
pub votekick: u16,
// 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,
@ -847,7 +850,7 @@ 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 || c.kickers.ttb > 0 || c.kickers.ban_expire > 0 { if c.kickers.any() || c.kickers.dontkickops || c.kickers.ttb > 0 || c.kickers.ban_expire > 0 || c.kickers.votekick > 0 {
snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() }); snapshot.push(Event::ChannelKickerSet { channel: c.name.clone(), kickers: c.kickers.clone() });
} }
if !c.badwords.is_empty() { if !c.badwords.is_empty() {
@ -1511,6 +1514,11 @@ impl Db {
self.update_kickers(channel, |k| k.ban_expire = secs) self.update_kickers(channel, |k| k.ban_expire = secs)
} }
/// Votes needed for a community !votekick/!voteban (0 = disabled).
pub fn set_votekick(&mut self, channel: &str, votes: u16) -> Result<(), ChanError> {
self.update_kickers(channel, |k| k.votekick = votes)
}
/// Add a badword regex. Validates it compiles within the size limit; returns /// Add a badword regex. Validates it compiles within the size limit; returns
/// Ok(false) if the exact pattern is already present. /// Ok(false) if the exact pattern is already present.
pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> { pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
@ -2315,6 +2323,9 @@ impl Store for Db {
fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> { fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> {
Db::set_ban_expire(self, channel, secs) Db::set_ban_expire(self, channel, secs)
} }
fn set_votekick(&mut self, channel: &str, votes: u16) -> Result<(), ChanError> {
Db::set_votekick(self, channel, votes)
}
fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> { fn badword_add(&mut self, channel: &str, pattern: &str) -> Result<bool, ChanError> {
Db::badword_add(self, channel, pattern) Db::badword_add(self, channel, pattern)
} }

View file

@ -23,6 +23,9 @@ const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN";
// The in-channel fantasy trigger, e.g. `!op`. // The in-channel fantasy trigger, e.g. `!op`.
const FANTASY_PREFIX: &str = "!"; const FANTASY_PREFIX: &str = "!";
// A community vote lapses if it isn't reached within this many seconds.
const VOTE_TTL: u64 = 120;
// 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
// shorter than this (or a lone "+") marks the end of the response (SASL 3.1). // shorter than this (or a lone "+") marks the end of the response (SASL 3.1).
const MAX_AUTHENTICATE: usize = 400; const MAX_AUTHENTICATE: usize = 400;
@ -119,6 +122,15 @@ pub struct Engine {
// ServiceCtx::count) plus engine-internal events; exposed over the gRPC // ServiceCtx::count) plus engine-internal events; exposed over the gRPC
// Stats API. Ordered so a snapshot is stable. // Stats API. Ordered so a snapshot is stable.
stats: std::collections::BTreeMap<String, u64>, stats: std::collections::BTreeMap<String, u64>,
// In-flight community !votekick/!voteban tallies, keyed by (channel_lc,
// target_nick_lc). Ephemeral; expire after VOTE_TTL.
votes: HashMap<(String, String), VoteState>,
}
struct VoteState {
ban: bool,
voters: std::collections::HashSet<String>, // voter uids, one vote each
started: u64,
} }
struct CachedBadwords { struct CachedBadwords {
@ -184,6 +196,7 @@ impl Engine {
trigger_cache: HashMap::new(), trigger_cache: HashMap::new(),
pending_unbans: Vec::new(), pending_unbans: Vec::new(),
stats: std::collections::BTreeMap::new(), stats: std::collections::BTreeMap::new(),
votes: HashMap::new(),
} }
} }
@ -990,8 +1003,11 @@ 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: a fantasy command (!op …) if a bot is assigned, plus a // A community !votekick/!voteban is handled on its own; otherwise the
// BotServ kicker check on every line. // line runs through fantasy (!op …), the kickers, and triggers.
if let Some(vote_actions) = self.handle_vote(from, to, text) {
ctx.actions.extend(vote_actions);
} else {
self.fantasy(&sender, to, text, &mut ctx); self.fantasy(&sender, to, text, &mut ctx);
let kicks = self.kicker_check(from, to, text); let kicks = self.kicker_check(from, to, text);
let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. })); let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. }));
@ -1002,6 +1018,7 @@ impl Engine {
ctx.actions.push(resp); ctx.actions.push(resp);
} }
} }
}
// BOTSTATS: count activity in channels that have a bot. // BOTSTATS: count activity in channels that have a bot.
if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) { if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) {
self.network.record_line(to, &nick); self.network.record_line(to, &nick);
@ -1252,6 +1269,57 @@ impl Engine {
}); });
} }
// Community moderation: `!votekick <nick>` / `!voteban <nick>`. Each voter
// (by uid) counts once; when the channel's VOTEKICK threshold is reached the
// assigned bot kicks (or bans then kicks) the target. Returns None if the line
// isn't a vote command, so the caller can fall through to fantasy.
fn handle_vote(&mut self, from: &str, channel: &str, text: &str) -> Option<Vec<NetAction>> {
let (ban, target_raw) = if let Some(t) = text.strip_prefix("!votekick ") {
(false, t)
} else {
(true, text.strip_prefix("!voteban ")?)
};
let target_nick = target_raw.trim();
if target_nick.is_empty() || target_nick.contains(' ') {
return Some(Vec::new());
}
let threshold = self.db.channel(channel).map(|c| c.kickers.votekick).unwrap_or(0);
let botnick = self.db.channel(channel).and_then(|c| c.assigned_bot.clone());
let (Some(botnick), true) = (botnick, threshold > 0) else { return Some(Vec::new()) };
let Some(botuid) = self.network.uid_by_nick(&botnick).map(str::to_string) else { return Some(Vec::new()) };
let Some(target_uid) = self.network.uid_by_nick(target_nick).map(str::to_string) else {
return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("There's no \x02{target_nick}\x02 here to vote on.") }]);
};
let target_display = self.network.nick_of(&target_uid).unwrap_or(target_nick).to_string();
let now = self.now_secs();
let key = (channel.to_ascii_lowercase(), target_display.to_ascii_lowercase());
let vs = self.votes.entry(key.clone()).or_insert(VoteState { ban, voters: std::collections::HashSet::new(), started: now });
if now.saturating_sub(vs.started) > VOTE_TTL {
*vs = VoteState { ban, voters: std::collections::HashSet::new(), started: now };
}
vs.ban = ban;
vs.voters.insert(from.to_string());
let count = vs.voters.len() as u16;
if count < threshold {
let verb = if ban { "ban" } else { "kick" };
return Some(vec![NetAction::Privmsg { from: botuid, to: channel.to_string(), text: format!("\x02{count}\x02/\x02{threshold}\x02 votes to {verb} \x02{target_display}\x02.") }]);
}
self.votes.remove(&key);
self.bump("botserv.votekick");
let mut out = Vec::new();
if ban {
if let Some(host) = self.network.host_of(&target_uid).map(str::to_string) {
out.push(NetAction::ChannelMode { from: botuid.clone(), channel: channel.to_string(), modes: format!("+b *!*@{host}") });
}
}
let verb = if ban { "banned" } else { "kicked" };
out.push(NetAction::Privmsg { from: botuid.clone(), to: channel.to_string(), text: format!("Vote passed — \x02{target_display}\x02 has been {verb}.") });
out.push(NetAction::Kick { from: botuid, channel: channel.to_string(), uid: target_uid, reason: format!("Voted out by the channel ({count} votes)") });
Some(out)
}
// 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
@ -2551,6 +2619,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");
} }
// Community !votekick tallies distinct voters and kicks at the threshold.
#[test]
fn botserv_votekick_reaches_threshold() {
let (mut e, _p) = kicker_fixture("bsvote");
let bs = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: t.into() });
let vote = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "#c".into(), text: t.into() });
bs(&mut e, "SET #c VOTEKICK 2");
e.handle(NetEvent::UserConnect { uid: "000AAAAAV".into(), nick: "victim".into(), host: "h".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "carol".into(), host: "h".into() });
let kicked = |out: &[NetAction]| out.iter().any(|a| matches!(a, NetAction::Kick { from, uid, .. } if from.starts_with("42SB") && uid == "000AAAAAV"));
// First voter, then the same voter again (deduped) — no kick yet.
assert!(!kicked(&vote(&mut e, "000AAAAAB", "!votekick victim")), "1 vote: no kick");
assert!(!kicked(&vote(&mut e, "000AAAAAB", "!votekick victim")), "same voter doesn't double-count");
// A second distinct voter reaches the threshold.
assert!(kicked(&vote(&mut e, "000AAAAAC", "!votekick victim")), "2 distinct votes: kicked");
}
// BOTSTATS reports per-channel activity the bot has seen this session. // BOTSTATS reports per-channel activity the bot has seen this session.
#[test] #[test]
fn botserv_botstats_reports_activity() { fn botserv_botstats_reports_activity() {