BotServ: TRIGGER — regex auto-responses
TRIGGER <#channel> ADD <regex>|<response> | DEL <num> | LIST | CLEAR: when a channel line matches <regex>, the assigned bot says <response> ($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.
This commit is contained in:
parent
47b1fd351e
commit
929a4cdd92
6 changed files with 237 additions and 4 deletions
|
|
@ -336,6 +336,12 @@ pub struct MemoView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// A service bot registered with BotServ.
|
// A service bot registered with BotServ.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TriggerView {
|
||||||
|
pub pattern: String,
|
||||||
|
pub response: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BotView {
|
pub struct BotView {
|
||||||
pub nick: String,
|
pub nick: String,
|
||||||
|
|
@ -578,6 +584,11 @@ pub trait Store {
|
||||||
fn kicker_test(&self, channel: &str, text: &str) -> Option<String>;
|
fn kicker_test(&self, channel: &str, text: &str) -> Option<String>;
|
||||||
// Copy one channel's BotServ config (kickers/badwords/greet/nobot) to another.
|
// Copy one channel's BotServ config (kickers/badwords/greet/nobot) to another.
|
||||||
fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError>;
|
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_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>;
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError>;
|
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<u64>) -> Result<(), ChanError>;
|
fn suspend_channel(&mut self, channel: &str, by: &str, reason: &str, expires: Option<u64>) -> Result<(), ChanError>;
|
||||||
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
fn unsuspend_channel(&mut self, channel: &str) -> Result<bool, ChanError>;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ mod kick;
|
||||||
mod badwords;
|
mod badwords;
|
||||||
#[path = "copy.rs"]
|
#[path = "copy.rs"]
|
||||||
mod copy;
|
mod copy;
|
||||||
|
#[path = "trigger.rs"]
|
||||||
|
mod trigger;
|
||||||
|
|
||||||
// Shared gate: the sender may administer <chan>'s bot options only as its
|
// Shared gate: the sender may administer <chan>'s bot options only as its
|
||||||
// founder or a services admin. Notices and returns false on failure.
|
// 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("KICK") => kick::handle(me, from, args, ctx, db),
|
||||||
Some("BADWORDS") => badwords::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("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> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> 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> <bot> puts a bot in your channel, \x02UNASSIGN\x02 <#channel> removes it, \x02INFO\x02 <bot|#channel> shows details, \x02SAY\x02/\x02ACT\x02 <#channel> <text> speaks through the bot, \x02SET\x02 <#channel> GREET <on|off> toggles greets, \x02KICK\x02 <#channel> <type> <on|off> 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.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
botserv/src/trigger.rs
Normal file
66
botserv/src/trigger.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
use fedserv_api::{ChanError, Sender, ServiceCtx, Store};
|
||||||
|
|
||||||
|
// TRIGGER <#channel> ADD <regex>|<response> | DEL <num> | LIST | CLEAR: manage
|
||||||
|
// the channel's auto-responses. When a line matches <regex>, the assigned bot
|
||||||
|
// says <response> ($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 <regex>|<response> | DEL <num> | 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 "<regex>|<response>".
|
||||||
|
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!)");
|
||||||
|
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 <regex>|<response>.");
|
||||||
|
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::<usize>().ok()) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: TRIGGER <#channel> DEL <num> (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."));
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ use super::scram::{self, Hash};
|
||||||
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
// fedserv-api SDK crate; re-exported so the engine keeps naming them locally and
|
||||||
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
// modules importing `crate::engine::db::{ChanError, ...}` are unaffected.
|
||||||
pub use fedserv_api::{
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -115,6 +115,7 @@ pub enum Event {
|
||||||
ChannelSettingsSet { channel: String, settings: ChanSettings },
|
ChannelSettingsSet { channel: String, settings: ChanSettings },
|
||||||
ChannelKickerSet { channel: String, kickers: KickerSettings },
|
ChannelKickerSet { channel: String, kickers: KickerSettings },
|
||||||
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
|
ChannelBadwordsSet { channel: String, badwords: Vec<String> },
|
||||||
|
ChannelTriggersSet { channel: String, triggers: Vec<Trigger> },
|
||||||
ChannelTopicSet { channel: String, topic: String },
|
ChannelTopicSet { channel: String, topic: String },
|
||||||
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
|
ChannelSuspended { channel: String, by: String, reason: String, ts: u64, expires: Option<u64> },
|
||||||
ChannelUnsuspended { channel: String },
|
ChannelUnsuspended { channel: String },
|
||||||
|
|
@ -167,6 +168,7 @@ impl Event {
|
||||||
| Event::ChannelSettingsSet { .. }
|
| Event::ChannelSettingsSet { .. }
|
||||||
| Event::ChannelKickerSet { .. }
|
| Event::ChannelKickerSet { .. }
|
||||||
| Event::ChannelBadwordsSet { .. }
|
| Event::ChannelBadwordsSet { .. }
|
||||||
|
| Event::ChannelTriggersSet { .. }
|
||||||
| Event::ChannelTopicSet { .. }
|
| Event::ChannelTopicSet { .. }
|
||||||
| Event::ChannelSuspended { .. }
|
| Event::ChannelSuspended { .. }
|
||||||
| Event::ChannelUnsuspended { .. }
|
| Event::ChannelUnsuspended { .. }
|
||||||
|
|
@ -307,6 +309,19 @@ pub struct ChannelInfo {
|
||||||
// cache knows to rebuild. Session-local; never serialized.
|
// cache knows to rebuild. Session-local; never serialized.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub badwords_rev: u64,
|
pub badwords_rev: u64,
|
||||||
|
// TRIGGERs: regex -> response the bot speaks when a line matches.
|
||||||
|
#[serde(default)]
|
||||||
|
pub triggers: Vec<Trigger>,
|
||||||
|
#[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
|
// BotServ's per-channel "kickers": the assigned bot kicks a message that trips
|
||||||
|
|
@ -832,6 +847,9 @@ impl Db {
|
||||||
if !c.badwords.is_empty() {
|
if !c.badwords.is_empty() {
|
||||||
snapshot.push(Event::ChannelBadwordsSet { channel: c.name.clone(), badwords: c.badwords.clone() });
|
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 {
|
for (nick, account) in &self.grouped {
|
||||||
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
|
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
|
||||||
|
|
@ -1274,7 +1292,7 @@ impl Db {
|
||||||
self.log
|
self.log
|
||||||
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
.append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts })
|
||||||
.map_err(|_| ChanError::Internal)?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1530,6 +1548,61 @@ impl Db {
|
||||||
self.channels.get(&key(channel)).map_or(&[], |c| c.badwords.as_slice())
|
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<bool, ChanError> {
|
||||||
|
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<bool, ChanError> {
|
||||||
|
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<usize, ChanError> {
|
||||||
|
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<Trigger>) -> 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,
|
/// 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.
|
/// or None. Flood/repeat are stateful/time-based and are not simulated here.
|
||||||
pub fn kicker_test(&self, channel: &str, text: &str) -> Option<String> {
|
pub fn kicker_test(&self, channel: &str, text: &str) -> Option<String> {
|
||||||
|
|
@ -1931,7 +2004,7 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
grouped.remove(&key(&nick));
|
grouped.remove(&key(&nick));
|
||||||
}
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
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 } => {
|
Event::ChannelDropped { name } => {
|
||||||
channels.remove(&key(&name));
|
channels.remove(&key(&name));
|
||||||
|
|
@ -1990,6 +2063,12 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
c.badwords_rev = c.badwords_rev.wrapping_add(1);
|
c.badwords_rev = c.badwords_rev.wrapping_add(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::ChannelTriggersSet { channel, triggers } => {
|
||||||
|
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 } => {
|
Event::ChannelTopicSet { channel, topic } => {
|
||||||
if let Some(c) = channels.get_mut(&key(&channel)) {
|
if let Some(c) = channels.get_mut(&key(&channel)) {
|
||||||
c.topic = topic;
|
c.topic = topic;
|
||||||
|
|
@ -2247,6 +2326,18 @@ impl Store for Db {
|
||||||
fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError> {
|
fn copy_bot_config(&mut self, src: &str, dst: &str) -> Result<(), ChanError> {
|
||||||
Db::copy_bot_config(self, src, dst)
|
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_del(&mut self, channel: &str, index: usize) -> Result<bool, ChanError> {
|
||||||
|
Db::trigger_del(self, channel, index)
|
||||||
|
}
|
||||||
|
fn trigger_clear(&mut self, channel: &str) -> Result<usize, ChanError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
fn set_channel_topic(&mut self, channel: &str, topic: &str) -> Result<(), ChanError> {
|
||||||
Db::set_channel_topic(self, channel, topic)
|
Db::set_channel_topic(self, channel, topic)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,9 @@ pub struct Engine {
|
||||||
// every pattern in a single linear pass (and the regex crate is backtracking-
|
// every pattern in a single linear pass (and the regex crate is backtracking-
|
||||||
// free, so user patterns can't blow up). Never event-logged.
|
// free, so user patterns can't blow up). Never event-logged.
|
||||||
badword_cache: HashMap<String, CachedBadwords>,
|
badword_cache: HashMap<String, CachedBadwords>,
|
||||||
|
// Compiled auto-response TRIGGER matchers, one per channel, rebuilt lazily on
|
||||||
|
// a revision change (same scheme as badwords). Never event-logged.
|
||||||
|
trigger_cache: HashMap<String, CachedTriggers>,
|
||||||
// Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event.
|
// Kicker bans (times-to-ban) awaiting BANEXPIRE removal. Swept on each event.
|
||||||
pending_unbans: Vec<PendingUnban>,
|
pending_unbans: Vec<PendingUnban>,
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +122,12 @@ struct CachedBadwords {
|
||||||
set: regex::RegexSet,
|
set: regex::RegexSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CachedTriggers {
|
||||||
|
rev: u64,
|
||||||
|
set: regex::RegexSet,
|
||||||
|
responses: Vec<String>, // parallel to the set's patterns
|
||||||
|
}
|
||||||
|
|
||||||
// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers,
|
// One user's recent-chatter counters in one channel (FLOOD + REPEAT kickers,
|
||||||
// plus the running kick count that drives times-to-ban).
|
// plus the running kick count that drives times-to-ban).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -161,6 +170,7 @@ impl Engine {
|
||||||
chatter: HashMap::new(),
|
chatter: HashMap::new(),
|
||||||
now_override: None,
|
now_override: None,
|
||||||
badword_cache: HashMap::new(),
|
badword_cache: HashMap::new(),
|
||||||
|
trigger_cache: HashMap::new(),
|
||||||
pending_unbans: Vec::new(),
|
pending_unbans: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -951,7 +961,14 @@ impl Engine {
|
||||||
// BotServ kicker check on every line.
|
// BotServ kicker check on every line.
|
||||||
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 { .. }));
|
||||||
ctx.actions.extend(kicks);
|
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 {
|
} else {
|
||||||
let Self { services, network, db, .. } = self;
|
let Self { services, network, db, .. } = self;
|
||||||
for svc in services.iter_mut() {
|
for svc in services.iter_mut() {
|
||||||
|
|
@ -1096,6 +1113,31 @@ impl Engine {
|
||||||
out
|
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<NetAction> {
|
||||||
|
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<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 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
|
// Get (or lazily create) a speaker's counters in a channel. get_mut avoids
|
||||||
// any allocation for a channel/user already being tracked.
|
// any allocation for a channel/user already being tracked.
|
||||||
fn chatter_entry(&mut self, channel: &str, from: &str) -> &mut ChatterState {
|
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");
|
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.
|
// WARN gives a one-time warning before the first real kick.
|
||||||
#[test]
|
#[test]
|
||||||
fn botserv_warn_before_kick() {
|
fn botserv_warn_before_kick() {
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
|
||||||
| Event::ChannelSettingsSet { .. }
|
| Event::ChannelSettingsSet { .. }
|
||||||
| Event::ChannelKickerSet { .. }
|
| Event::ChannelKickerSet { .. }
|
||||||
| Event::ChannelBadwordsSet { .. }
|
| Event::ChannelBadwordsSet { .. }
|
||||||
|
| Event::ChannelTriggersSet { .. }
|
||||||
| Event::ChannelTopicSet { .. }
|
| Event::ChannelTopicSet { .. }
|
||||||
| Event::ChannelSuspended { .. }
|
| Event::ChannelSuspended { .. }
|
||||||
| Event::ChannelUnsuspended { .. }
|
| Event::ChannelUnsuspended { .. }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue