diff --git a/api/src/lib.rs b/api/src/lib.rs index 65046d1..768e8ad 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -589,6 +589,7 @@ pub trait Store { 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_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. fn badword_add(&mut self, channel: &str, pattern: &str) -> Result; fn badword_del(&mut self, channel: &str, pattern: &str) -> Result; diff --git a/botserv/src/set.rs b/botserv/src/set.rs index 82f6aff..99aea8a 100644 --- a/botserv/src/set.rs +++ b/botserv/src/set.rs @@ -42,6 +42,19 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: 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::().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 (0 to disable)"), + } + return; + } + // BANEXPIRE takes a duration, not ON/OFF. if option.eq_ignore_ascii_case("BANEXPIRE") { let Some(&arg) = args.get(3) else { diff --git a/src/engine/db.rs b/src/engine/db.rs index 8746e59..d08c1f7 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -376,6 +376,9 @@ pub struct KickerSettings { // Warn a user (once) before kicking them the first time. #[serde(default)] 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. #[serde(default)] pub dontkickops: bool, @@ -847,7 +850,7 @@ impl Db { if let Some(bot) = &c.assigned_bot { snapshot.push(Event::ChannelBotAssigned { channel: c.name.clone(), bot: bot.clone() }); } - if c.kickers.any() || c.kickers.dontkickops || 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() }); } if !c.badwords.is_empty() { @@ -1511,6 +1514,11 @@ impl Db { 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 /// Ok(false) if the exact pattern is already present. pub fn badword_add(&mut self, channel: &str, pattern: &str) -> Result { @@ -2315,6 +2323,9 @@ impl Store for Db { fn set_ban_expire(&mut self, channel: &str, secs: u32) -> Result<(), ChanError> { 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 { Db::badword_add(self, channel, pattern) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index c0dd544..f1c05e9 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -23,6 +23,9 @@ const SASL_MECHS: &str = "EXTERNAL,SCRAM-SHA-512,SCRAM-SHA-256,PLAIN"; // The in-channel fantasy trigger, e.g. `!op`. 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 // shorter than this (or a lone "+") marks the end of the response (SASL 3.1). const MAX_AUTHENTICATE: usize = 400; @@ -119,6 +122,15 @@ pub struct Engine { // ServiceCtx::count) plus engine-internal events; exposed over the gRPC // Stats API. Ordered so a snapshot is stable. stats: std::collections::BTreeMap, + // 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, // voter uids, one vote each + started: u64, } struct CachedBadwords { @@ -184,6 +196,7 @@ impl Engine { trigger_cache: HashMap::new(), pending_unbans: Vec::new(), stats: std::collections::BTreeMap::new(), + votes: HashMap::new(), } } @@ -990,16 +1003,20 @@ impl Engine { let mut ctx = ServiceCtx::default(); let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs }; if to.starts_with('#') || to.starts_with('&') { - // In-channel: a fantasy command (!op …) if a bot is assigned, plus a - // BotServ kicker check on every line. - self.fantasy(&sender, to, text, &mut ctx); - let kicks = self.kicker_check(from, to, text); - let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. })); - ctx.actions.extend(kicks); - // Don't reward a line the bot just kicked for with a response. - if !kicked { - if let Some(resp) = self.trigger_response(from, to, text) { - ctx.actions.push(resp); + // A community !votekick/!voteban is handled on its own; otherwise the + // 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); + let kicks = self.kicker_check(from, to, text); + let kicked = kicks.iter().any(|a| matches!(a, NetAction::Kick { .. })); + ctx.actions.extend(kicks); + // Don't reward a line the bot just kicked for with a response. + if !kicked { + if let Some(resp) = self.trigger_response(from, to, text) { + ctx.actions.push(resp); + } } } // BOTSTATS: count activity in channels that have a bot. @@ -1252,6 +1269,57 @@ impl Engine { }); } + // Community moderation: `!votekick ` / `!voteban `. 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> { + 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 // 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 @@ -2551,6 +2619,24 @@ mod tests { 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. #[test] fn botserv_botstats_reports_activity() {