From 929a4cdd92870addc4ac8b2813eb2913bf749147 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 13 Jul 2026 18:03:46 +0000 Subject: [PATCH] =?UTF-8?q?BotServ:=20TRIGGER=20=E2=80=94=20regex=20auto-r?= =?UTF-8?q?esponses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRIGGER <#channel> ADD | | DEL | LIST | CLEAR: when a channel line matches , the assigned bot says ($nick becomes the speaker's nick). Only the first matching trigger fires, and a line the bot just kicked for gets no response. Same engine machinery as badwords: patterns compile into one RegexSet per channel (linear-time, ReDoS-safe), cached and rebuilt only on a revision change. Patterns validated + size-limited at add time. --- api/src/lib.rs | 11 +++++ botserv/src/lib.rs | 5 ++- botserv/src/trigger.rs | 66 ++++++++++++++++++++++++++++ src/engine/db.rs | 97 ++++++++++++++++++++++++++++++++++++++++-- src/engine/mod.rs | 61 ++++++++++++++++++++++++++ src/grpc.rs | 1 + 6 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 botserv/src/trigger.rs diff --git a/api/src/lib.rs b/api/src/lib.rs index 81f76bc..5ec62b1 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -336,6 +336,12 @@ pub struct MemoView { } // A service bot registered with BotServ. +#[derive(Debug, Clone)] +pub struct TriggerView { + pub pattern: String, + pub response: String, +} + #[derive(Debug, Clone)] pub struct BotView { pub nick: String, @@ -578,6 +584,11 @@ pub trait Store { fn kicker_test(&self, channel: &str, text: &str) -> Option; // Copy one channel's BotServ config (kickers/badwords/greet/nobot) to another. fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError>; + // Auto-response triggers (regex -> response). + fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str) -> Result; + fn trigger_del(&mut self, channel: &str, index: usize) -> Result; + fn trigger_clear(&mut self, channel: &str) -> Result; + fn triggers(&self, channel: &str) -> Vec; 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) -> Result<(), ChanError>; fn unsuspend_channel(&mut self, channel: &str) -> Result; diff --git a/botserv/src/lib.rs b/botserv/src/lib.rs index 52b8083..022bb0f 100644 --- a/botserv/src/lib.rs +++ b/botserv/src/lib.rs @@ -20,6 +20,8 @@ mod kick; mod badwords; #[path = "copy.rs"] mod copy; +#[path = "trigger.rs"] +mod trigger; // Shared gate: the sender may administer 's bot options only as its // founder or a services admin. Notices and returns false on failure. @@ -63,7 +65,8 @@ impl Service for BotServ { Some("KICK") => kick::handle(me, from, args, ctx, db), Some("BADWORDS") => badwords::handle(me, from, args, ctx, db), Some("COPY") => copy::handle(me, from, args, ctx, db), - Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> configures kickers, \x02BADWORDS\x02 <#channel> ADD|DEL|LIST manages badword regexes. Operators also have \x02BOT\x02 ADD|DEL|LIST."), + Some("TRIGGER") => trigger::handle(me, from, args, ctx, db), + Some("HELP") | None => ctx.notice(me, from.uid, "BotServ keeps service bots for your channels: \x02ASSIGN\x02 <#channel> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 shows details, \x02SAY\x02/\x02ACT\x02 <#channel> speaks through the bot, \x02SET\x02 <#channel> GREET toggles greets, \x02KICK\x02 <#channel> configures kickers (with \x02TEST\x02/\x02WARN\x02/\x02TTB\x02), \x02BADWORDS\x02 and \x02TRIGGER\x02 <#channel> ADD|DEL|LIST manage badword regexes and auto-responses, \x02COPY\x02 <#src> <#dst> clones settings. Operators also have \x02BOT\x02 ADD|CHANGE|DEL|LIST."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), } } diff --git a/botserv/src/trigger.rs b/botserv/src/trigger.rs new file mode 100644 index 0000000..cba13c0 --- /dev/null +++ b/botserv/src/trigger.rs @@ -0,0 +1,66 @@ +use fedserv_api::{ChanError, Sender, ServiceCtx, Store}; + +// TRIGGER <#channel> ADD | | DEL | LIST | CLEAR: manage +// the channel's auto-responses. When a line matches , the assigned bot +// says ($nick becomes the speaker's nick). Founder-or-admin. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> ADD | | DEL | LIST | CLEAR"); + return; + }; + if !super::require_channel_admin(me, from, chan, ctx, db) { + return; + } + match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { + Some("ADD") => { + // Everything after ADD is "|". + let rest = args[3..].join(" "); + let Some((pattern, response)) = rest.split_once('|') else { + ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> ADD | (e.g. (?i)hello|Hi $nick!)"); + return; + }; + let (pattern, response) = (pattern.trim(), response.trim()); + if pattern.is_empty() || response.is_empty() { + ctx.notice(me, from.uid, "Both a pattern and a response are required: ADD |."); + return; + } + match db.trigger_add(chan, pattern, response) { + Ok(true) => ctx.notice(me, from.uid, format!("Added a trigger to \x02{chan}\x02.")), + Ok(false) => ctx.notice(me, from.uid, "That pattern already has a trigger."), + Err(ChanError::InvalidPattern) => ctx.notice(me, from.uid, format!("\x02{pattern}\x02 isn't a valid regular expression.")), + Err(_) => reg_error(me, from, chan, ctx), + } + } + Some("DEL") => { + let Some(n) = args.get(3).and_then(|s| s.parse::().ok()) else { + ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> DEL (see LIST)"); + return; + }; + match db.trigger_del(chan, n) { + Ok(true) => ctx.notice(me, from.uid, format!("Trigger #\x02{n}\x02 removed from \x02{chan}\x02.")), + Ok(false) => ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no trigger #\x02{n}\x02.")), + Err(_) => reg_error(me, from, chan, ctx), + } + } + Some("CLEAR") => match db.trigger_clear(chan) { + Ok(n) => ctx.notice(me, from.uid, format!("Cleared \x02{n}\x02 trigger(s) from \x02{chan}\x02.")), + Err(_) => reg_error(me, from, chan, ctx), + }, + None | Some("LIST") => { + let triggers = db.triggers(chan); + if triggers.is_empty() { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 has no triggers.")); + return; + } + ctx.notice(me, from.uid, format!("Triggers for \x02{chan}\x02 ({}):", triggers.len())); + for (i, t) in triggers.iter().enumerate() { + ctx.notice(me, from.uid, format!(" {}. {} \x02→\x02 {}", i + 1, t.pattern, t.response)); + } + } + Some(other) => ctx.notice(me, from.uid, format!("Unknown TRIGGER command \x02{other}\x02. Use ADD, DEL, LIST or CLEAR.")), + } +} + +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.")); +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 1a7ee1d..b3555e0 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -30,7 +30,7 @@ use super::scram::{self, Hash}; // fedserv-api SDK crate; re-exported so the engine keeps naming them locally and // modules importing `crate::engine::db::{ChanError, ...}` are unaffected. pub use fedserv_api::{ - AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, + AccountView, AjoinView, BotView, MemoView, SuspensionView, ChanAccessView, ChanAkickView, ChanError, ChanSetting, ChannelView, CertError, CodeKind, Kicker, RegError, Store, TriggerView, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -115,6 +115,7 @@ pub enum Event { ChannelSettingsSet { channel: String, settings: ChanSettings }, ChannelKickerSet { channel: String, kickers: KickerSettings }, ChannelBadwordsSet { channel: String, badwords: Vec }, + ChannelTriggersSet { channel: String, triggers: Vec }, ChannelTopicSet { channel: String, topic: String }, ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option }, ChannelUnsuspended { channel: String }, @@ -167,6 +168,7 @@ impl Event { | Event::ChannelSettingsSet { .. } | Event::ChannelKickerSet { .. } | Event::ChannelBadwordsSet { .. } + | Event::ChannelTriggersSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } | Event::ChannelUnsuspended { .. } @@ -307,6 +309,19 @@ pub struct ChannelInfo { // cache knows to rebuild. Session-local; never serialized. #[serde(skip)] pub badwords_rev: u64, + // TRIGGERs: regex -> response the bot speaks when a line matches. + #[serde(default)] + pub triggers: Vec, + #[serde(skip)] + pub triggers_rev: u64, +} + +// A bot auto-response: when a channel line matches `pattern`, the assigned bot +// says `response` (with $nick replaced by the speaker's nick). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trigger { + pub pattern: String, + pub response: String, } // BotServ's per-channel "kickers": the assigned bot kicks a message that trips @@ -832,6 +847,9 @@ impl Db { if !c.badwords.is_empty() { snapshot.push(Event::ChannelBadwordsSet { channel: c.name.clone(), badwords: c.badwords.clone() }); } + if !c.triggers.is_empty() { + snapshot.push(Event::ChannelTriggersSet { channel: c.name.clone(), triggers: c.triggers.clone() }); + } } for (nick, account) in &self.grouped { snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() }); @@ -1274,7 +1292,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .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 , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 }); + 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() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0 }); Ok(()) } @@ -1530,6 +1548,61 @@ impl Db { self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice()) } + /// Add an auto-response trigger. Validates the pattern; Ok(false) if the same + /// pattern is already present. + pub fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str) -> Result { + if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() { + return Err(ChanError::InvalidPattern); + } + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + if c.triggers.iter().any(|t| t.pattern == pattern) { + return Ok(false); + } + let mut list = c.triggers.clone(); + list.push(Trigger { pattern: pattern.to_string(), response: response.to_string() }); + self.write_triggers(channel, &k, list)?; + Ok(true) + } + + /// Remove the trigger at 1-based `index`. Ok(false) if out of range. + pub fn trigger_del(&mut self, channel: &str, index: usize) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + if index == 0 || index > c.triggers.len() { + return Ok(false); + } + let mut list = c.triggers.clone(); + list.remove(index - 1); + self.write_triggers(channel, &k, list)?; + Ok(true) + } + + /// Clear all triggers; returns how many were removed. + pub fn trigger_clear(&mut self, channel: &str) -> Result { + let k = key(channel); + let Some(c) = self.channels.get(&k) else { return Err(ChanError::NoChannel) }; + let n = c.triggers.len(); + if n > 0 { + self.write_triggers(channel, &k, Vec::new())?; + } + Ok(n) + } + + pub fn triggers(&self, channel: &str) -> &[Trigger] { + self.channels.get(&key(channel)).map_or(&[], |c| c.triggers.as_slice()) + } + + fn write_triggers(&mut self, channel: &str, k: &str, list: Vec) -> Result<(), ChanError> { + self.log + .append(Event::ChannelTriggersSet { channel: channel.to_string(), triggers: list.clone() }) + .map_err(|_| ChanError::Internal)?; + let c = self.channels.get_mut(k).unwrap(); + c.triggers = list; + c.triggers_rev = c.triggers_rev.wrapping_add(1); + Ok(()) + } + /// Dry-run the content kickers against a line: the reason it would be kicked, /// or None. Flood/repeat are stateful/time-based and are not simulated here. pub fn kicker_test(&self, channel: &str, text: &str) -> Option { @@ -1931,7 +2004,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - 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() , badwords: Vec::new(), badwords_rev: 0 }); + 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() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0 }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -1990,6 +2063,12 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.triggers = triggers; + c.triggers_rev = c.triggers_rev.wrapping_add(1); + } + } Event::ChannelTopicSet { channel, topic } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.topic = topic; @@ -2247,6 +2326,18 @@ impl Store for Db { fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError> { Db::copy_bot_config(self, src, dst) } + fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str) -> Result { + Db::trigger_add(self, channel, pattern, response) + } + fn trigger_del(&mut self, channel: &str, index: usize) -> Result { + Db::trigger_del(self, channel, index) + } + fn trigger_clear(&mut self, channel: &str) -> Result { + Db::trigger_clear(self, channel) + } + fn triggers(&self, channel: &str) -> Vec { + Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone() }).collect() + } fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> { Db::set_channel_topic(self, channel, topic) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 394f935..bb48ef3 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -110,6 +110,9 @@ pub struct Engine { // every pattern in a single linear pass (and the regex crate is backtracking- // free, so user patterns can't blow up). Never event-logged. badword_cache: HashMap, + // Compiled auto-response TRIGGER matchers, one per channel, rebuilt lazily on + // a revision change (same scheme as badwords). Never event-logged. + trigger_cache: HashMap, // Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event. pending_unbans: Vec, } @@ -119,6 +122,12 @@ struct CachedBadwords { set: regex::RegexSet, } +struct CachedTriggers { + rev: u64, + set: regex::RegexSet, + responses: Vec, // parallel to the set's patterns +} + // One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers, // plus the running kick count that drives times-to-ban). #[derive(Default)] @@ -161,6 +170,7 @@ impl Engine { chatter: HashMap::new(), now_override: None, badword_cache: HashMap::new(), + trigger_cache: HashMap::new(), pending_unbans: Vec::new(), } } @@ -951,7 +961,14 @@ impl Engine { // 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); + } + } } else { let Self { services, network, db, .. } = self; for svc in services.iter_mut() { @@ -1096,6 +1113,31 @@ impl Engine { out } + // An auto-response for a channel line: if it matches a TRIGGER pattern, the + // assigned bot says the trigger's response (with $nick substituted). Only the + // first matching trigger fires. Cache rebuilt only when the list changes. + fn trigger_response(&mut self, from: &str, channel: &str, text: &str) -> Option { + let c = self.db.channel(channel)?; + if c.triggers.is_empty() { + return None; + } + let bot = c.assigned_bot.as_deref()?; + let botuid = self.network.uid_by_nick(bot)?.to_string(); + let rev = c.triggers_rev; + if self.trigger_cache.get(channel).map(|ct| ct.rev) != Some(rev) { + let patterns: Vec = c.triggers.iter().map(|t| t.pattern.clone()).collect(); + let set = db::build_badword_set(&patterns); + let responses = c.triggers.iter().map(|t| t.response.clone()).collect(); + self.trigger_cache.insert(channel.to_string(), CachedTriggers { rev, set, responses }); + } + let cached = self.trigger_cache.get(channel).unwrap(); + let idx = cached.set.matches(text).iter().next()?; + let response = cached.responses.get(idx)?.clone(); + let nick = self.network.nick_of(from).unwrap_or(from); + let response = response.replace("$nick", nick); + Some(NetAction::Privmsg { from: botuid, to: channel.to_string(), text: response }) + } + // Get (or lazily create) a speaker's counters in a channel. get_mut avoids // any allocation for a channel/user already being tracked. fn chatter_entry(&mut self, channel: &str, from: &str) -> &mut ChatterState { @@ -2408,6 +2450,25 @@ mod tests { assert!(says(&bs(&mut e, "KICK #d TEST i love frogs"), "would be kicked"), "badwords copied"); } + // TRIGGER makes the assigned bot auto-respond to a matching line, with $nick + // substituted for the speaker. + #[test] + fn botserv_trigger_auto_responds() { + let (mut e, _p) = kicker_fixture("bstrigger"); + 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() }); + bs(&mut e, "TRIGGER #c ADD (?i)hello|Hi $nick, welcome!"); + + // A matching line gets a bot response addressed with the speaker's nick. + let out = say(&mut e, "hello everyone"); + assert!( + out.iter().any(|a| matches!(a, NetAction::Privmsg { from, to, text } if from.starts_with("42SB") && to == "#c" && text == "Hi spammer, welcome!")), + "auto-response: {out:?}" + ); + // A non-matching line is ignored. + assert!(!say(&mut e, "goodbye all").iter().any(|a| matches!(a, NetAction::Privmsg { .. })), "no response on miss"); + } + // WARN gives a one-time warning before the first real kick. #[test] fn botserv_warn_before_kick() { diff --git a/src/grpc.rs b/src/grpc.rs index 7142fee..9e9657e 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -139,6 +139,7 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::ChannelSettingsSet { .. } | Event::ChannelKickerSet { .. } | Event::ChannelBadwordsSet { .. } + | Event::ChannelTriggersSet { .. } | Event::ChannelTopicSet { .. } | Event::ChannelSuspended { .. } | Event::ChannelUnsuspended { .. }