about summary refs log tree commit diff
path: root/fun/paroxysm/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'fun/paroxysm/src/main.rs')
-rw-r--r--fun/paroxysm/src/main.rs395
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(())
+}