e6e19f3d81
just `cargo update` Change-Id: Ia3fdc3660fc92d81dc4781adf2c200d961118dd5 Reviewed-on: https://cl.tvl.fyi/c/depot/+/5640 Autosubmit: tazjin <tazjin@tvl.su> Tested-by: BuildkiteCI Reviewed-by: sterni <sternenseemann@systemli.org>
395 lines
14 KiB
Rust
395 lines
14 KiB
Rust
// 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(())
|
|
}
|