BotServ: richer TRIGGERs — capture groups + cooldown
Responses can now use $1..$9 to substitute regex capture groups (e.g. ADD hi (\w+)|Hi $1!), and an optional trailing |<secs> sets a per-trigger cooldown so a trigger can't be spammed. The cache compiles each pattern to its own Regex (for captures) and tracks last-fired for the cooldown.
This commit is contained in:
parent
766ef6ca47
commit
374748b1f9
4 changed files with 92 additions and 22 deletions
|
|
@ -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<bool, ChanError>;
|
||||
fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result<bool, ChanError>;
|
||||
fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError>;
|
||||
fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError>;
|
||||
fn triggers(&self, channel: &str) -> Vec<TriggerView>;
|
||||
|
|
|
|||
|
|
@ -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 "<regex>|<response>".
|
||||
// "<regex>|<response>" with an optional trailing "|<cooldown-secs>".
|
||||
// $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 <regex>|<response> (e.g. (?i)hello|Hi $nick!)");
|
||||
let Some((pattern, mut response)) = rest.split_once('|') else {
|
||||
ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> ADD <regex>|<response>[|<cooldown-secs>]");
|
||||
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::<u32>() {
|
||||
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 <regex>|<response>.");
|
||||
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.")),
|
||||
|
|
|
|||
|
|
@ -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<bool, ChanError> {
|
||||
pub fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result<bool, ChanError> {
|
||||
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<bool, ChanError> {
|
||||
Db::trigger_add(self, channel, pattern, response)
|
||||
fn trigger_add(&mut self, channel: &str, pattern: &str, response: &str, cooldown: u32) -> Result<bool, ChanError> {
|
||||
Db::trigger_add(self, channel, pattern, response, cooldown)
|
||||
}
|
||||
fn trigger_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError> {
|
||||
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<TriggerView> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -128,8 +128,15 @@ struct CachedBadwords {
|
|||
|
||||
struct CachedTriggers {
|
||||
rev: u64,
|
||||
set: regex::RegexSet,
|
||||
responses: Vec<String>, // parallel to the set's patterns
|
||||
entries: Vec<TriggerRt>,
|
||||
}
|
||||
|
||||
// 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<String> = 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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue