diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | migrations/2018-09-19-231843_create_quotes/down.sql | 1 | ||||
| -rw-r--r-- | migrations/2018-09-19-231843_create_quotes/up.sql | 9 | ||||
| -rw-r--r-- | src/error.rs | 4 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/plugins/factoids/mod.rs | 2 | ||||
| -rw-r--r-- | src/plugins/help.rs | 2 | ||||
| -rw-r--r-- | src/plugins/mod.rs | 1 | ||||
| -rw-r--r-- | src/plugins/quote/database.rs | 136 | ||||
| -rw-r--r-- | src/plugins/quote/mod.rs | 262 |
12 files changed, 423 insertions, 2 deletions
@@ -416,6 +416,7 @@ dependencies = [ "log4rs 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)", "r2d2 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)", "r2d2-diesel 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rand 0.5.5 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "rlua 0.14.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -40,6 +40,7 @@ antidote = "1.0.0" log4rs = "0.8.0" frippy_derive = { path = "frippy_derive" } +rand = "0.5.5" # TODO Use crates.io again, as soon as OpenSSL 1.1.1 is supported [dependencies.irc] diff --git a/migrations/2018-09-19-231843_create_quotes/down.sql b/migrations/2018-09-19-231843_create_quotes/down.sql new file mode 100644 index 0000000..b9d0f8b --- /dev/null +++ b/migrations/2018-09-19-231843_create_quotes/down.sql @@ -0,0 +1 @@ +DROP TABLE quotes diff --git a/migrations/2018-09-19-231843_create_quotes/up.sql b/migrations/2018-09-19-231843_create_quotes/up.sql new file mode 100644 index 0000000..357a63a --- /dev/null +++ b/migrations/2018-09-19-231843_create_quotes/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE quotes ( + quotee VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL, + idx INTEGER NOT NULL, + content TEXT NOT NULL, + author VARCHAR(32) NOT NULL, + created TIMESTAMP NOT NULL, + PRIMARY KEY (quotee, channel, idx) +) diff --git a/src/error.rs b/src/error.rs index 251e812..70a7724 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,6 +33,10 @@ pub enum ErrorKind { #[fail(display = "A Factoids error has occured")] Factoids, + /// A Quote error + #[fail(display = "A Quote error has occured")] + Quote, + /// A Remind error #[fail(display = "A Remind error has occured")] Remind, @@ -54,6 +54,7 @@ extern crate regex; extern crate reqwest; extern crate serde_json; extern crate time; +extern crate rand; pub mod error; pub mod plugin; diff --git a/src/main.rs b/src/main.rs index 9aed069..c337a46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ use irc::client::reactor::IrcReactor; use frippy::plugins::emoji::Emoji; use frippy::plugins::factoids::Factoids; +use frippy::plugins::quote::Quote; use frippy::plugins::help::Help; use frippy::plugins::keepnick::KeepNick; use frippy::plugins::remind::Remind; @@ -122,12 +123,14 @@ fn run() -> Result<(), Error> { Ok(_) => { let pool = Arc::new(pool); bot.add_plugin(Factoids::new(pool.clone())); + bot.add_plugin(Quote::new(pool.clone())); bot.add_plugin(Tell::new(pool.clone())); bot.add_plugin(Remind::new(pool.clone())); info!("Connected to MySQL server") } Err(e) => { bot.add_plugin(Factoids::new(HashMap::new())); + bot.add_plugin(Quote::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); bot.add_plugin(Remind::new(HashMap::new())); error!("Failed to run migrations: {}", e); @@ -137,6 +140,7 @@ fn run() -> Result<(), Error> { } } else { bot.add_plugin(Factoids::new(HashMap::new())); + bot.add_plugin(Quote::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); bot.add_plugin(Remind::new(HashMap::new())); } @@ -147,6 +151,7 @@ fn run() -> Result<(), Error> { error!("frippy was not built with the mysql feature") } bot.add_plugin(Factoids::new(HashMap::new())); + bot.add_plugin(Quote::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); bot.add_plugin(Remind::new(HashMap::new())); } diff --git a/src/plugins/factoids/mod.rs b/src/plugins/factoids/mod.rs index 10f5131..a3d521a 100644 --- a/src/plugins/factoids/mod.rs +++ b/src/plugins/factoids/mod.rs @@ -228,7 +228,7 @@ impl<T: Database, C: Client> Factoids<T, C> { fn help(&self) -> &str { "usage: factoids <subcommand>\r\n\ - subcommands: add, fromurl, remove, info, get, exec, help" + subcommands: add, fromurl, remove, get, info, exec, help" } } diff --git a/src/plugins/help.rs b/src/plugins/help.rs index 5de2aca..1b50ca0 100644 --- a/src/plugins/help.rs +++ b/src/plugins/help.rs @@ -36,7 +36,7 @@ impl<C: FrippyClient> Plugin for Help<C> { client .send_notice( &command.source, - "Available commands: help, tell, factoids, remind\r\n\ + "Available commands: help, tell, factoids, remind, quote\r\n\ For more detailed help call help on the specific command.\r\n\ Example: 'remind help'", ) diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 05616f1..e3bda60 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -1,6 +1,7 @@ //! Collection of plugins included pub mod emoji; pub mod factoids; +pub mod quote; pub mod help; pub mod keepnick; pub mod remind; diff --git a/src/plugins/quote/database.rs b/src/plugins/quote/database.rs new file mode 100644 index 0000000..6ad08a0 --- /dev/null +++ b/src/plugins/quote/database.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; +#[cfg(feature = "mysql")] +use std::sync::Arc; + +#[cfg(feature = "mysql")] +use diesel::mysql::MysqlConnection; +#[cfg(feature = "mysql")] +use diesel::prelude::*; +#[cfg(feature = "mysql")] +use failure::ResultExt; +#[cfg(feature = "mysql")] +use r2d2::Pool; +#[cfg(feature = "mysql")] +use r2d2_diesel::ConnectionManager; + +use chrono::NaiveDateTime; + +use super::error::*; + +#[cfg_attr(feature = "mysql", derive(Queryable))] +#[derive(Clone, Debug)] +pub struct Quote { + pub quotee: String, + pub channel: String, + pub idx: i32, + pub content: String, + pub author: String, + pub created: NaiveDateTime, +} + +#[cfg_attr(feature = "mysql", derive(Insertable))] +#[cfg_attr(feature = "mysql", table_name = "quotes")] +pub struct NewQuote<'a> { + pub quotee: &'a str, + pub channel: &'a str, + pub idx: i32, + pub content: &'a str, + pub author: &'a str, + pub created: NaiveDateTime, +} + +pub trait Database: Send + Sync { + fn insert_quote(&mut self, quote: &NewQuote) -> Result<(), QuoteError>; + fn get_quote(&self, quotee: &str, channel: &str, idx: i32) -> Result<Quote, QuoteError>; + fn count_quotes(&self, quotee: &str, channel: &str) -> Result<i32, QuoteError>; +} + +// HashMap +impl<S: ::std::hash::BuildHasher + Send + Sync> Database for HashMap<(String, String, i32), Quote, S> { + fn insert_quote(&mut self, quote: &NewQuote) -> Result<(), QuoteError> { + let quote = Quote { + quotee: quote.quotee.to_owned(), + channel: quote.channel.to_owned(), + idx: quote.idx, + content: quote.content.to_owned(), + author: quote.author.to_owned(), + created: quote.created, + }; + + let quotee = quote.quotee.clone(); + let channel = quote.channel.clone(); + match self.insert((quotee, channel, quote.idx), quote) { + None => Ok(()), + Some(_) => Err(ErrorKind::Duplicate)?, + } + } + + fn get_quote(&self, quotee: &str, channel: &str, idx: i32) -> Result<Quote, QuoteError> { + Ok(self.get(&(quotee.to_owned(), channel.to_owned(), idx)) + .cloned() + .ok_or(ErrorKind::NotFound)?) + } + + fn count_quotes(&self, quotee: &str, channel: &str) -> Result<i32, QuoteError> { + Ok(self.iter().filter(|&(&(ref n, ref c, _), _)| n == quotee && c == channel).count() as i32) + } +} + +// Diesel automatically defines the quotes module as public. +// We create a schema module to keep it private. +#[cfg(feature = "mysql")] +mod schema { + table! { + quotes (quotee, channel, idx) { + quotee -> Varchar, + channel -> Varchar, + idx -> Integer, + content -> Text, + author -> Varchar, + created -> Timestamp, + } + } +} + +#[cfg(feature = "mysql")] +use self::schema::quotes; + +#[cfg(feature = "mysql")] +impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { + fn insert_quote(&mut self, quote: &NewQuote) -> Result<(), QuoteError> { + use diesel; + + let conn = &*self.get().context(ErrorKind::NoConnection)?; + diesel::insert_into(quotes::table) + .values(quote) + .execute(conn) + .context(ErrorKind::MysqlError)?; + + Ok(()) + } + + fn get_quote(&self, quotee: &str, channel: &str, idx: i32) -> Result<Quote, QuoteError> { + let conn = &*self.get().context(ErrorKind::NoConnection)?; + Ok(quotes::table + .find((quotee, channel, idx)) + .first(conn) + .context(ErrorKind::MysqlError)?) + } + + fn count_quotes(&self, quotee: &str, channel: &str) -> Result<i32, QuoteError> { + use diesel; + + let conn = &*self.get().context(ErrorKind::NoConnection)?; + let count: Result<i64, _> = quotes::table + .filter(quotes::columns::quotee.eq(quotee)) + .filter(quotes::columns::channel.eq(channel)) + .count() + .get_result(conn); + + match count { + Ok(c) => Ok(c as i32), + Err(diesel::NotFound) => Ok(0), + Err(e) => Err(e).context(ErrorKind::MysqlError)?, + } + } +} diff --git a/src/plugins/quote/mod.rs b/src/plugins/quote/mod.rs new file mode 100644 index 0000000..43333e7 --- /dev/null +++ b/src/plugins/quote/mod.rs @@ -0,0 +1,262 @@ +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use antidote::RwLock; +use irc::client::prelude::*; +use rand::{thread_rng, Rng}; +use chrono::NaiveDateTime; +use time; + +use plugin::*; +use FrippyClient; +pub mod database; +use self::database::Database; + +use self::error::*; +use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; +use failure::ResultExt; + +enum QuoteResponse { + Public(String), + Private(String), +} + +#[derive(PluginName)] +pub struct Quote<T: Database, C: Client> { + quotes: RwLock<T>, + phantom: PhantomData<C>, +} + +impl<T: Database, C: Client> Quote<T, C> { + pub fn new(db: T) -> Self { + Quote { + quotes: RwLock::new(db), + phantom: PhantomData, + } + } + + fn create_quote( + &self, + quotee: &str, + channel: &str, + content: &str, + author: &str, + ) -> Result<&str, QuoteError> { + let count = self.quotes.read().count_quotes(quotee, channel)?; + let tm = time::now().to_timespec(); + + let quote = database::NewQuote { + quotee, + channel, + idx: count + 1, + content, + author, + created: NaiveDateTime::from_timestamp(tm.sec, 0u32), + }; + + Ok(self.quotes + .write() + .insert_quote("e) + .map(|()| "Successfully added!")?) + } + + fn add(&self, command: &mut PluginCommand) -> Result<&str, QuoteError> { + if command.tokens.len() < 2 { + Err(ErrorKind::InvalidCommand)?; + } + + if command.target == command.source { + Err(ErrorKind::PrivateMessageNotAllowed)?; + } + + let quotee = command.tokens.remove(0); + let channel = &command.target; + let content = command.tokens.join(" "); + + Ok(self.create_quote("ee, channel, &content, &command.source)?) + } + + fn get(&self, command: &PluginCommand) -> Result<String, QuoteError> { + if command.tokens.is_empty() { + Err(ErrorKind::InvalidCommand)?; + } + + let quotee = &command.tokens[0]; + let channel = &command.target; + let count = self.quotes.read().count_quotes(quotee, channel)?; + + if count < 1 { + Err(ErrorKind::NotFound)?; + } + + let idx = match command.tokens.len() { + 1 => thread_rng().gen_range(1, count + 1), + _ => { + let idx = match i32::from_str(&command.tokens[1]) { + Ok(i) => i, + Err(_) => Err(ErrorKind::InvalidIndex)?, + }; + + if idx < 0 { + count + idx + 1 + } else { + idx + } + } + }; + + let quote = self.quotes + .read() + .get_quote(quotee, channel, idx) + .context(ErrorKind::NotFound)?; + + + Ok(format!("\"{}\" - {}[{}/{}]", quote.content, quote.quotee, idx, count)) + } + + fn info(&self, command: &PluginCommand) -> Result<String, QuoteError> { + match command.tokens.len() { + 0 => Err(ErrorKind::InvalidCommand)?, + 1 => { + let quotee = &command.tokens[0]; + let channel = &command.target; + let count = self.quotes.read().count_quotes(quotee, channel)?; + + Ok(match count { + 0 => Err(ErrorKind::NotFound)?, + 1 => format!("{} has 1 quote", quotee), + _ => format!("{} has {} quotes", quotee, count), + }) + } + _ => { + let quotee = &command.tokens[0]; + let channel = &command.target; + let idx = i32::from_str(&command.tokens[1]).context(ErrorKind::InvalidIndex)?; + let quote = self.quotes.read().get_quote(quotee, channel, idx)?; + + Ok(format!( + "{}'s quote was added by {} at {} UTC", + quotee, quote.author, quote.created + )) + } + } + } + + fn help(&self) -> &str { + "usage: quotes <subcommand>\r\n\ + subcommands: add, get, info, help" + } +} + +impl<T: Database, C: FrippyClient> Plugin for Quote<T, C> { + type Client = C; + fn execute(&self, _: &Self::Client, _: &Message) -> ExecutionStatus { + ExecutionStatus::Done + } + + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { + panic!("Quotes should not use threading") + } + + fn command( + &self, + client: &Self::Client, + mut command: PluginCommand, + ) -> Result<(), FrippyError> { + use self::QuoteResponse::{Private, Public}; + + if command.tokens.is_empty() { + client + .send_notice(&command.source, &ErrorKind::InvalidCommand.to_string()) + .context(FrippyErrorKind::Connection)?; + + return Ok(()); + } + + let target = command.target.clone(); + let source = command.source.clone(); + + let sub_command = command.tokens.remove(0); + let result = match sub_command.as_ref() { + "add" => self.add(&mut command).map(|s| Private(s.to_owned())), + "get" => self.get(&command).map(Public), + "info" => self.info(&command).map(Public), + "help" => Ok(Private(self.help().to_owned())), + _ => Err(ErrorKind::InvalidCommand.into()), + }; + + match result { + Ok(v) => match v { + Public(m) => client + .send_privmsg(&target, &m) + .context(FrippyErrorKind::Connection)?, + Private(m) => client + .send_notice(&source, &m) + .context(FrippyErrorKind::Connection)?, + }, + Err(e) => { + let message = e.to_string(); + client + .send_notice(&source, &message) + .context(FrippyErrorKind::Connection)?; + Err(e).context(FrippyErrorKind::Quote)? + } + } + + Ok(()) + } + + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { + Err(String::from( + "Evaluation of commands is not implemented for Quote at this time", + )) + } +} + +impl<T: Database, C: FrippyClient> fmt::Debug for Quote<T, C> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Quote {{ ... }}") + } +} + +pub mod error { + #[derive(Copy, Clone, Eq, PartialEq, Debug, Fail, Error)] + #[error = "QuoteError"] + pub enum ErrorKind { + /// Invalid command error + #[fail(display = "Incorrect command. Send \"quote help\" for help")] + InvalidCommand, + + /// Invalid index error + #[fail(display = "Invalid index")] + InvalidIndex, + + /// Private message error + #[fail(display = "You can only add quotes in channel messages")] + PrivateMessageNotAllowed, + + /// Download error + #[fail(display = "Download failed")] + Download, + + /// Duplicate error + #[fail(display = "Entry already exists")] + Duplicate, + + /// Not found error + #[fail(display = "Quote was not found")] + NotFound, + + /// MySQL error + #[cfg(feature = "mysql")] + #[fail(display = "Failed to execute MySQL Query")] + MysqlError, + + /// No connection error + #[cfg(feature = "mysql")] + #[fail(display = "No connection to the database")] + NoConnection, + } +} |
