diff --git a/api/src/lib.rs b/api/src/lib.rs index 40093da..65046d1 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -351,6 +351,7 @@ pub struct MemoView { pub struct TriggerView { pub pattern: String, pub response: String, + pub cooldown: u32, } #[derive(Debug, Clone)] @@ -598,7 +599,7 @@ pub trait Store { // 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_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> 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; diff --git a/botserv/src/trigger.rs b/botserv/src/trigger.rs index cba13c0..3ef948e 100644 --- a/botserv/src/trigger.rs +++ b/botserv/src/trigger.rs @@ -13,18 +13,28 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } match args.get(2).map(|s| s.to_ascii_uppercase()).as_deref() { Some("ADD") => { - // Everything after ADD is "|". + // "|" with an optional trailing "|". + // $1..$9 in the response fill from regex capture groups; $nick is the + // speaker. 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!)"); + let Some((pattern, mut response)) = rest.split_once('|') else { + ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> ADD |[|]"); return; }; + // A numeric field after the last '|' is a cooldown, not part of the text. + let mut cooldown = 0; + if let Some((head, tail)) = response.rsplit_once('|') { + if let Ok(secs) = tail.trim().parse::() { + cooldown = secs; + response = head; + } + } 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) { + match db.trigger_add(chan, pattern, response, cooldown) { 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.")), @@ -54,7 +64,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: } 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)); + let cd = if t.cooldown > 0 { format!(" (cooldown {}s)", t.cooldown) } else { String::new() }; + ctx.notice(me, from.uid, format!(" {}. {} \x02→\x02 {}{cd}", 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.")), diff --git a/src/engine/db.rs b/src/engine/db.rs index ac92fd3..8746e59 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -322,6 +322,9 @@ pub struct ChannelInfo { pub struct Trigger { pub pattern: String, pub response: String, + // Minimum seconds between firings of this trigger (0 = no limit). + #[serde(default)] + pub cooldown: u32, } // BotServ's per-channel "kickers": the assigned bot kicks a message that trips @@ -1554,7 +1557,7 @@ impl Db { /// 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 { + pub fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result { if regex::RegexBuilder::new(pattern).size_limit(BADWORD_SIZE_LIMIT).build().is_err() { return Err(ChanError::InvalidPattern); } @@ -1564,7 +1567,7 @@ impl Db { return Ok(false); } let mut list = c.triggers.clone(); - list.push(Trigger { pattern: pattern.to_string(), response: response.to_string() }); + list.push(Trigger { pattern: pattern.to_string(), response: response.to_string(), cooldown }); self.write_triggers(channel, &k, list)?; Ok(true) } @@ -2330,8 +2333,8 @@ 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_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result { + Db::trigger_add(self, channel, pattern, response, cooldown) } fn trigger_del(&mut self, channel: &str, index: usize) -> Result { Db::trigger_del(self, channel, index) @@ -2340,7 +2343,7 @@ impl Store for Db { 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() + Db::triggers(self, channel).iter().map(|t| TriggerView { pattern: t.pattern.clone(), response: t.response.clone(), cooldown: t.cooldown }).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 3f38db4..c0dd544 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -128,8 +128,15 @@ struct CachedBadwords { struct CachedTriggers { rev: u64, - set: regex::RegexSet, - responses: Vec, // parallel to the set's patterns + entries: Vec, +} + +// A compiled trigger, with its own last-fired time for the cooldown. +struct TriggerRt { + re: regex::Regex, + response: String, + cooldown: u32, + last_fired: u64, } // One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers, @@ -1173,16 +1180,42 @@ impl Engine { 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 entries = c + .triggers + .iter() + .filter_map(|t| { + regex::RegexBuilder::new(&t.pattern) + .case_insensitive(true) + .size_limit(db::BADWORD_SIZE_LIMIT) + .build() + .ok() + .map(|re| TriggerRt { re, response: t.response.clone(), cooldown: t.cooldown, last_fired: 0 }) + }) + .collect(); + self.trigger_cache.insert(channel.to_string(), CachedTriggers { rev, entries }); } - 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); + let now = self.now_secs(); + let nick = self.network.nick_of(from).unwrap_or(from).to_string(); + + // First matching trigger wins; $1..$9 fill from capture groups, $nick from + // the speaker. A trigger still cooling down suppresses the response. + let cached = self.trigger_cache.get_mut(channel).unwrap(); + let mut response = None; + for e in cached.entries.iter_mut() { + if let Some(caps) = e.re.captures(text) { + if now.saturating_sub(e.last_fired) < e.cooldown as u64 { + return None; + } + e.last_fired = now; + let mut resp = e.response.clone(); + for i in 1..caps.len() { + resp = resp.replace(&format!("${i}"), caps.get(i).map_or("", |m| m.as_str())); + } + response = Some(resp.replace("$nick", &nick)); + break; + } + } + let response = response?; self.bump("botserv.trigger"); Some(NetAction::Privmsg { from: botuid, to: channel.to_string(), text: response }) } @@ -2571,6 +2604,28 @@ mod tests { assert!(kicked(&say(&mut e, "SHOUTING WITHOUT VOICE")), "devoiced user kicked"); } + // Triggers substitute regex capture groups ($1) and honour a cooldown. + #[test] + fn botserv_trigger_captures_and_cooldown() { + let (mut e, _p) = kicker_fixture("bstrigcap"); + 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)hi (\\w+)|Hi $1!|30"); // capture group + 30s cooldown + let response = |out: &[NetAction]| out.iter().find_map(|a| match a { + NetAction::Privmsg { text, .. } => Some(text.clone()), + _ => None, + }); + + e.now_override = Some(1000); + assert_eq!(response(&say(&mut e, "hi alice")).as_deref(), Some("Hi alice!"), "capture substituted"); + // Within the cooldown window: suppressed. + e.now_override = Some(1010); + assert_eq!(response(&say(&mut e, "hi bob")), None, "suppressed by cooldown"); + // After the cooldown: fires again. + e.now_override = Some(1031); + assert_eq!(response(&say(&mut e, "hi carol")).as_deref(), Some("Hi carol!"), "fires after cooldown"); + } + // WARN gives a one-time warning before the first real kick. #[test] fn botserv_warn_before_kick() {