diff options
Diffstat (limited to 'fun/paroxysm/src/main.rs')
-rw-r--r-- | fun/paroxysm/src/main.rs | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/fun/paroxysm/src/main.rs b/fun/paroxysm/src/main.rs new file mode 100644 index 000000000000..998d125bf41c --- /dev/null +++ b/fun/paroxysm/src/main.rs @@ -0,0 +1,395 @@ +// TODO(tazjin): Upgrade to a Diesel version with public derive +// macros. +#[macro_use] +extern crate diesel; + +use crate::cfg::Config; +use crate::keyword::KeywordDetails; +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, Pool}; +use failure::{format_err, Error}; +use irc::client::prelude::*; +use lazy_static::lazy_static; +use log::{debug, info, warn}; +use rand::rngs::ThreadRng; +use rand::{thread_rng, Rng}; +use regex::{Captures, Regex}; +use std::collections::HashMap; +use std::fmt::Display; + +mod cfg; +mod keyword; +mod models; +mod schema; + +lazy_static! { + static ref LEARN_RE: Regex = + Regex::new(r#"^\?\?(?P<gen>!)?\s*(?P<subj>[^\[:]*):\s*(?P<val>.*)"#).unwrap(); + static ref QUERY_RE: Regex = + Regex::new(r#"^\?\?\s*(?P<subj>[^\[:]*)(?P<idx>\[[^\]]+\])?"#).unwrap(); + static ref QLAST_RE: Regex = Regex::new(r#"^\?\?\s*(?P<subj>[^\[:]*)!"#).unwrap(); + static ref INCREMENT_RE: Regex = + Regex::new(r#"^\?\?(?P<gen>!)?\s*(?P<subj>[^\[:]*)(?P<incrdecr>\+\+|\-\-)"#).unwrap(); + static ref MOVE_RE: Regex = + Regex::new(r#"^\?\?(?P<gen>!)?\s*(?P<subj>[^\[:]*)(?P<idx>\[[^\]]+\])->(?P<new_idx>.*)"#) + .unwrap(); +} + +pub struct App { + client: IrcClient, + pg: Pool<ConnectionManager<PgConnection>>, + rng: ThreadRng, + cfg: Config, + last_msgs: HashMap<String, HashMap<String, String>>, +} + +impl App { + pub fn report_error<T: Display>( + &mut self, + nick: &str, + chan: &str, + msg: T, + ) -> Result<(), Error> { + self.client + .send_notice(nick, format!("[{}] \x0304Error:\x0f {}", chan, msg))?; + Ok(()) + } + + pub fn keyword_from_captures( + &mut self, + learn: &::regex::Captures, + nick: &str, + chan: &str, + ) -> Result<KeywordDetails, Error> { + let db = self.pg.get()?; + debug!("Fetching keyword for captures: {:?}", learn); + let subj = &learn["subj"]; + let learn_chan = if learn.name("gen").is_some() { + "*" + } else { + chan + }; + if !chan.starts_with("#") && learn_chan != "*" { + Err(format_err!("Only general entries may be taught via PM."))?; + } + debug!("Fetching keyword '{}' for chan {}", subj, learn_chan); + let kwd = KeywordDetails::get_or_create(subj, learn_chan, &db)?; + if kwd.keyword.chan == "*" && !self.cfg.admins.contains(nick) { + Err(format_err!( + "Only administrators can create or modify general entries." + ))?; + } + Ok(kwd) + } + + pub fn handle_move( + &mut self, + target: &str, + nick: &str, + chan: &str, + mv: Captures, + ) -> Result<(), Error> { + let db = self.pg.get()?; + let idx = &mv["idx"]; + let idx = match idx[1..(idx.len() - 1)].parse::<usize>() { + Ok(i) => i, + Err(e) => Err(format_err!("Could not parse index: {}", e))?, + }; + let new_idx = match mv["new_idx"].parse::<i32>() { + Ok(i) => i, + Err(e) => Err(format_err!("Could not parse target index: {}", e))?, + }; + let mut kwd = self.keyword_from_captures(&mv, nick, chan)?; + if new_idx < 0 { + kwd.delete(idx, &db)?; + self.client.send_notice( + target, + format!("\x02{}\x0f: Deleted entry {}.", kwd.keyword.name, idx), + )?; + } else { + kwd.swap(idx, new_idx as _, &db)?; + self.client.send_notice( + target, + format!( + "\x02{}\x0f: Swapped entries {} and {}.", + kwd.keyword.name, idx, new_idx + ), + )?; + } + Ok(()) + } + + pub fn handle_learn( + &mut self, + target: &str, + nick: &str, + chan: &str, + learn: Captures, + ) -> Result<(), Error> { + let db = self.pg.get()?; + let val = &learn["val"]; + let mut kwd = self.keyword_from_captures(&learn, nick, chan)?; + let idx = kwd.learn(nick, val, &db)?; + self.client + .send_notice(target, kwd.format_entry(idx).unwrap())?; + Ok(()) + } + + pub fn handle_insert_last_quote( + &mut self, + target: &str, + nick: &str, + chan: &str, + qlast: Captures, + ) -> Result<(), Error> { + let db = self.pg.get()?; + let nick_to_grab = &qlast["subj"].to_ascii_lowercase(); + let mut kwd = self.keyword_from_captures(&qlast, nick, chan)?; + let chan_lastmsgs = self + .last_msgs + .entry(chan.to_string()) + .or_insert(HashMap::new()); + // Use `nick` here, so things like "grfn: see glittershark" work. + let val = if let Some(last) = chan_lastmsgs.get(nick_to_grab) { + if last.starts_with("\x01ACTION ") { + // Yes, this is inefficient, but it's better than writing some hacky CTCP parsing + // code I guess (also, characters are hard, so just blindly slicing + // seems like a bad idea) + format!( + "* {} {}", + nick_to_grab, + last.replace("\x01ACTION ", "").replace("\x01", "") + ) + } else { + format!("<{}> {}", nick_to_grab, last) + } + } else { + Err(format_err!("I dunno what {} said...", kwd.keyword.name))? + }; + let idx = kwd.learn(nick, &val, &db)?; + self.client + .send_notice(target, kwd.format_entry(idx).unwrap())?; + Ok(()) + } + + pub fn handle_increment( + &mut self, + target: &str, + nick: &str, + chan: &str, + icr: Captures, + ) -> Result<(), Error> { + let db = self.pg.get()?; + let mut kwd = self.keyword_from_captures(&icr, nick, chan)?; + let is_incr = &icr["incrdecr"] == "++"; + let now = chrono::Utc::now().naive_utc().date(); + let mut idx = None; + for (i, ent) in kwd.entries.iter().enumerate() { + if ent.creation_ts.date() == now { + if let Ok(val) = ent.text.parse::<i32>() { + let val = if is_incr { val + 1 } else { val - 1 }; + idx = Some((i + 1, val)); + } + } + } + if let Some((i, val)) = idx { + kwd.update(i, &val.to_string(), &db)?; + self.client + .send_notice(target, kwd.format_entry(i).unwrap())?; + } else { + let val = if is_incr { 1 } else { -1 }; + let idx = kwd.learn(nick, &val.to_string(), &db)?; + self.client + .send_notice(target, kwd.format_entry(idx).unwrap())?; + } + Ok(()) + } + + pub fn handle_query( + &mut self, + target: &str, + nick: &str, + chan: &str, + query: Captures, + ) -> Result<(), Error> { + let db = self.pg.get()?; + let subj = &query["subj"]; + let idx = match query.name("idx") { + Some(i) => { + let i = i.as_str(); + match &i[1..(i.len() - 1)] { + "*" => Some(-1), + x => x.parse::<usize>().map(|x| x as i32).ok(), + } + } + None => None, + }; + debug!("Querying {} with idx {:?}", subj, idx); + match KeywordDetails::get(subj, chan, &db)? { + Some(kwd) => { + if let Some(mut idx) = idx { + if idx == -1 { + // 'get all entries' ('*' parses into this) + // step 1: make a blob of all the quotes + let mut data_to_upload = String::new(); + for i in 0..kwd.entries.len() { + data_to_upload + .push_str(&kwd.format_entry_colours(i + 1, false).unwrap()); + data_to_upload.push('\n'); + } + // step 2: attempt to POST it to eta's pastebin + // TODO(eta): make configurable + let response = crimp::Request::put("https://eta.st/lx/upload") + .user_agent("paroxysm/0.0.2 crimp/0.2")? + .header("Linx-Expiry", "7200")? // 2 hours + .body("text/plain", data_to_upload.as_bytes()) + .timeout(std::time::Duration::from_secs(2))? + .send()? + .as_string()?; + // step 3: tell the world about it + if response.status != 200 { + Err(format_err!( + "upload returned {}: {}", + response.status, + response.body + ))? + } + self.client.send_notice( + target, + format!( + "\x02{}\x0f: uploaded {} quotes to \x02\x0311{}\x0f (will expire in \x0224\x0f hours)", + subj, + kwd.entries.len(), + response.body + ) + )?; + } else { + if idx == 0 { + idx = 1; + } + if let Some(ent) = kwd.format_entry(idx as _) { + self.client.send_notice(target, ent)?; + } else { + let pluralised = if kwd.entries.len() == 1 { + "entry" + } else { + "entries" + }; + self.client.send_notice( + target, + format!( + "\x02{}\x0f: only has \x02\x0304{}\x0f {}", + subj, + kwd.entries.len(), + pluralised + ), + )?; + } + } + } else { + let entry = if kwd.entries.len() < 2 { + 1 // because [1, 1) does not a range make + } else { + self.rng.gen_range(1, kwd.entries.len()) + }; + if let Some(ent) = kwd.format_entry(entry) { + self.client.send_notice(target, ent)?; + } else { + self.client + .send_notice(target, format!("\x02{}\x0f: no entries yet", subj))?; + } + } + } + None => { + // If someone just posts "??????????", don't spam the channel with + // an error message (but do allow joke entries to appear if set). + if !subj.chars().all(|c| c == '?' || c == ' ') { + self.client + .send_notice(target, format!("\x02{}\x0f: never heard of it", subj))?; + } + } + } + Ok(()) + } + + pub fn handle_privmsg(&mut self, from: &str, chan: &str, msg: &str) -> Result<(), Error> { + let nick = from.split("!").next().ok_or(format_err!( + "Received PRIVMSG from a source without nickname (failed to split n!u@h)" + ))?; + let target = if chan.starts_with("#") { chan } else { nick }; + debug!("[{}] <{}> {}", chan, nick, msg); + if let Some(learn) = LEARN_RE.captures(msg) { + self.handle_learn(target, nick, chan, learn)?; + } else if let Some(qlast) = QLAST_RE.captures(msg) { + self.handle_insert_last_quote(target, nick, chan, qlast)?; + } else if let Some(mv) = MOVE_RE.captures(msg) { + self.handle_move(target, nick, chan, mv)?; + } else if let Some(icr) = INCREMENT_RE.captures(msg) { + self.handle_increment(target, nick, chan, icr)?; + } else if let Some(query) = QUERY_RE.captures(msg) { + self.handle_query(target, nick, chan, query)?; + } else { + let chan_lastmsgs = self + .last_msgs + .entry(chan.to_string()) + .or_insert(HashMap::new()); + chan_lastmsgs.insert(nick.to_string().to_ascii_lowercase(), msg.to_string()); + } + Ok(()) + } + + pub fn handle_msg(&mut self, m: Message) -> Result<(), Error> { + match m.command { + Command::PRIVMSG(channel, message) => { + if let Some(src) = m.prefix { + if let Err(e) = self.handle_privmsg(&src, &channel, &message) { + warn!("error handling command in {} (src {}): {}", channel, src, e); + if let Some(nick) = src.split("!").next() { + self.report_error(nick, &channel, e)?; + } + } + } + } + Command::INVITE(nick, channel) => { + if self.cfg.admins.contains(&nick) { + info!("Joining {} after admin invite", channel); + self.client.send_join(channel)?; + } + } + _ => {} + } + Ok(()) + } +} + +fn main() -> Result<(), Error> { + println!("[+] loading configuration"); + let default_log_filter = "paroxysm=info".to_string(); + let mut settings = config::Config::default(); + settings.merge(config::Environment::with_prefix("PARX"))?; + let cfg: Config = settings.try_into()?; + let env = env_logger::Env::new() + .default_filter_or(cfg.log_filter.clone().unwrap_or(default_log_filter)); + env_logger::init_from_env(env); + info!("paroxysm starting up"); + info!("connecting to database at {}", cfg.database_url); + let pg = Pool::new(ConnectionManager::new(&cfg.database_url))?; + info!("connecting to IRC using config {}", cfg.irc_config_path); + let client = IrcClient::new(&cfg.irc_config_path)?; + client.identify()?; + let st = client.stream(); + let mut app = App { + client, + pg, + cfg, + rng: thread_rng(), + last_msgs: HashMap::new(), + }; + info!("running!"); + st.for_each_incoming(|m| { + if let Err(e) = app.handle_msg(m) { + warn!("Error processing message: {}", e); + } + })?; + Ok(()) +} |