diff options
Diffstat (limited to 'src')
25 files changed, 2400 insertions, 825 deletions
diff --git a/src/error.rs b/src/error.rs index 36d5724..039b71d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,8 +2,9 @@ use failure::Fail; -pub fn log_error(e: FrippyError) { - let text = e.causes() +pub fn log_error(e: &FrippyError) { + let text = e + .causes() .skip(1) .fold(format!("{}", e), |acc, err| format!("{}: {}", acc, err)); error!("{}", text); @@ -17,6 +18,10 @@ pub enum ErrorKind { #[fail(display = "A connection error occured")] Connection, + /// Thread spawn error + #[fail(display = "Failed to spawn thread")] + ThreadSpawn, + /// A Url error #[fail(display = "A Url error has occured")] Url, @@ -25,7 +30,15 @@ pub enum ErrorKind { #[fail(display = "A Tell error has occured")] Tell, - /// A Factoids error - #[fail(display = "A Factoids error has occured")] - Factoids, + /// A Factoid error + #[fail(display = "A Factoid error has occured")] + Factoid, + + /// A Quote error + #[fail(display = "A Quote error has occured")] + Quote, + + /// A Remind error + #[fail(display = "A Remind error has occured")] + Remind, } @@ -14,11 +14,10 @@ //! //! let config = Config::load("config.toml").unwrap(); //! let mut reactor = IrcReactor::new().unwrap(); -//! let mut bot = Bot::new(); +//! let mut bot = Bot::new("."); //! //! bot.add_plugin(plugins::help::Help::new()); -//! bot.add_plugin(plugins::emoji::Emoji::new()); -//! bot.add_plugin(plugins::currency::Currency::new()); +//! bot.add_plugin(plugins::unicode::Unicode::new()); //! //! bot.connect(&mut reactor, &config).unwrap(); //! reactor.run().unwrap(); @@ -46,49 +45,72 @@ extern crate lazy_static; #[macro_use] extern crate log; +extern crate antidote; extern crate chrono; +extern crate circular_queue; extern crate humantime; extern crate irc; +extern crate rand; +extern crate regex; extern crate reqwest; +extern crate serde_json; extern crate time; +pub mod error; pub mod plugin; pub mod plugins; pub mod utils; -pub mod error; + +use plugin::*; + +use error::*; +use failure::ResultExt; + +pub use irc::client::data::Config; +use irc::client::ext::ClientExt; +use irc::client::reactor::IrcReactor; +use irc::client::{Client, IrcClient}; +use irc::error::IrcError; +use irc::proto::{command::Command, Message}; use std::collections::HashMap; use std::fmt; -use std::thread::spawn; use std::sync::Arc; +use std::thread; -pub use irc::client::prelude::*; -pub use irc::error::IrcError; -use error::*; -use failure::ResultExt; +pub trait FrippyClient: Client + Send + Sync + Clone + fmt::Debug { + fn current_nickname(&self) -> &str; +} -use plugin::*; +impl FrippyClient for IrcClient { + fn current_nickname(&self) -> &str { + self.current_nickname() + } +} /// The bot which contains the main logic. -#[derive(Default)] -pub struct Bot { - plugins: ThreadedPlugins, +pub struct Bot<'a> { + prefix: &'a str, + plugins: ThreadedPlugins<IrcClient>, } -impl Bot { - /// Creates a `Bot`. +impl<'a> Bot<'a> { + /// Creates a `Bot` without any plugins. /// By itself the bot only responds to a few simple CTCP commands /// defined per config file. /// Any other functionality has to be provided by plugins /// which need to implement [`Plugin`](plugin/trait.Plugin.html). + /// To send commands to a plugin + /// the message has to start with the plugin's name prefixed by `cmd_prefix`. /// /// # Examples /// ``` /// use frippy::Bot; - /// let mut bot = Bot::new(); + /// let mut bot = Bot::new("."); /// ``` - pub fn new() -> Bot { + pub fn new(cmd_prefix: &'a str) -> Self { Bot { + prefix: cmd_prefix, plugins: ThreadedPlugins::new(), } } @@ -100,10 +122,13 @@ impl Bot { /// ``` /// use frippy::{plugins, Bot}; /// - /// let mut bot = frippy::Bot::new(); + /// let mut bot = frippy::Bot::new("."); /// bot.add_plugin(plugins::help::Help::new()); /// ``` - pub fn add_plugin<T: Plugin + 'static>(&mut self, plugin: T) { + pub fn add_plugin<T>(&mut self, plugin: T) + where + T: Plugin<Client = IrcClient> + 'static, + { self.plugins.add(plugin); } @@ -115,7 +140,7 @@ impl Bot { /// ``` /// use frippy::{plugins, Bot}; /// - /// let mut bot = frippy::Bot::new(); + /// let mut bot = frippy::Bot::new("."); /// bot.add_plugin(plugins::help::Help::new()); /// bot.remove_plugin("Help"); /// ``` @@ -142,7 +167,7 @@ impl Bot { /// /// let config = Config::load("config.toml").unwrap(); /// let mut reactor = IrcReactor::new().unwrap(); - /// let mut bot = Bot::new(); + /// let mut bot = Bot::new("."); /// /// bot.connect(&mut reactor, &config).unwrap(); /// reactor.run().unwrap(); @@ -160,22 +185,27 @@ impl Bot { client.identify().context(ErrorKind::Connection)?; info!("Identified"); - // TODO Verify if we actually need to clone plugins twice + // TODO Verify if we actually need to clone twice let plugins = self.plugins.clone(); + let prefix = self.prefix.to_owned(); reactor.register_client_with_handler(client, move |client, message| { - process_msg(client, plugins.clone(), message) + process_msg(client, plugins.clone(), &prefix.clone(), message) }); Ok(()) } } -fn process_msg( - client: &IrcClient, - mut plugins: ThreadedPlugins, +fn process_msg<C>( + client: &C, + mut plugins: ThreadedPlugins<C>, + prefix: &str, message: Message, -) -> Result<(), IrcError> { +) -> Result<(), IrcError> +where + C: FrippyClient + 'static, +{ // Log any channels we join if let Command::JOIN(ref channel, _, _) = message.command { if message.source_nickname().unwrap() == client.current_nickname() { @@ -184,7 +214,7 @@ fn process_msg( } // Check for possible command and save the result for later - let command = PluginCommand::from(&client.current_nickname().to_lowercase(), &message); + let command = PluginCommand::try_from(prefix, &message); plugins.execute_plugins(client, message); @@ -198,19 +228,22 @@ fn process_msg( Ok(()) } -#[derive(Clone, Default, Debug)] -struct ThreadedPlugins { - plugins: HashMap<String, Arc<Plugin>>, +#[derive(Clone, Debug)] +struct ThreadedPlugins<C: FrippyClient> { + plugins: HashMap<String, Arc<Plugin<Client = C>>>, } -impl ThreadedPlugins { - pub fn new() -> ThreadedPlugins { +impl<C: FrippyClient + 'static> ThreadedPlugins<C> { + pub fn new() -> Self { ThreadedPlugins { plugins: HashMap::new(), } } - pub fn add<T: Plugin + 'static>(&mut self, plugin: T) { + pub fn add<T>(&mut self, plugin: T) + where + T: Plugin<Client = C> + 'static, + { let name = plugin.name().to_lowercase(); let safe_plugin = Arc::new(plugin); @@ -221,14 +254,16 @@ impl ThreadedPlugins { self.plugins.remove(&name.to_lowercase()).map(|_| ()) } - pub fn execute_plugins(&mut self, client: &IrcClient, message: Message) { + /// Runs the execute functions on all plugins. + /// Any errors that occur are printed right away. + pub fn execute_plugins(&mut self, client: &C, message: Message) { let message = Arc::new(message); for (name, plugin) in self.plugins.clone() { // Send the message to the plugin if the plugin needs it match plugin.execute(client, &message) { ExecutionStatus::Done => (), - ExecutionStatus::Err(e) => log_error(e), + ExecutionStatus::Err(e) => log_error(&e), ExecutionStatus::RequiresThread => { debug!( "Spawning thread to execute {} with {}", @@ -242,11 +277,19 @@ impl ThreadedPlugins { let client = client.clone(); // Execute the plugin in another thread - spawn(move || { - if let Err(e) = plugin.execute_threaded(&client, &message) { - log_error(e); - }; - }); + if let Err(e) = thread::Builder::new() + .name(name) + .spawn(move || { + if let Err(e) = plugin.execute_threaded(&client, &message) { + log_error(&e); + } else { + debug!("{} sent response from thread", plugin.name()); + } + }) + .context(ErrorKind::ThreadSpawn) + { + log_error(&e.into()); + } } } } @@ -254,17 +297,10 @@ impl ThreadedPlugins { pub fn handle_command( &mut self, - client: &IrcClient, + client: &C, mut command: PluginCommand, ) -> Result<(), FrippyError> { - if !command.tokens.iter().any(|s| !s.is_empty()) { - let help = format!("Use \"{} help\" to get help", client.current_nickname()); - client - .send_notice(&command.source, &help) - .context(ErrorKind::Connection)?; - } - - // Check if the command is for this plugin + // Check if there is a plugin for this command if let Some(plugin) = self.plugins.get(&command.tokens[0].to_lowercase()) { // The first token contains the name of the plugin let name = command.tokens.remove(0); @@ -274,31 +310,24 @@ impl ThreadedPlugins { // Clone for the move - the client uses an Arc internally let client = client.clone(); let plugin = Arc::clone(plugin); - spawn(move || { - if let Err(e) = plugin.command(&client, command) { - log_error(e); - }; - }); - - Ok(()) - } else { - let help = format!( - "\"{} {}\" is not a command, \ - try \"{0} help\" instead.", - client.current_nickname(), - command.tokens[0] - ); - - Ok(client - .send_notice(&command.source, &help) - .context(ErrorKind::Connection)?) + thread::Builder::new() + .name(name) + .spawn(move || { + if let Err(e) = plugin.command(&client, command) { + log_error(&e); + }; + }) + .context(ErrorKind::ThreadSpawn)?; } + + Ok(()) } } -impl fmt::Display for ThreadedPlugins { +impl<C: FrippyClient> fmt::Display for ThreadedPlugins<C> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let plugin_names = self.plugins + let plugin_names = self + .plugins .iter() .map(|(_, p)| p.name().to_owned()) .collect::<Vec<String>>(); diff --git a/src/main.rs b/src/main.rs index b9a4b8f..ef24e4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ extern crate frippy; extern crate glob; extern crate irc; +extern crate log4rs; extern crate time; #[cfg(feature = "mysql")] @@ -21,80 +22,50 @@ extern crate failure; #[macro_use] extern crate log; +use std::collections::HashMap; #[cfg(feature = "mysql")] use std::sync::Arc; -use std::collections::HashMap; -use log::{Level, LevelFilter, Metadata, Record}; -use irc::client::reactor::IrcReactor; use glob::glob; +use irc::client::reactor::IrcReactor; -pub use frippy::plugins::help::Help; -pub use frippy::plugins::url::Url; -pub use frippy::plugins::emoji::Emoji; -pub use frippy::plugins::tell::Tell; -pub use frippy::plugins::currency::Currency; -pub use frippy::plugins::keepnick::KeepNick; -pub use frippy::plugins::factoids::Factoids; +use frippy::plugins::unicode::Unicode; +use frippy::plugins::factoid::Factoid; +use frippy::plugins::help::Help; +use frippy::plugins::keepnick::KeepNick; +use frippy::plugins::quote::Quote; +use frippy::plugins::remind::Remind; +use frippy::plugins::sed::Sed; +use frippy::plugins::tell::Tell; +use frippy::plugins::url::UrlTitles; -use frippy::Config; use failure::Error; +use frippy::Config; #[cfg(feature = "mysql")] embed_migrations!(); -struct Logger; - -impl log::Log for Logger { - fn enabled(&self, metadata: &Metadata) -> bool { - metadata.target().contains("frippy") - } - - fn log(&self, record: &Record) { - if self.enabled(record.metadata()) { - if record.metadata().level() >= Level::Debug { - println!( - "[{}]({}) {} -> {}", - time::now().rfc822(), - record.level(), - record.target(), - record.args() - ); - } else { - println!( - "[{}]({}) {}", - time::now().rfc822(), - record.level(), - record.args() - ); - } +fn main() { + if let Err(e) = log4rs::init_file("log.yml", Default::default()) { + use log4rs::Error; + match e { + Error::Log(e) => eprintln!("Log4rs error: {}", e), + Error::Log4rs(e) => eprintln!("Failed to parse \"log.yml\" as log4rs config: {}", e), } - } - fn flush(&self) {} -} - -static LOGGER: Logger = Logger; + return; + } -fn main() { // Print any errors that caused frippy to shut down if let Err(e) = run() { - let text = e.causes() - .skip(1) + let text = e + .iter_causes() .fold(format!("{}", e), |acc, err| format!("{}: {}", acc, err)); error!("{}", text); - }; + } } fn run() -> Result<(), Error> { - log::set_max_level(if cfg!(debug_assertions) { - LevelFilter::Debug - } else { - LevelFilter::Info - }); - - log::set_logger(&LOGGER).unwrap(); - // Load all toml files in the configs directory let mut configs = Vec::new(); for toml in glob("configs/*.toml").unwrap() { @@ -120,21 +91,24 @@ fn run() -> Result<(), Error> { // Open a connection and add work for each config for config in configs { + let mut prefix = None; let mut disabled_plugins = None; let mut mysql_url = None; if let Some(ref options) = config.options { if let Some(disabled) = options.get("disabled_plugins") { disabled_plugins = Some(disabled.split(',').map(|p| p.trim()).collect::<Vec<_>>()); } + prefix = options.get("prefix"); mysql_url = options.get("mysql_url"); } + let prefix = prefix.cloned().unwrap_or_else(|| String::from(".")); - let mut bot = frippy::Bot::new(); + let mut bot = frippy::Bot::new(&prefix); bot.add_plugin(Help::new()); - bot.add_plugin(Url::new(1024)); - bot.add_plugin(Emoji::new()); - bot.add_plugin(Currency::new()); + bot.add_plugin(UrlTitles::new(1024)); + bot.add_plugin(Sed::new(60)); + bot.add_plugin(Unicode::new()); bot.add_plugin(KeepNick::new()); #[cfg(feature = "mysql")] @@ -149,21 +123,27 @@ fn run() -> Result<(), Error> { Ok(pool) => match embedded_migrations::run(&*pool.get()?) { Ok(_) => { let pool = Arc::new(pool); - bot.add_plugin(Factoids::new(pool.clone())); + bot.add_plugin(Factoid::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(Factoid::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); } }, Err(e) => error!("Failed to connect to database: {}", e), } } else { - bot.add_plugin(Factoids::new(HashMap::new())); + bot.add_plugin(Factoid::new(HashMap::new())); + bot.add_plugin(Quote::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); + bot.add_plugin(Remind::new(HashMap::new())); } } #[cfg(not(feature = "mysql"))] @@ -171,8 +151,10 @@ fn run() -> Result<(), Error> { if mysql_url.is_some() { error!("frippy was not built with the mysql feature") } - bot.add_plugin(Factoids::new(HashMap::new())); + bot.add_plugin(Factoid::new(HashMap::new())); + bot.add_plugin(Quote::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); + bot.add_plugin(Remind::new(HashMap::new())); } if let Some(disabled_plugins) = disabled_plugins { @@ -187,5 +169,7 @@ fn run() -> Result<(), Error> { } // Run the bots until they throw an error - an error could be loss of connection - Ok(reactor.run()?) + reactor.run()?; + + Ok(()) } diff --git a/src/plugin.rs b/src/plugin.rs index bc428d5..65bfe1f 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -1,8 +1,8 @@ //! Definitions required for every `Plugin` use std::fmt; -use irc::client::prelude::*; use error::FrippyError; +use irc::client::prelude::*; /// Describes if a [`Plugin`](trait.Plugin.html) is done working on a /// [`Message`](../../irc/proto/message/struct.Message.html) or if another thread is required. @@ -20,17 +20,19 @@ pub enum ExecutionStatus { /// `Plugin` has to be implemented for any struct that should be usable /// as a `Plugin` in frippy. pub trait Plugin: PluginName + Send + Sync + fmt::Debug { + type Client; /// Handles messages which are not commands or returns /// [`RequiresThread`](enum.ExecutionStatus.html#variant.RequiresThread) /// if [`execute_threaded()`](trait.Plugin.html#tymethod.execute_threaded) should be used instead. - fn execute(&self, client: &IrcClient, message: &Message) -> ExecutionStatus; + fn execute(&self, client: &Self::Client, message: &Message) -> ExecutionStatus; /// Handles messages which are not commands in a new thread. - fn execute_threaded(&self, client: &IrcClient, message: &Message) -> Result<(), FrippyError>; + fn execute_threaded(&self, client: &Self::Client, message: &Message) + -> Result<(), FrippyError>; /// Handles any command directed at this plugin. - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError>; + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError>; /// Similar to [`command()`](trait.Plugin.html#tymethod.command) but return a String instead of /// sending messages directly to IRC. - fn evaluate(&self, client: &IrcClient, command: PluginCommand) -> Result<String, String>; + fn evaluate(&self, client: &Self::Client, command: PluginCommand) -> Result<String, String>; } /// `PluginName` is required by [`Plugin`](trait.Plugin.html). @@ -66,35 +68,24 @@ impl PluginCommand { /// Creates a `PluginCommand` from [`Message`](../../irc/proto/message/struct.Message.html) /// if it contains a [`PRIVMSG`](../../irc/proto/command/enum.Command.html#variant.PRIVMSG) /// that starts with the provided `nick`. - pub fn from(nick: &str, message: &Message) -> Option<PluginCommand> { + pub fn try_from(prefix: &str, message: &Message) -> Option<PluginCommand> { // Get the actual message out of PRIVMSG if let Command::PRIVMSG(_, ref content) = message.command { - // Split content by spaces and filter empty tokens + // Split content by spaces let mut tokens: Vec<String> = content.split(' ').map(ToOwned::to_owned).collect(); - // Commands start with our name - if tokens[0].to_lowercase().starts_with(nick) { - // Remove the bot's name from the first token - tokens[0].drain(..nick.len()); - - // We assume that only ':' and ',' are used as suffixes on IRC - // If there are any other chars we assume that it is not ment for the bot - tokens[0] = tokens[0].chars().filter(|&c| !":,".contains(c)).collect(); - if !tokens[0].is_empty() { - return None; - } - - // The first token contained the name of the bot - tokens.remove(0); - - Some(PluginCommand { - source: message.source_nickname().unwrap().to_string(), - target: message.response_target().unwrap().to_string(), - tokens: tokens, - }) - } else { - None + // Commands start with a prefix + if !tokens[0].to_lowercase().starts_with(prefix) { + return None; } + // Remove the prefix from the first token + tokens[0].drain(..prefix.len()); + + Some(PluginCommand { + source: message.source_nickname().unwrap().to_string(), + target: message.response_target().unwrap().to_string(), + tokens, + }) } else { None } diff --git a/src/plugins/currency.rs b/src/plugins/currency.rs deleted file mode 100644 index 53a245c..0000000 --- a/src/plugins/currency.rs +++ /dev/null @@ -1,166 +0,0 @@ -extern crate reqwest; -extern crate serde; -extern crate serde_json; - -use std::io::Read; -use std::num::ParseFloatError; - -use irc::client::prelude::*; - -use self::reqwest::Client; -use self::reqwest::header::Connection; -use self::serde_json::Value; - -use plugin::*; - -use error::FrippyError; -use error::ErrorKind as FrippyErrorKind; -use failure::ResultExt; - -#[derive(PluginName, Default, Debug)] -pub struct Currency; - -struct ConvertionRequest<'a> { - value: f64, - source: &'a str, - target: &'a str, -} - -impl<'a> ConvertionRequest<'a> { - fn send(&self) -> Option<f64> { - let response = Client::new() - .get("https://api.fixer.io/latest") - .form(&[("base", self.source)]) - .header(Connection::close()) - .send(); - - match response { - Ok(mut response) => { - let mut body = String::new(); - response.read_to_string(&mut body).ok()?; - - let convertion_rates: Result<Value, _> = serde_json::from_str(&body); - match convertion_rates { - Ok(convertion_rates) => { - let rates: &Value = convertion_rates.get("rates")?; - let target_rate: &Value = rates.get(self.target.to_uppercase())?; - Some(self.value * target_rate.as_f64()?) - } - Err(_) => None, - } - } - Err(_) => None, - } - } -} - -impl Currency { - pub fn new() -> Currency { - Currency {} - } - - fn eval_command<'a>( - &self, - tokens: &'a [String], - ) -> Result<ConvertionRequest<'a>, ParseFloatError> { - Ok(ConvertionRequest { - value: tokens[0].parse()?, - source: &tokens[1], - target: &tokens[2], - }) - } - - fn convert(&self, client: &IrcClient, command: &mut PluginCommand) -> Result<String, String> { - if command.tokens.len() < 3 { - return Err(self.invalid_command(client)); - } - - let request = match self.eval_command(&command.tokens) { - Ok(request) => request, - Err(_) => { - return Err(self.invalid_command(client)); - } - }; - - match request.send() { - Some(response) => { - let response = format!( - "{} {} => {:.4} {}", - request.value, - request.source.to_lowercase(), - response / 1.00000000, - request.target.to_lowercase() - ); - - Ok(response) - } - None => Err(String::from( - "An error occured during the conversion of the given currency", - )), - } - } - - fn help(&self, client: &IrcClient) -> String { - format!( - "usage: {} currency value from_currency to_currency\r\n\ - example: {0} currency 1.5 eur usd\r\n\ - available currencies: AUD, BGN, BRL, CAD, \ - CHF, CNY, CZK, DKK, GBP, HKD, HRK, HUF, \ - IDR, ILS, INR, JPY, KRW, MXN, MYR, NOK, \ - NZD, PHP, PLN, RON, RUB, SEK, SGD, THB, \ - TRY, USD, ZAR", - client.current_nickname() - ) - } - - fn invalid_command(&self, client: &IrcClient) -> String { - format!( - "Incorrect Command. \ - Send \"{} currency help\" for help.", - client.current_nickname() - ) - } -} - -impl Plugin for Currency { - fn execute(&self, _: &IrcClient, _: &Message) -> ExecutionStatus { - ExecutionStatus::Done - } - - fn execute_threaded(&self, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { - panic!("Currency does not implement the execute function!") - } - - fn command(&self, client: &IrcClient, mut command: PluginCommand) -> Result<(), FrippyError> { - if command.tokens.is_empty() { - return Ok(client - .send_notice(&command.source, &self.invalid_command(client)) - .context(FrippyErrorKind::Connection)?); - } - - match command.tokens[0].as_ref() { - "help" => Ok(client - .send_notice(&command.source, &self.help(client)) - .context(FrippyErrorKind::Connection)?), - _ => match self.convert(client, &mut command) { - Ok(msg) => Ok(client - .send_privmsg(&command.target, &msg) - .context(FrippyErrorKind::Connection)?), - Err(msg) => Ok(client - .send_notice(&command.source, &msg) - .context(FrippyErrorKind::Connection)?), - }, - } - } - - fn evaluate(&self, client: &IrcClient, mut command: PluginCommand) -> Result<String, String> { - if command.tokens.is_empty() { - return Err(self.invalid_command(client)); - } - - match command.tokens[0].as_ref() { - "help" => Ok(self.help(client)), - _ => self.convert(client, &mut command), - } - } -} diff --git a/src/plugins/emoji.rs b/src/plugins/emoji.rs deleted file mode 100644 index f1d9376..0000000 --- a/src/plugins/emoji.rs +++ /dev/null @@ -1,134 +0,0 @@ -extern crate unicode_names; - -use std::fmt; - -use irc::client::prelude::*; - -use plugin::*; - -use error::FrippyError; -use error::ErrorKind as FrippyErrorKind; -use failure::Fail; -use failure::ResultExt; - -struct EmojiHandle { - symbol: char, - count: i32, -} - -impl fmt::Display for EmojiHandle { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let name = match unicode_names::name(self.symbol) { - Some(sym) => sym.to_string().to_lowercase(), - None => String::from("UNKNOWN"), - }; - - if self.count > 1 { - write!(f, "{}x {}", self.count, name) - } else { - write!(f, "{}", name) - } - } -} - -#[derive(PluginName, Default, Debug)] -pub struct Emoji; - -impl Emoji { - pub fn new() -> Emoji { - Emoji {} - } - - fn emoji(&self, content: &str) -> String { - self.return_emojis(content) - .iter() - .map(|e| e.to_string()) - .collect::<Vec<String>>() - .join(", ") - } - - fn return_emojis(&self, string: &str) -> Vec<EmojiHandle> { - let mut emojis: Vec<EmojiHandle> = Vec::new(); - - let mut current = EmojiHandle { - symbol: ' ', - count: 0, - }; - - for c in string.chars() { - if !self.is_emoji(&c) { - continue; - } - - if current.symbol == c { - current.count += 1; - } else { - if current.count > 0 { - emojis.push(current); - } - - current = EmojiHandle { - symbol: c, - count: 1, - } - } - } - - if current.count > 0 { - emojis.push(current); - } - - emojis - } - - fn is_emoji(&self, c: &char) -> bool { - // Emoji ranges from stackoverflow: - // https://stackoverflow.com/questions/30757193/find-out-if-character-in-string-is-emoji - match *c { '\u{1F600}'...'\u{1F64F}' // Emoticons - | '\u{1F300}'...'\u{1F5FF}' // Misc Symbols and Pictographs - | '\u{1F680}'...'\u{1F6FF}' // Transport and Map - | '\u{2600}' ...'\u{26FF}' // Misc symbols - | '\u{2700}' ...'\u{27BF}' // Dingbats - | '\u{FE00}' ...'\u{FE0F}' // Variation Selectors - | '\u{1F900}'...'\u{1F9FF}' // Supplemental Symbols and Pictographs - | '\u{20D0}' ...'\u{20FF}' => true, // Combining Diacritical Marks for Symbols - _ => false, - } - } -} - -impl Plugin for Emoji { - fn execute(&self, client: &IrcClient, message: &Message) -> ExecutionStatus { - match message.command { - Command::PRIVMSG(_, ref content) => match client - .send_privmsg(message.response_target().unwrap(), &self.emoji(content)) - { - Ok(_) => ExecutionStatus::Done, - Err(e) => ExecutionStatus::Err(e.context(FrippyErrorKind::Connection).into()), - }, - _ => ExecutionStatus::Done, - } - } - - fn execute_threaded(&self, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { - panic!("Emoji should not use threading") - } - - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { - Ok(client - .send_notice( - &command.source, - "This Plugin does not implement any commands.", - ) - .context(FrippyErrorKind::Connection)?) - } - - fn evaluate(&self, _: &IrcClient, command: PluginCommand) -> Result<String, String> { - let emojis = self.emoji(&command.tokens[0]); - if emojis.is_empty() { - Ok(emojis) - } else { - Err(String::from("No emojis were found.")) - } - } -} diff --git a/src/plugins/factoids/database.rs b/src/plugins/factoid/database.rs index b1fe8dd..5e7e24c 100644 --- a/src/plugins/factoids/database.rs +++ b/src/plugins/factoid/database.rs @@ -1,20 +1,17 @@ -#[cfg(feature = "mysql")] -extern crate dotenv; - +use std::collections::HashMap; #[cfg(feature = "mysql")] use std::sync::Arc; -use std::collections::HashMap; #[cfg(feature = "mysql")] +use diesel::mysql::MysqlConnection; +#[cfg(feature = "mysql")] use diesel::prelude::*; #[cfg(feature = "mysql")] -use diesel::mysql::MysqlConnection; +use failure::ResultExt; #[cfg(feature = "mysql")] use r2d2::Pool; #[cfg(feature = "mysql")] use r2d2_diesel::ConnectionManager; -#[cfg(feature = "mysql")] -use failure::ResultExt; use chrono::NaiveDateTime; @@ -40,21 +37,21 @@ pub struct NewFactoid<'a> { pub created: NaiveDateTime, } -pub trait Database: Send { - fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidsError>; - fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidsError>; - fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidsError>; - fn count_factoids(&self, name: &str) -> Result<i32, FactoidsError>; +pub trait Database: Send + Sync { + fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidError>; + fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidError>; + fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidError>; + fn count_factoids(&self, name: &str) -> Result<i32, FactoidError>; } // HashMap -impl Database for HashMap<(String, i32), Factoid> { - fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidsError> { +impl<S: ::std::hash::BuildHasher + Send + Sync> Database for HashMap<(String, i32), Factoid, S> { + fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidError> { let factoid = Factoid { - name: String::from(factoid.name), + name: factoid.name.to_owned(), idx: factoid.idx, - content: factoid.content.to_string(), - author: factoid.author.to_string(), + content: factoid.content.to_owned(), + author: factoid.author.to_owned(), created: factoid.created, }; @@ -65,20 +62,21 @@ impl Database for HashMap<(String, i32), Factoid> { } } - fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidsError> { - Ok(self.get(&(String::from(name), idx)) + fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidError> { + Ok(self + .get(&(name.to_owned(), idx)) .cloned() .ok_or(ErrorKind::NotFound)?) } - fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidsError> { - match self.remove(&(String::from(name), idx)) { + fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidError> { + match self.remove(&(name.to_owned(), idx)) { Some(_) => Ok(()), None => Err(ErrorKind::NotFound)?, } } - fn count_factoids(&self, name: &str) -> Result<i32, FactoidsError> { + fn count_factoids(&self, name: &str) -> Result<i32, FactoidError> { Ok(self.iter().filter(|&(&(ref n, _), _)| n == name).count() as i32) } } @@ -103,7 +101,7 @@ use self::schema::factoids; #[cfg(feature = "mysql")] impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { - fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidsError> { + fn insert_factoid(&mut self, factoid: &NewFactoid) -> Result<(), FactoidError> { use diesel; let conn = &*self.get().context(ErrorKind::NoConnection)?; @@ -115,7 +113,7 @@ impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { Ok(()) } - fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidsError> { + fn get_factoid(&self, name: &str, idx: i32) -> Result<Factoid, FactoidError> { let conn = &*self.get().context(ErrorKind::NoConnection)?; Ok(factoids::table .find((name, idx)) @@ -123,9 +121,9 @@ impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { .context(ErrorKind::MysqlError)?) } - fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidsError> { - use diesel; + fn delete_factoid(&mut self, name: &str, idx: i32) -> Result<(), FactoidError> { use self::factoids::columns; + use diesel; let conn = &*self.get().context(ErrorKind::NoConnection)?; match diesel::delete( @@ -145,7 +143,7 @@ impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { } } - fn count_factoids(&self, name: &str) -> Result<i32, FactoidsError> { + fn count_factoids(&self, name: &str) -> Result<i32, FactoidError> { use diesel; let conn = &*self.get().context(ErrorKind::NoConnection)?; diff --git a/src/plugins/factoids/mod.rs b/src/plugins/factoid/mod.rs index 2f3690f..4fcc7a0 100644 --- a/src/plugins/factoids/mod.rs +++ b/src/plugins/factoid/mod.rs @@ -1,46 +1,47 @@ extern crate rlua; -use std::fmt; -use std::str::FromStr; -use std::sync::Mutex; use self::rlua::prelude::*; +use antidote::RwLock; use irc::client::prelude::*; +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; -use time; use chrono::NaiveDateTime; +use time; use plugin::*; +use FrippyClient; pub mod database; use self::database::Database; mod utils; use self::utils::*; +use utils::Url; -use failure::ResultExt; +use self::error::*; use error::ErrorKind as FrippyErrorKind; use error::FrippyError; -use self::error::*; +use failure::ResultExt; static LUA_SANDBOX: &'static str = include_str!("sandbox.lua"); -#[derive(PluginName)] -pub struct Factoids<T: Database> { - factoids: Mutex<T>, +enum FactoidResponse { + Public(String), + Private(String), } -macro_rules! try_lock { - ( $m:expr ) => { - match $m.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } - } +#[derive(PluginName)] +pub struct Factoid<T: Database, C: Client> { + factoids: RwLock<T>, + phantom: PhantomData<C>, } -impl<T: Database> Factoids<T> { - pub fn new(db: T) -> Factoids<T> { - Factoids { - factoids: Mutex::new(db), +impl<T: Database, C: Client> Factoid<T, C> { + pub fn new(db: T) -> Self { + Factoid { + factoids: RwLock::new(db), + phantom: PhantomData, } } @@ -49,24 +50,26 @@ impl<T: Database> Factoids<T> { name: &str, content: &str, author: &str, - ) -> Result<&str, FactoidsError> { - let count = try_lock!(self.factoids).count_factoids(name)?; + ) -> Result<&str, FactoidError> { + let count = self.factoids.read().count_factoids(name)?; let tm = time::now().to_timespec(); let factoid = database::NewFactoid { - name: name, + name, idx: count, - content: content, - author: author, + content, + author, created: NaiveDateTime::from_timestamp(tm.sec, 0u32), }; - Ok(try_lock!(self.factoids) + Ok(self + .factoids + .write() .insert_factoid(&factoid) .map(|()| "Successfully added!")?) } - fn add(&self, command: &mut PluginCommand) -> Result<&str, FactoidsError> { + fn add(&self, command: &mut PluginCommand) -> Result<&str, FactoidError> { if command.tokens.len() < 2 { Err(ErrorKind::InvalidCommand)?; } @@ -77,38 +80,41 @@ impl<T: Database> Factoids<T> { Ok(self.create_factoid(&name, &content, &command.source)?) } - fn add_from_url(&self, command: &mut PluginCommand) -> Result<&str, FactoidsError> { + fn add_from_url(&self, command: &mut PluginCommand) -> Result<&str, FactoidError> { if command.tokens.len() < 2 { Err(ErrorKind::InvalidCommand)?; } let name = command.tokens.remove(0); let url = &command.tokens[0]; - let content = ::utils::download(url, Some(1024)).context(ErrorKind::Download)?; + let content = Url::from(url.as_ref()) + .max_kib(1024) + .request() + .context(ErrorKind::Download)?; Ok(self.create_factoid(&name, &content, &command.source)?) } - fn remove(&self, command: &mut PluginCommand) -> Result<&str, FactoidsError> { - if command.tokens.len() < 1 { + fn remove(&self, command: &mut PluginCommand) -> Result<&str, FactoidError> { + if command.tokens.is_empty() { Err(ErrorKind::InvalidCommand)?; } let name = command.tokens.remove(0); - let count = try_lock!(self.factoids).count_factoids(&name)?; + let count = self.factoids.read().count_factoids(&name)?; - match try_lock!(self.factoids).delete_factoid(&name, count - 1) { + match self.factoids.write().delete_factoid(&name, count - 1) { Ok(()) => Ok("Successfully removed"), Err(e) => Err(e)?, } } - fn get(&self, command: &PluginCommand) -> Result<String, FactoidsError> { + fn get(&self, command: &PluginCommand) -> Result<String, FactoidError> { let (name, idx) = match command.tokens.len() { 0 => Err(ErrorKind::InvalidCommand)?, 1 => { let name = &command.tokens[0]; - let count = try_lock!(self.factoids).count_factoids(name)?; + let count = self.factoids.read().count_factoids(name)?; if count < 1 { Err(ErrorKind::NotFound)?; @@ -127,7 +133,9 @@ impl<T: Database> Factoids<T> { } }; - let factoid = try_lock!(self.factoids) + let factoid = self + .factoids + .read() .get_factoid(name, idx) .context(ErrorKind::NotFound)?; @@ -136,12 +144,12 @@ impl<T: Database> Factoids<T> { Ok(format!("{}: {}", factoid.name, message)) } - fn info(&self, command: &PluginCommand) -> Result<String, FactoidsError> { + fn info(&self, command: &PluginCommand) -> Result<String, FactoidError> { match command.tokens.len() { 0 => Err(ErrorKind::InvalidCommand)?, 1 => { let name = &command.tokens[0]; - let count = try_lock!(self.factoids).count_factoids(name)?; + let count = self.factoids.read().count_factoids(name)?; Ok(match count { 0 => Err(ErrorKind::NotFound)?, @@ -152,7 +160,7 @@ impl<T: Database> Factoids<T> { _ => { let name = &command.tokens[0]; let idx = i32::from_str(&command.tokens[1]).context(ErrorKind::InvalidIndex)?; - let factoid = try_lock!(self.factoids).get_factoid(name, idx)?; + let factoid = self.factoids.read().get_factoid(name, idx)?; Ok(format!( "{}: Added by {} at {} UTC", @@ -162,13 +170,13 @@ impl<T: Database> Factoids<T> { } } - fn exec(&self, mut command: PluginCommand) -> Result<String, FactoidsError> { - if command.tokens.len() < 1 { + fn exec(&self, mut command: PluginCommand) -> Result<String, FactoidError> { + if command.tokens.is_empty() { Err(ErrorKind::InvalidIndex)? } else { let name = command.tokens.remove(0); - let count = try_lock!(self.factoids).count_factoids(&name)?; - let factoid = try_lock!(self.factoids).get_factoid(&name, count - 1)?; + let count = self.factoids.read().count_factoids(&name)?; + let factoid = self.factoids.read().get_factoid(&name, count - 1)?; let content = factoid.content; let value = if content.starts_with('>') { @@ -179,7 +187,10 @@ impl<T: Database> Factoids<T> { } else { match self.run_lua(&name, &content, &command) { Ok(v) => v, - Err(e) => format!("\"{}\"", e), + Err(e) => match e { + LuaError::CallbackError { cause, .. } => cause.to_string(), + _ => e.to_string(), + }, } } } else { @@ -190,12 +201,7 @@ impl<T: Database> Factoids<T> { } } - fn run_lua( - &self, - name: &str, - code: &str, - command: &PluginCommand, - ) -> Result<String, rlua::Error> { + fn run_lua(&self, name: &str, code: &str, command: &PluginCommand) -> Result<String, LuaError> { let args = command .tokens .iter() @@ -208,6 +214,7 @@ impl<T: Database> Factoids<T> { globals.set("factoid", code)?; globals.set("download", lua.create_function(download)?)?; + globals.set("json_decode", lua.create_function(json_decode)?)?; globals.set("sleep", lua.create_function(sleep)?)?; globals.set("args", args)?; globals.set("input", command.tokens.join(" "))?; @@ -220,10 +227,16 @@ impl<T: Database> Factoids<T> { Ok(output.join("|")) } + + fn help(&self) -> &str { + "usage: factoids <subcommand>\r\n\ + subcommands: add, fromurl, remove, get, info, exec, help" + } } -impl<T: Database> Plugin for Factoids<T> { - fn execute(&self, _: &IrcClient, message: &Message) -> ExecutionStatus { +impl<T: Database, C: FrippyClient> Plugin for Factoid<T, C> { + type Client = C; + fn execute(&self, _: &Self::Client, message: &Message) -> ExecutionStatus { match message.command { Command::PRIVMSG(_, ref content) => if content.starts_with('!') { ExecutionStatus::RequiresThread @@ -234,7 +247,11 @@ impl<T: Database> Plugin for Factoids<T> { } } - fn execute_threaded(&self, client: &IrcClient, message: &Message) -> Result<(), FrippyError> { + fn execute_threaded( + &self, + client: &Self::Client, + message: &Message, + ) -> Result<(), FrippyError> { if let Command::PRIVMSG(_, mut content) = message.command.clone() { content.remove(0); @@ -246,22 +263,29 @@ impl<T: Database> Plugin for Factoids<T> { tokens: t, }; - Ok(match self.exec(c) { - Ok(f) => client + if let Ok(f) = self.exec(c) { + client .send_privmsg(&message.response_target().unwrap(), &f) - .context(FrippyErrorKind::Connection)?, - Err(_) => (), - }) - } else { - Ok(()) + .context(FrippyErrorKind::Connection)?; + } } + + Ok(()) } - fn command(&self, client: &IrcClient, mut command: PluginCommand) -> Result<(), FrippyError> { + fn command( + &self, + client: &Self::Client, + mut command: PluginCommand, + ) -> Result<(), FrippyError> { + use self::FactoidResponse::{Private, Public}; + if command.tokens.is_empty() { - return Ok(client - .send_notice(&command.target, "Invalid command") - .context(FrippyErrorKind::Connection)?); + client + .send_notice(&command.source, "Invalid command") + .context(FrippyErrorKind::Connection)?; + + return Ok(()); } let target = command.target.clone(); @@ -269,45 +293,55 @@ impl<T: Database> Plugin for Factoids<T> { let sub_command = command.tokens.remove(0); let result = match sub_command.as_ref() { - "add" => self.add(&mut command).map(|s| s.to_owned()), - "fromurl" => self.add_from_url(&mut command).map(|s| s.to_owned()), - "remove" => self.remove(&mut command).map(|s| s.to_owned()), - "get" => self.get(&command), - "info" => self.info(&command), - "exec" => self.exec(command), + "add" => self.add(&mut command).map(|s| Private(s.to_owned())), + "fromurl" => self + .add_from_url(&mut command) + .map(|s| Private(s.to_owned())), + "remove" => self.remove(&mut command).map(|s| Private(s.to_owned())), + "get" => self.get(&command).map(Public), + "info" => self.info(&command).map(Public), + "exec" => self.exec(command).map(Public), + "help" => Ok(Private(self.help().to_owned())), _ => Err(ErrorKind::InvalidCommand.into()), }; - Ok(match result { - Ok(v) => client - .send_privmsg(&target, &v) - .context(FrippyErrorKind::Connection)?, + 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::Factoids)? + Err(e).context(FrippyErrorKind::Factoid)? } - }) + } + + Ok(()) } - fn evaluate(&self, _: &IrcClient, _: PluginCommand) -> Result<String, String> { + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { Err(String::from( - "Evaluation of commands is not implemented for Factoids at this time", + "Evaluation of commands is not implemented for Factoid at this time", )) } } -impl<T: Database> fmt::Debug for Factoids<T> { +impl<T: Database, C: FrippyClient> fmt::Debug for Factoid<T, C> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "Factoids {{ ... }}") + write!(f, "Factoid {{ ... }}") } } pub mod error { #[derive(Copy, Clone, Eq, PartialEq, Debug, Fail, Error)] - #[error = "FactoidsError"] + #[error = "FactoidError"] pub enum ErrorKind { /// Invalid command error #[fail(display = "Invalid Command")] diff --git a/src/plugins/factoids/sandbox.lua b/src/plugins/factoid/sandbox.lua index 3fc74cd..a927535 100644 --- a/src/plugins/factoids/sandbox.lua +++ b/src/plugins/factoid/sandbox.lua @@ -13,13 +13,30 @@ function sendln(text) table.insert(output, "") end +function trim(s) + local from = s:match"^%s*()" + return from > #s and "" or s:match(".*%S", from) +end + +trimmedInput = trim(input) + +if trimmedInput == "" then + ioru = user +else + ioru = trimmedInput +end + local sandbox_env = { print = send, println = sendln, + trim = trim, eval = nil, + sleep = nil, + json = {decode = json_decode}, args = args, input = input, user = user, + ioru = ioru, channel = channel, request = download, string = string, @@ -60,10 +77,21 @@ function eval(code) end end +-- Only sleeps for 1 second at a time +-- This ensures that the timeout check can still run +function safesleep(dur) + while dur > 1000 do + dur = dur - 1000 + sleep(1000) + end + sleep(dur) +end + sandbox_env.eval = eval +sandbox_env.sleep = safesleep -- Check if the factoid timed out -function checktime(event, line) +function checktime() if os.time() - time >= timeout then error("Timed out after " .. timeout .. " seconds", 0) else @@ -72,12 +100,24 @@ function checktime(event, line) end end +-- Check if the factoid uses too much memory +function checkmem() + if collectgarbage("count") > maxmem then + error("Factoid used over " .. maxmem .. " kbyte of ram") + end +end + local f, e = load(factoid, nil, nil, sandbox_env) -- Add timeout hook time = os.time() +-- The timeout is defined in seconds timeout = 30 debug.sethook(checktime, "l") +-- Add memory check hook +-- The max memory is defined in kilobytes +maxmem = 1000 +debug.sethook(checkmem, "l") if f then f() diff --git a/src/plugins/factoid/utils.rs b/src/plugins/factoid/utils.rs new file mode 100644 index 0000000..7bd9b20 --- /dev/null +++ b/src/plugins/factoid/utils.rs @@ -0,0 +1,82 @@ +use std::thread; +use std::time::Duration; + +use serde_json::{self, Value as SerdeValue}; + +use super::rlua::Error as LuaError; +use super::rlua::Error::RuntimeError; +use super::rlua::{Lua, Value as LuaValue}; + +use utils::error::ErrorKind::Connection; +use utils::Url; + +use failure::Fail; + +pub fn sleep(_: &Lua, dur: u64) -> Result<(), LuaError> { + thread::sleep(Duration::from_millis(dur)); + Ok(()) +} + +pub fn download(_: &Lua, url: String) -> Result<String, LuaError> { + let url = Url::from(url).max_kib(1024); + match url.request() { + Ok(v) => Ok(v), + Err(e) => { + let error = match e.kind() { + Connection => e.cause().unwrap().to_string(), + _ => e.to_string(), + }; + + Err(RuntimeError(format!( + "Failed to download {} - {}", + url.as_str(), + error + ))) + } + } +} + +fn convert_value(lua: &Lua, sval: SerdeValue, max_recurs: usize) -> Result<LuaValue, LuaError> { + if max_recurs == 0 { + return Err(RuntimeError(String::from( + "Reached max recursion level - json is nested too deep", + ))); + } + + let lval = match sval { + SerdeValue::Null => LuaValue::Nil, + SerdeValue::Bool(b) => LuaValue::Boolean(b), + SerdeValue::String(s) => LuaValue::String(lua.create_string(&s)?), + SerdeValue::Number(n) => { + let f = n.as_f64().ok_or_else(|| { + RuntimeError(String::from("Failed to convert number into double")) + })?; + LuaValue::Number(f) + } + SerdeValue::Array(arr) => { + let table = lua.create_table()?; + for (i, val) in arr.into_iter().enumerate() { + table.set(i + 1, convert_value(lua, val, max_recurs - 1)?)?; + } + + LuaValue::Table(table) + } + SerdeValue::Object(obj) => { + let table = lua.create_table()?; + for (key, val) in obj { + table.set(key, convert_value(lua, val, max_recurs - 1)?)?; + } + + LuaValue::Table(table) + } + }; + + Ok(lval) +} + +pub fn json_decode(lua: &Lua, json: String) -> Result<LuaValue, LuaError> { + let ser_val: SerdeValue = + serde_json::from_str(&json).map_err(|e| RuntimeError(e.to_string()))?; + + convert_value(lua, ser_val, 25) +} diff --git a/src/plugins/factoids/utils.rs b/src/plugins/factoids/utils.rs deleted file mode 100644 index 70ac8a7..0000000 --- a/src/plugins/factoids/utils.rs +++ /dev/null @@ -1,25 +0,0 @@ -extern crate reqwest; - -use std::thread; -use std::time::Duration; - -use utils; -use super::rlua::prelude::*; - -use self::LuaError::RuntimeError; - -pub fn download(_: &Lua, url: String) -> Result<String, LuaError> { - match utils::download(&url, Some(1024)) { - Ok(v) => Ok(v), - Err(e) => Err(RuntimeError(format!( - "Failed to download {} - {}", - url, - e.to_string() - ))), - } -} - -pub fn sleep(_: &Lua, dur: u64) -> Result<(), LuaError> { - thread::sleep(Duration::from_millis(dur)); - Ok(()) -} diff --git a/src/plugins/help.rs b/src/plugins/help.rs index 7e3658d..d54008a 100644 --- a/src/plugins/help.rs +++ b/src/plugins/help.rs @@ -1,36 +1,51 @@ +use std::marker::PhantomData; + use irc::client::prelude::*; use plugin::*; +use FrippyClient; -use error::FrippyError; use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; use failure::ResultExt; #[derive(PluginName, Default, Debug)] -pub struct Help; +pub struct Help<C> { + phantom: PhantomData<C>, +} -impl Help { - pub fn new() -> Help { - Help {} +impl<C: FrippyClient> Help<C> { + pub fn new() -> Self { + Help { + phantom: PhantomData, + } } } -impl Plugin for Help { - fn execute(&self, _: &IrcClient, _: &Message) -> ExecutionStatus { +impl<C: FrippyClient> Plugin for Help<C> { + type Client = C; + fn execute(&self, _: &Self::Client, _: &Message) -> ExecutionStatus { ExecutionStatus::Done } - fn execute_threaded(&self, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { panic!("Help should not use threading") } - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { - Ok(client - .send_notice(&command.source, "Help has not been added yet.") - .context(FrippyErrorKind::Connection)?) + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { + client + .send_notice( + &command.source, + "Available commands: help, tell, factoids, remind, quote, unicode\r\n\ + For more detailed help call help on the specific command.\r\n\ + Example: 'remind help'", + ) + .context(FrippyErrorKind::Connection)?; + + Ok(()) } - fn evaluate(&self, _: &IrcClient, _: PluginCommand) -> Result<String, String> { + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { Err(String::from("Help has not been added yet.")) } } diff --git a/src/plugins/keepnick.rs b/src/plugins/keepnick.rs index 58ac167..6ba16c1 100644 --- a/src/plugins/keepnick.rs +++ b/src/plugins/keepnick.rs @@ -1,20 +1,27 @@ +use std::marker::PhantomData; + use irc::client::prelude::*; use plugin::*; +use FrippyClient; -use error::FrippyError; use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; use failure::ResultExt; #[derive(PluginName, Default, Debug)] -pub struct KeepNick; +pub struct KeepNick<C> { + phantom: PhantomData<C>, +} -impl KeepNick { - pub fn new() -> KeepNick { - KeepNick {} +impl<C: FrippyClient> KeepNick<C> { + pub fn new() -> Self { + KeepNick { + phantom: PhantomData, + } } - fn check_nick(&self, client: &IrcClient, leaver: &str) -> ExecutionStatus { + fn check_nick(&self, client: &C, leaver: &str) -> ExecutionStatus { let cfg_nick = match client.config().nickname { Some(ref nick) => nick.clone(), None => return ExecutionStatus::Done, @@ -41,8 +48,9 @@ impl KeepNick { } } -impl Plugin for KeepNick { - fn execute(&self, client: &IrcClient, message: &Message) -> ExecutionStatus { +impl<C: FrippyClient> Plugin for KeepNick<C> { + type Client = C; + fn execute(&self, client: &Self::Client, message: &Message) -> ExecutionStatus { match message.command { Command::QUIT(ref nick) => { self.check_nick(client, &nick.clone().unwrap_or_else(String::new)) @@ -51,20 +59,22 @@ impl Plugin for KeepNick { } } - fn execute_threaded(&self, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { panic!("Tell should not use threading") } - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { - Ok(client + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { + client .send_notice( &command.source, "This Plugin does not implement any commands.", ) - .context(FrippyErrorKind::Connection)?) + .context(FrippyErrorKind::Connection)?; + + Ok(()) } - fn evaluate(&self, _: &IrcClient, _: PluginCommand) -> Result<String, String> { + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { Err(String::from("This Plugin does not implement any commands.")) } } diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 9a3ba2f..0dfb011 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -1,8 +1,10 @@ //! Collection of plugins included +pub mod factoid; pub mod help; -pub mod url; -pub mod emoji; -pub mod tell; -pub mod currency; -pub mod factoids; pub mod keepnick; +pub mod quote; +pub mod remind; +pub mod sed; +pub mod tell; +pub mod unicode; +pub mod url; diff --git a/src/plugins/quote/database.rs b/src/plugins/quote/database.rs new file mode 100644 index 0000000..49d6058 --- /dev/null +++ b/src/plugins/quote/database.rs @@ -0,0 +1,142 @@ +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..edeed40 --- /dev/null +++ b/src/plugins/quote/mod.rs @@ -0,0 +1,278 @@ +use std::fmt; +use std::marker::PhantomData; +use std::str::FromStr; + +use antidote::RwLock; +use chrono::NaiveDateTime; +use irc::client::prelude::*; +use rand::{thread_rng, Rng}; +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 len = command.tokens.len(); + let idx = if len < 2 || command.tokens[1].is_empty() { + thread_rng().gen_range(1, count + 1) + } else { + let idx_string = &command.tokens[1]; + let idx = match i32::from_str(idx_string) { + 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 idx = if idx < 0 { + self.quotes.read().count_quotes(quotee, channel)? + idx + 1 + } else { + idx + }; + + let quote = self + .quotes + .read() + .get_quote(quotee, channel, idx) + .context(ErrorKind::NotFound)?; + + 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, + } +} diff --git a/src/plugins/remind/database.rs b/src/plugins/remind/database.rs new file mode 100644 index 0000000..97d93e8 --- /dev/null +++ b/src/plugins/remind/database.rs @@ -0,0 +1,236 @@ +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::fmt; + +#[cfg(feature = "mysql")] +use std::sync::Arc; + +#[cfg(feature = "mysql")] +use diesel::mysql::MysqlConnection; +#[cfg(feature = "mysql")] +use diesel::prelude::*; +#[cfg(feature = "mysql")] +use r2d2::Pool; +#[cfg(feature = "mysql")] +use r2d2_diesel::ConnectionManager; + +#[cfg(feature = "mysql")] +use failure::ResultExt; + +use chrono::NaiveDateTime; + +use super::error::*; + +#[cfg(feature = "mysql")] +static LAST_ID_SQL: &'static str = "SELECT LAST_INSERT_ID()"; + +#[cfg_attr(feature = "mysql", derive(Queryable))] +#[derive(Clone, Debug)] +pub struct Event { + pub id: i64, + pub receiver: String, + pub content: String, + pub author: String, + pub time: NaiveDateTime, + pub repeat: Option<i64>, +} + +impl fmt::Display for Event { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}: {} reminds {} to \"{}\" at {}", + self.id, self.author, self.receiver, self.content, self.time + ) + } +} + +#[cfg_attr(feature = "mysql", derive(Insertable))] +#[cfg_attr(feature = "mysql", table_name = "events")] +#[derive(Debug)] +pub struct NewEvent<'a> { + pub receiver: &'a str, + pub content: &'a str, + pub author: &'a str, + pub time: &'a NaiveDateTime, + pub repeat: Option<i64>, +} + +pub trait Database: Send + Sync { + fn insert_event(&mut self, event: &NewEvent) -> Result<i64, RemindError>; + fn update_event_time(&mut self, id: i64, time: &NaiveDateTime) -> Result<(), RemindError>; + fn get_events_before(&self, time: &NaiveDateTime) -> Result<Vec<Event>, RemindError>; + fn get_user_events(&self, user: &str) -> Result<Vec<Event>, RemindError>; + fn get_event(&self, id: i64) -> Result<Event, RemindError>; + fn delete_event(&mut self, id: i64) -> Result<(), RemindError>; +} + +// HashMap +impl<S: ::std::hash::BuildHasher + Send + Sync> Database for HashMap<i64, Event, S> { + fn insert_event(&mut self, event: &NewEvent) -> Result<i64, RemindError> { + let mut id = 0; + while self.contains_key(&id) { + id += 1; + } + + let event = Event { + id, + receiver: event.receiver.to_owned(), + content: event.content.to_owned(), + author: event.author.to_owned(), + time: *event.time, + repeat: event.repeat, + }; + + match self.insert(id, event) { + None => Ok(id), + Some(_) => Err(ErrorKind::Duplicate)?, + } + } + + fn update_event_time(&mut self, id: i64, time: &NaiveDateTime) -> Result<(), RemindError> { + let entry = self.entry(id); + + match entry { + Entry::Occupied(mut v) => v.get_mut().time = *time, + Entry::Vacant(_) => return Err(ErrorKind::NotFound.into()), + } + + Ok(()) + } + + fn get_events_before(&self, time: &NaiveDateTime) -> Result<Vec<Event>, RemindError> { + let mut events = Vec::new(); + + for (_, event) in self.iter() { + if event.time < *time { + events.push(event.clone()) + } + } + + if events.is_empty() { + Err(ErrorKind::NotFound.into()) + } else { + Ok(events) + } + } + + fn get_user_events(&self, user: &str) -> Result<Vec<Event>, RemindError> { + let mut events = Vec::new(); + + for (_, event) in self.iter() { + if event.receiver.eq_ignore_ascii_case(user) { + events.push(event.clone()) + } + } + + if events.is_empty() { + Err(ErrorKind::NotFound.into()) + } else { + Ok(events) + } + } + + fn get_event(&self, id: i64) -> Result<Event, RemindError> { + Ok(self.get(&id).cloned().ok_or(ErrorKind::NotFound)?) + } + + fn delete_event(&mut self, id: i64) -> Result<(), RemindError> { + match self.remove(&id) { + Some(_) => Ok(()), + None => Err(ErrorKind::NotFound)?, + } + } +} + +#[cfg(feature = "mysql")] +mod schema { + table! { + events (id) { + id -> Bigint, + receiver -> Varchar, + content -> Text, + author -> Varchar, + time -> Timestamp, + repeat -> Nullable<Bigint>, + } + } +} + +#[cfg(feature = "mysql")] +use self::schema::events; + +#[cfg(feature = "mysql")] +impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { + fn insert_event(&mut self, event: &NewEvent) -> Result<i64, RemindError> { + use diesel::{self, dsl::sql, types::Bigint}; + let conn = &*self.get().context(ErrorKind::NoConnection)?; + + diesel::insert_into(events::table) + .values(event) + .execute(conn) + .context(ErrorKind::MysqlError)?; + + let id = sql::<Bigint>(LAST_ID_SQL) + .get_result(conn) + .context(ErrorKind::MysqlError)?; + + Ok(id) + } + + fn update_event_time(&mut self, id: i64, time: &NaiveDateTime) -> Result<(), RemindError> { + use self::events::columns; + use diesel; + let conn = &*self.get().context(ErrorKind::NoConnection)?; + + match diesel::update(events::table.filter(columns::id.eq(id))) + .set(columns::time.eq(time)) + .execute(conn) + { + Ok(0) => Err(ErrorKind::NotFound)?, + Ok(_) => Ok(()), + Err(e) => Err(e).context(ErrorKind::MysqlError)?, + } + } + + fn get_events_before(&self, time: &NaiveDateTime) -> Result<Vec<Event>, RemindError> { + use self::events::columns; + let conn = &*self.get().context(ErrorKind::NoConnection)?; + + Ok(events::table + .filter(columns::time.lt(time)) + .load::<Event>(conn) + .context(ErrorKind::MysqlError)?) + } + + fn get_user_events(&self, user: &str) -> Result<Vec<Event>, RemindError> { + use self::events::columns; + let conn = &*self.get().context(ErrorKind::NoConnection)?; + + Ok(events::table + .filter(columns::receiver.eq(user)) + .load::<Event>(conn) + .context(ErrorKind::MysqlError)?) + } + + fn get_event(&self, id: i64) -> Result<Event, RemindError> { + let conn = &*self.get().context(ErrorKind::NoConnection)?; + + Ok(events::table + .find(id) + .first(conn) + .context(ErrorKind::MysqlError)?) + } + + fn delete_event(&mut self, id: i64) -> Result<(), RemindError> { + use self::events::columns; + use diesel; + + let conn = &*self.get().context(ErrorKind::NoConnection)?; + match diesel::delete(events::table.filter(columns::id.eq(id))).execute(conn) { + Ok(0) => Err(ErrorKind::NotFound)?, + Ok(_) => Ok(()), + Err(e) => Err(e).context(ErrorKind::MysqlError)?, + } + } +} diff --git a/src/plugins/remind/mod.rs b/src/plugins/remind/mod.rs new file mode 100644 index 0000000..2a8a093 --- /dev/null +++ b/src/plugins/remind/mod.rs @@ -0,0 +1,337 @@ +use std::marker::PhantomData; +use std::thread::{sleep, spawn}; +use std::{fmt, sync::Arc, time::Duration}; + +use antidote::RwLock; +use irc::client::prelude::*; + +use chrono::{self, NaiveDateTime}; +use time; + +use plugin::*; +use FrippyClient; + +pub mod database; +mod parser; +use self::database::Database; +use self::parser::CommandParser; + +use self::error::*; +use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; +use failure::ResultExt; + +fn get_time() -> NaiveDateTime { + let tm = time::now().to_timespec(); + NaiveDateTime::from_timestamp(tm.sec, 0u32) +} + +fn get_events<T: Database>(db: &RwLock<T>, in_next: chrono::Duration) -> Vec<database::Event> { + loop { + let before = get_time() + in_next; + match db.read().get_events_before(&before) { + Ok(events) => return events, + Err(e) => { + if e.kind() != ErrorKind::NotFound { + error!("Failed to get events: {}", e); + } + } + } + + sleep(in_next.to_std().expect("Failed to convert look ahead time")); + } +} + +fn run<T: Database, C: FrippyClient>(client: &C, db: Arc<RwLock<T>>) { + let look_ahead = chrono::Duration::minutes(2); + + let mut events = get_events(&db, look_ahead); + + let mut sleep_time = look_ahead + .to_std() + .expect("Failed to convert look ahead time"); + + loop { + let now = get_time(); + for event in events { + if event.time <= now { + let msg = format!("Reminder from {}: {}", event.author, event.content); + if let Err(e) = client.send_notice(&event.receiver, &msg) { + error!("Failed to send reminder: {}", e); + } else { + debug!("Sent reminder {:?}", event); + + if let Some(repeat) = event.repeat { + let next_time = event.time + chrono::Duration::seconds(repeat); + + if let Err(e) = db.write().update_event_time(event.id, &next_time) { + error!("Failed to update reminder: {}", e); + } else { + debug!("Updated time"); + } + } else if let Err(e) = db.write().delete_event(event.id) { + error!("Failed to delete reminder: {}", e); + } + } + } else { + let until_event = (event.time - now) + .to_std() + .expect("Failed to convert until event time"); + + if until_event < sleep_time { + sleep_time = until_event + Duration::from_secs(1); + } + } + } + + sleep(sleep_time); + sleep_time = Duration::from_secs(120); + + events = get_events(&db, look_ahead); + } +} + +#[derive(PluginName)] +pub struct Remind<T: Database + 'static, C> { + events: Arc<RwLock<T>>, + has_reminder: RwLock<bool>, + phantom: PhantomData<C>, +} + +impl<T: Database + 'static, C: FrippyClient> Remind<T, C> { + pub fn new(db: T) -> Self { + let events = Arc::new(RwLock::new(db)); + + Remind { + events, + has_reminder: RwLock::new(false), + phantom: PhantomData, + } + } + + fn user_cmd(&self, command: PluginCommand) -> Result<String, RemindError> { + let parser = CommandParser::parse_target(command.tokens)?; + + self.set(&parser, &command.source) + } + + fn me_cmd(&self, command: PluginCommand) -> Result<String, RemindError> { + let source = command.source.clone(); + let parser = CommandParser::with_target(command.tokens, command.source)?; + + self.set(&parser, &source) + } + + fn set(&self, parser: &CommandParser, author: &str) -> Result<String, RemindError> { + debug!("parser: {:?}", parser); + + let target = parser.get_target(); + let time = parser.get_time(Duration::from_secs(120))?; + + let event = database::NewEvent { + receiver: target, + content: &parser.get_message(), + author, + time: &time, + repeat: parser + .get_repeat(Duration::from_secs(300))? + .map(|d| d.as_secs() as i64), + }; + + debug!("New event: {:?}", event); + + Ok(self.events + .write() + .insert_event(&event) + .map(|id| format!("Created reminder with id {} at {} UTC", id, time))?) + } + + fn list(&self, user: &str) -> Result<String, RemindError> { + let mut events = self.events.read().get_user_events(user)?; + + if events.is_empty() { + Err(ErrorKind::NotFound)?; + } + + let mut list = events.remove(0).to_string(); + for ev in events { + list.push_str("\r\n"); + list.push_str(&ev.to_string()); + } + + Ok(list) + } + + fn delete(&self, mut command: PluginCommand) -> Result<&str, RemindError> { + let id = command + .tokens + .remove(0) + .parse::<i64>() + .context(ErrorKind::Parsing)?; + let event = self.events + .read() + .get_event(id) + .context(ErrorKind::NotFound)?; + + if event.receiver.eq_ignore_ascii_case(&command.source) + || event.author.eq_ignore_ascii_case(&command.source) + { + self.events + .write() + .delete_event(id) + .map(|()| "Successfully deleted") + } else { + Ok("Only the author or receiver can delete a reminder") + } + } + + fn help(&self) -> &str { + "usage: remind <subcommand>\r\n\ + subcommands: user, me, list, delete, help\r\n\ + examples\r\n\ + remind user foo to sleep in 1 hour\r\n\ + remind me to leave early on 1.1 at 16:00 every week" + } +} + +impl<T: Database, C: FrippyClient + 'static> Plugin for Remind<T, C> { + type Client = C; + fn execute(&self, client: &Self::Client, msg: &Message) -> ExecutionStatus { + if let Command::JOIN(_, _, _) = msg.command { + let mut has_reminder = self.has_reminder.write(); + + if !*has_reminder { + let events = Arc::clone(&self.events); + let client = client.clone(); + + spawn(move || run(&client, events)); + + *has_reminder = true; + } + } + + ExecutionStatus::Done + } + + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { + panic!("Remind should not use frippy's threading") + } + + fn command( + &self, + client: &Self::Client, + mut command: PluginCommand, + ) -> Result<(), FrippyError> { + if command.tokens.is_empty() { + client + .send_notice(&command.source, &ErrorKind::InvalidCommand.to_string()) + .context(FrippyErrorKind::Connection)?; + return Ok(()); + } + + let source = command.source.clone(); + + let sub_command = command.tokens.remove(0); + let response = match sub_command.as_ref() { + "user" => self.user_cmd(command), + "me" => self.me_cmd(command), + "delete" => self.delete(command).map(|s| s.to_owned()), + "list" => self.list(&source), + "help" => Ok(self.help().to_owned()), + _ => Err(ErrorKind::InvalidCommand.into()), + }; + + match response { + Ok(msg) => client + .send_notice(&source, &msg) + .context(FrippyErrorKind::Connection)?, + Err(e) => { + let message = e.to_string(); + + client + .send_notice(&source, &message) + .context(FrippyErrorKind::Connection)?; + + Err(e).context(FrippyErrorKind::Remind)? + } + } + + Ok(()) + } + + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { + Err(String::from( + "Evaluation of commands is not implemented for remind at this time", + )) + } +} + +impl<T: Database, C: FrippyClient> fmt::Debug for Remind<T, C> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "Remind {{ ... }}") + } +} + +pub mod error { + #[derive(Copy, Clone, Eq, PartialEq, Debug, Fail, Error)] + #[error = "RemindError"] + pub enum ErrorKind { + /// Invalid command error + #[fail(display = "Incorrect Command. Send \"remind help\" for help.")] + InvalidCommand, + + /// Missing message error + #[fail(display = "Reminder needs to have a description")] + MissingMessage, + + /// Missing receiver error + #[fail(display = "Specify who to remind")] + MissingReceiver, + + /// Missing time error + #[fail(display = "Reminder needs to have a time")] + MissingTime, + + /// Invalid time error + #[fail(display = "Could not parse time")] + InvalidTime, + + /// Invalid date error + #[fail(display = "Could not parse date")] + InvalidDate, + + /// Parse error + #[fail(display = "Could not parse integers")] + Parsing, + + /// Ambigous time error + #[fail(display = "Time specified is ambiguous")] + AmbiguousTime, + + /// Time too short error + #[fail(display = "Reminder needs to be in over 2 minutes")] + TimeShort, + + /// Repeat time too short error + #[fail(display = "Repeat time needs to be over 5 minutes")] + RepeatTimeShort, + + /// Duplicate error + #[fail(display = "Entry already exists")] + Duplicate, + + /// Not found error + #[fail(display = "No events 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, + } +} diff --git a/src/plugins/remind/parser.rs b/src/plugins/remind/parser.rs new file mode 100644 index 0000000..91d13ab --- /dev/null +++ b/src/plugins/remind/parser.rs @@ -0,0 +1,251 @@ +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use humantime::parse_duration; +use std::time::Duration; +use time; + +use super::error::*; +use failure::ResultExt; + +#[derive(Default, Debug)] +pub struct CommandParser { + on_date: Option<String>, + at_time: Option<String>, + in_duration: Option<String>, + every_time: Option<String>, + target: String, + message: Option<String>, +} + +#[derive(PartialEq, Clone, Copy)] +enum ParseState { + None, + On, + At, + In, + Every, + Msg, +} + +impl CommandParser { + pub fn parse_target(mut tokens: Vec<String>) -> Result<Self, RemindError> { + let mut parser = CommandParser::default(); + + if tokens.is_empty() { + Err(ErrorKind::MissingReceiver)?; + } + + parser.target = tokens.remove(0); + + parser.parse_tokens(tokens) + } + + pub fn with_target(tokens: Vec<String>, target: String) -> Result<Self, RemindError> { + let mut parser = CommandParser::default(); + parser.target = target; + + parser.parse_tokens(tokens) + } + + fn parse_tokens(mut self, tokens: Vec<String>) -> Result<Self, RemindError> { + let mut state = ParseState::None; + let mut cur_str = String::new(); + + for token in tokens { + let next_state = match token.as_ref() { + "on" => ParseState::On, + "at" => ParseState::At, + "in" => ParseState::In, + "every" => ParseState::Every, + "to" => ParseState::Msg, + _ => { + if !cur_str.is_empty() { + cur_str.push(' '); + } + cur_str.push_str(&token); + state + } + }; + + if next_state != state { + if state != ParseState::None { + self = self.add_string_by_state(state, cur_str)?; + cur_str = String::new(); + } + + state = next_state; + } + } + + self = self.add_string_by_state(state, cur_str)?; + + if self.message.is_none() { + return Err(ErrorKind::MissingMessage.into()); + } + + if self.in_duration.is_some() && self.at_time.is_some() + || self.in_duration.is_some() && self.on_date.is_some() + { + return Err(ErrorKind::AmbiguousTime.into()); + } + + if self.in_duration.is_none() && self.at_time.is_none() && self.on_date.is_none() { + return Err(ErrorKind::MissingTime.into()); + } + + Ok(self) + } + + fn add_string_by_state(self, state: ParseState, string: String) -> Result<Self, RemindError> { + use self::ParseState::*; + let string = Some(string); + match state { + On if self.on_date.is_none() => Ok(CommandParser { + on_date: string, + ..self + }), + At if self.at_time.is_none() => Ok(CommandParser { + at_time: string, + ..self + }), + In if self.in_duration.is_none() => Ok(CommandParser { + in_duration: string, + ..self + }), + Msg if self.message.is_none() => Ok(CommandParser { + message: string, + ..self + }), + Every if self.every_time.is_none() => Ok(CommandParser { + every_time: string, + ..self + }), + _ => Err(ErrorKind::MissingMessage.into()), + } + } + + fn parse_date(&self, str_date: &str) -> Result<NaiveDate, RemindError> { + let nums = str_date + .split('.') + .map(|s| s.parse::<u32>()) + .collect::<Result<Vec<_>, _>>() + .context(ErrorKind::InvalidDate)?; + + if 2 > nums.len() || nums.len() > 3 { + return Err(ErrorKind::InvalidDate.into()); + } + + let day = nums[0]; + let month = nums[1]; + + let parse_date = match nums.get(2) { + Some(year) => { + NaiveDate::from_ymd_opt(*year as i32, month, day).ok_or(ErrorKind::InvalidDate)? + } + None => { + let now = time::now(); + let date = NaiveDate::from_ymd_opt(now.tm_year + 1900, month, day) + .ok_or(ErrorKind::InvalidDate)?; + if date.succ().and_hms(0, 0, 0).timestamp() < now.to_timespec().sec { + NaiveDate::from_ymd(now.tm_year + 1901, month, day) + } else { + date + } + } + }; + + Ok(parse_date) + } + + fn parse_time(&self, str_time: &str) -> Result<NaiveTime, RemindError> { + let nums = str_time + .split(':') + .map(|s| s.parse::<u32>()) + .collect::<Result<Vec<_>, _>>() + .context(ErrorKind::InvalidTime)?; + + if 2 != nums.len() { + return Err(ErrorKind::InvalidTime.into()); + } + + let hour = nums[0]; + let minute = nums[1]; + + Ok(NaiveTime::from_hms(hour, minute, 0)) + } + + pub fn get_time(&self, min_dur: Duration) -> Result<NaiveDateTime, RemindError> { + if let Some(ref str_duration) = self.in_duration { + let duration = parse_duration(&str_duration).context(ErrorKind::InvalidTime)?; + + if duration < min_dur { + return Err(ErrorKind::TimeShort.into()); + } + + let tm = time::now().to_timespec(); + return Ok(NaiveDateTime::from_timestamp( + tm.sec + duration.as_secs() as i64, + 0u32, + )); + } + + let mut date = None; + if let Some(ref str_date) = self.on_date { + date = Some(self.parse_date(str_date)?); + } + + if let Some(ref str_time) = self.at_time { + let time = self.parse_time(str_time)?; + + if let Some(date) = date { + Ok(date.and_time(time)) + } else { + let now = time::now(); + let today = NaiveDate::from_ymd_opt( + now.tm_year + 1900, + now.tm_mon as u32 + 1, + now.tm_mday as u32, + ).ok_or(ErrorKind::InvalidDate)?; + + let time_today = today.and_time(time); + + if time_today.timestamp() < now.to_timespec().sec { + debug!("tomorrow"); + + Ok(today.succ().and_time(time)) + } else { + debug!("today"); + + Ok(time_today) + } + } + } else { + Ok(date.expect("At this point date has to be set") + .and_hms(0, 0, 0)) + } + } + + pub fn get_repeat(&self, min_dur: Duration) -> Result<Option<Duration>, RemindError> { + if let Some(mut words) = self.every_time.clone() { + if !words.chars().next().unwrap().is_digit(10) { + words.insert(0, '1'); + } + let dur = parse_duration(&words).context(ErrorKind::InvalidTime)?; + + if dur <= min_dur { + return Err(ErrorKind::RepeatTimeShort.into()); + } + + Ok(Some(dur)) + } else { + Ok(None) + } + } + + pub fn get_target(&self) -> &str { + &self.target + } + + pub fn get_message(&self) -> &str { + self.message.as_ref().expect("Has to be set") + } +} diff --git a/src/plugins/sed.rs b/src/plugins/sed.rs new file mode 100644 index 0000000..2c8522f --- /dev/null +++ b/src/plugins/sed.rs @@ -0,0 +1,193 @@ +use std::collections::HashMap; +use std::marker::PhantomData; + +use antidote::RwLock; +use circular_queue::CircularQueue; +use regex::{Regex, RegexBuilder, Captures}; + +use irc::client::prelude::*; + +use plugin::*; +use FrippyClient; + +use self::error::*; +use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; +use failure::Fail; +use failure::ResultExt; + +lazy_static! { + static ref RE: Regex = + Regex::new(r"^s/((?:\\/|[^/])+)/((?:\\/|[^/])*)/(?:(\w+))?\s*$").unwrap(); +} + +#[derive(PluginName, Debug)] +pub struct Sed<C> { + per_channel: usize, + channel_messages: RwLock<HashMap<String, CircularQueue<String>>>, + phantom: PhantomData<C>, +} + +impl<C: FrippyClient> Sed<C> { + pub fn new(per_channel: usize) -> Self { + Sed { + per_channel, + channel_messages: RwLock::new(HashMap::new()), + phantom: PhantomData, + } + } + + fn add_message(&self, channel: String, message: String) { + let mut channel_messages = self.channel_messages.write(); + let messages = channel_messages + .entry(channel) + .or_insert_with(|| CircularQueue::with_capacity(self.per_channel)); + messages.push(message); + } + + fn format_escaped(&self, input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut escape = false; + + for c in input.chars() { + if escape && !r"/\".contains(c) { + output.push('\\'); + } else if !escape && c == '\\' { + escape = true; + continue; + } + escape = false; + + output.push(c); + } + + output + } + + fn run_regex(&self, channel: &str, captures: &Captures) -> Result<String, SedError> { + let mut global_match = false; + let mut case_insens = false; + let mut ign_whitespace = false; + let mut swap_greed = false; + let mut enable_unicode = true; + + debug!("{:?}", captures); + + let first = self.format_escaped(captures.get(1).unwrap().as_str()); + let second = self.format_escaped(captures.get(2).unwrap().as_str()); + + if let Some(flags) = captures.get(3) { + let flags = flags.as_str(); + + global_match = flags.contains('g'); + case_insens = flags.contains('i'); + ign_whitespace = flags.contains('x'); + swap_greed = flags.contains('U'); + enable_unicode = !flags.contains('u'); + } + + let user_re = RegexBuilder::new(&first) + .case_insensitive(case_insens) + .ignore_whitespace(ign_whitespace) + .unicode(enable_unicode) + .swap_greed(swap_greed) + .build() + .context(ErrorKind::InvalidRegex)?; + + let channel_messages = self.channel_messages.read(); + let messages = channel_messages.get(channel).ok_or(ErrorKind::NoMessages)?; + + for message in messages.iter() { + if user_re.is_match(message) { + let response = if global_match { + user_re.replace_all(message, &second[..]) + } else { + user_re.replace(message, &second[..]) + }; + + return Ok(response.to_string()); + } + } + + Err(ErrorKind::NoMatch)? + } +} + +impl<C: FrippyClient> Plugin for Sed<C> { + type Client = C; + fn execute(&self, client: &Self::Client, message: &Message) -> ExecutionStatus { + match message.command { + Command::PRIVMSG(_, ref content) => { + let channel = message.response_target().unwrap_or(""); + let user = message.source_nickname().unwrap_or(""); + if channel == user { + return ExecutionStatus::Done; + } + + if let Some(captures) = RE.captures(content) { + let result = match self.run_regex(channel, &captures) { + Ok(msg) => client.send_privmsg(channel, &msg), + Err(e) => match e.kind() { + ErrorKind::InvalidRegex => { + let err = e.cause().unwrap().to_string(); + client.send_notice(user, &err.replace('\n', "\r\n")) + } + _ => client.send_notice(user, &e.to_string()), + }, + }; + + match result { + Err(e) => { + ExecutionStatus::Err(e.context(FrippyErrorKind::Connection).into()) + } + Ok(_) => ExecutionStatus::Done, + } + } else { + self.add_message(channel.to_string(), content.to_string()); + + ExecutionStatus::Done + } + } + _ => ExecutionStatus::Done, + } + } + + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { + panic!("Sed should not use threading") + } + + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { + client + .send_notice( + &command.source, + "Currently this Plugin does not implement any commands.", + ) + .context(FrippyErrorKind::Connection)?; + + Ok(()) + } + + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { + Err(String::from( + "Evaluation of commands is not implemented for sed at this time", + )) + } +} + +pub mod error { + #[derive(Copy, Clone, Eq, PartialEq, Debug, Fail, Error)] + #[error = "SedError"] + pub enum ErrorKind { + /// Invalid regex error + #[fail(display = "Invalid regex")] + InvalidRegex, + + /// No messages found error + #[fail(display = "No messages were found for this channel")] + NoMessages, + + /// No match found error + #[fail(display = "No recent messages match this regex")] + NoMatch, + } +} diff --git a/src/plugins/tell/database.rs b/src/plugins/tell/database.rs index 98e9fb3..cbcb93d 100644 --- a/src/plugins/tell/database.rs +++ b/src/plugins/tell/database.rs @@ -1,15 +1,12 @@ -#[cfg(feature = "mysql")] -extern crate dotenv; - +use std::collections::HashMap; #[cfg(feature = "mysql")] use std::sync::Arc; -use std::collections::HashMap; #[cfg(feature = "mysql")] -use diesel::prelude::*; -#[cfg(feature = "mysql")] use diesel::mysql::MysqlConnection; #[cfg(feature = "mysql")] +use diesel::prelude::*; +#[cfg(feature = "mysql")] use r2d2::Pool; #[cfg(feature = "mysql")] use r2d2_diesel::ConnectionManager; @@ -40,7 +37,7 @@ pub struct NewTellMessage<'a> { pub message: &'a str, } -pub trait Database: Send { +pub trait Database: Send + Sync { fn insert_tell(&mut self, tell: &NewTellMessage) -> Result<(), TellError>; fn get_tells(&self, receiver: &str) -> Result<Vec<TellMessage>, TellError>; fn get_receivers(&self) -> Result<Vec<String>, TellError>; @@ -48,7 +45,7 @@ pub trait Database: Send { } // HashMap -impl Database for HashMap<String, Vec<TellMessage>> { +impl<S: ::std::hash::BuildHasher + Send + Sync> Database for HashMap<String, Vec<TellMessage>, S> { fn insert_tell(&mut self, tell: &NewTellMessage) -> Result<(), TellError> { let tell = TellMessage { id: 0, @@ -138,8 +135,8 @@ impl Database for Arc<Pool<ConnectionManager<MysqlConnection>>> { } fn delete_tells(&mut self, receiver: &str) -> Result<(), TellError> { - use diesel; use self::tells::columns; + use diesel; let conn = &*self.get().context(ErrorKind::NoConnection)?; diesel::delete(tells::table.filter(columns::receiver.eq(receiver))) diff --git a/src/plugins/tell/mod.rs b/src/plugins/tell/mod.rs index bdfb55c..3c0dc3d 100644 --- a/src/plugins/tell/mod.rs +++ b/src/plugins/tell/mod.rs @@ -1,97 +1,116 @@ -use irc::client::prelude::*; +use std::marker::PhantomData; -use std::time::Duration; -use std::sync::Mutex; +use antidote::RwLock; +use irc::client::data::User; +use irc::client::prelude::*; -use time; use chrono::NaiveDateTime; use humantime::format_duration; +use std::time::Duration; +use time; use plugin::*; +use FrippyClient; -use failure::Fail; -use failure::ResultExt; +use self::error::*; use error::ErrorKind as FrippyErrorKind; use error::FrippyError; -use self::error::*; +use failure::Fail; +use failure::ResultExt; +use log::{debug, trace}; pub mod database; use self::database::Database; -macro_rules! try_lock { - ( $m:expr ) => { - match $m.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } - } -} - -#[derive(PluginName, Default)] -pub struct Tell<T: Database> { - tells: Mutex<T>, +#[derive(PluginName)] +pub struct Tell<T: Database, C> { + tells: RwLock<T>, + phantom: PhantomData<C>, } -impl<T: Database> Tell<T> { - pub fn new(db: T) -> Tell<T> { +impl<T: Database, C: FrippyClient> Tell<T, C> { + pub fn new(db: T) -> Self { Tell { - tells: Mutex::new(db), + tells: RwLock::new(db), + phantom: PhantomData, } } - fn tell_command( - &self, - client: &IrcClient, - command: PluginCommand, - ) -> Result<String, TellError> { + fn tell_command(&self, client: &C, command: PluginCommand) -> Result<String, TellError> { if command.tokens.len() < 2 { - return Ok(self.invalid_command(client)); + return Ok(self.invalid_command().to_owned()); } - let receiver = &command.tokens[0]; + let mut online = Vec::new(); + + let receivers = command.tokens[0].split(',').filter(|&s| !s.is_empty()); let sender = command.source; - if receiver.eq_ignore_ascii_case(client.current_nickname()) { - return Ok(String::from("I am right here!")); - } + let mut no_receiver = true; + for receiver in receivers { + if receiver.eq_ignore_ascii_case(client.current_nickname()) + || receiver.eq_ignore_ascii_case(&sender) + { + if !online.contains(&receiver) { + online.push(receiver); + } + continue; + } - if receiver.eq_ignore_ascii_case(&sender) { - return Ok(String::from("That's your name!")); - } + let channels = client + .list_channels() + .expect("The irc crate should not be compiled with the \"nochanlists\" feature"); + + let find_receiver = |option: Option<Vec<User>>| { + option.and_then(|users| { + users + .into_iter() + .find(|user| user.get_nickname().eq_ignore_ascii_case(&receiver)) + }) + }; - if let Some(channels) = client.list_channels() { - for channel in channels { - if let Some(users) = client.list_users(&channel) { - if users - .iter() - .any(|u| u.get_nickname().eq_ignore_ascii_case(&receiver)) - { - return Ok(format!("{} is currently online.", receiver)); - } + if channels + .iter() + .map(|channel| client.list_users(&channel)) + .map(find_receiver) + .any(|option| option.is_some()) + { + if !online.contains(&receiver) { + // online.push(receiver); } + // TODO Change this when https://github.com/aatxe/irc/issues/136 gets resolved + //continue; } - } - let tm = time::now().to_timespec(); - let message = command.tokens[1..].join(" "); - let tell = database::NewTellMessage { - sender: &sender, - receiver: &receiver.to_lowercase(), - time: NaiveDateTime::from_timestamp(tm.sec, 0u32), - message: &message, - }; - - try_lock!(self.tells).insert_tell(&tell)?; + let tm = time::now().to_timespec(); + let message = command.tokens[1..].join(" "); + let tell = database::NewTellMessage { + sender: &sender, + receiver: &receiver.to_lowercase(), + time: NaiveDateTime::from_timestamp(tm.sec, 0u32), + message: &message, + }; + + debug!("Saving tell for {:?}", receiver); + self.tells.write().insert_tell(&tell)?; + no_receiver = false; + } - Ok(String::from("Got it!")) + Ok(if no_receiver && online.is_empty() { + String::from("Invalid receiver.") + } else { + match online.len() { + 0 => format!("Got it!"), + 1 => format!("{} is currently online.", online[0]), + _ => format!("{} are currently online.", online.join(", ")), + } + }) } - fn on_namelist( - &self, - client: &IrcClient, - channel: &str, - ) -> Result<(), FrippyError> { - let receivers = try_lock!(self.tells) + fn on_namelist(&self, client: &C, channel: &str) -> Result<(), FrippyError> { + let receivers = self + .tells + .read() .get_receivers() .context(FrippyErrorKind::Tell)?; @@ -111,12 +130,15 @@ impl<T: Database> Tell<T> { Ok(()) } } - fn send_tells(&self, client: &IrcClient, receiver: &str) -> Result<(), FrippyError> { + + fn send_tells(&self, client: &C, receiver: &str) -> Result<(), FrippyError> { + trace!("Checking {} for tells", receiver); + if client.current_nickname() == receiver { return Ok(()); } - let mut tells = try_lock!(self.tells); + let mut tells = self.tells.write(); let tell_messages = match tells.get_tells(&receiver.to_lowercase()) { Ok(t) => t, @@ -135,14 +157,13 @@ impl<T: Database> Tell<T> { let dur = now - Duration::new(tell.time.timestamp() as u64, 0); let human_dur = format_duration(dur); + let message = format!( + "Tell from {} {} ago: {}", + tell.sender, human_dur, tell.message + ); + client - .send_notice( - receiver, - &format!( - "Tell from {} {} ago: {}", - tell.sender, human_dur, tell.message - ), - ) + .send_notice(receiver, &message) .context(FrippyErrorKind::Connection)?; debug!( @@ -158,36 +179,30 @@ impl<T: Database> Tell<T> { Ok(()) } - fn invalid_command(&self, client: &IrcClient) -> String { - format!( - "Incorrect Command. \ - Send \"{} tell help\" for help.", - client.current_nickname() - ) + fn invalid_command(&self) -> &str { + "Incorrect Command. \ + Send \"tell help\" for help." } - fn help(&self, client: &IrcClient) -> String { - format!( - "usage: {} tell user message\r\n\ - example: {0} tell Foobar Hello!", - client.current_nickname() - ) + fn help(&self) -> &str { + "Used to send messages to offline users which they will receive when they come online.\r\n + usage: tell user message\r\n\ + example: tell Foobar Hello!" } } -impl<T: Database> Plugin for Tell<T> { - fn execute(&self, client: &IrcClient, message: &Message) -> ExecutionStatus { +impl<T: Database, C: FrippyClient> Plugin for Tell<T, C> { + type Client = C; + fn execute(&self, client: &Self::Client, message: &Message) -> ExecutionStatus { let res = match message.command { Command::JOIN(_, _, _) => self.send_tells(client, message.source_nickname().unwrap()), Command::NICK(ref nick) => self.send_tells(client, nick), + Command::PRIVMSG(_, _) => self.send_tells(client, message.source_nickname().unwrap()), Command::Response(resp, ref chan_info, _) => { if resp == Response::RPL_NAMREPLY { debug!("NAMREPLY info: {:?}", chan_info); - self.on_namelist( - client, - &chan_info[chan_info.len() - 1], - ) + self.on_namelist(client, &chan_info[chan_info.len() - 1]) } else { Ok(()) } @@ -201,43 +216,44 @@ impl<T: Database> Plugin for Tell<T> { } } - fn execute_threaded(&self, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { panic!("Tell should not use threading") } - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { if command.tokens.is_empty() { - return Ok(client - .send_notice(&command.source, &self.invalid_command(client)) - .context(FrippyErrorKind::Connection)?); + client + .send_notice(&command.source, &self.invalid_command()) + .context(FrippyErrorKind::Connection)?; + return Ok(()); } let sender = command.source.to_owned(); - Ok(match command.tokens[0].as_ref() { + match command.tokens[0].as_ref() { "help" => client - .send_notice(&command.source, &self.help(client)) - .context(FrippyErrorKind::Connection) - .into(), + .send_notice(&command.source, &self.help()) + .context(FrippyErrorKind::Connection), _ => match self.tell_command(client, command) { Ok(msg) => client .send_notice(&sender, &msg) .context(FrippyErrorKind::Connection), Err(e) => client .send_notice(&sender, &e.to_string()) - .context(FrippyErrorKind::Connection) - .into(), + .context(FrippyErrorKind::Connection), }, - }?) + }?; + + Ok(()) } - fn evaluate(&self, _: &IrcClient, _: PluginCommand) -> Result<String, String> { + fn evaluate(&self, _: &Self::Client, _: PluginCommand) -> Result<String, String> { Err(String::from("This Plugin does not implement any commands.")) } } use std::fmt; -impl<T: Database> fmt::Debug for Tell<T> { +impl<T: Database, C: FrippyClient> fmt::Debug for Tell<T, C> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Tell {{ ... }}") } diff --git a/src/plugins/unicode.rs b/src/plugins/unicode.rs new file mode 100644 index 0000000..56c8666 --- /dev/null +++ b/src/plugins/unicode.rs @@ -0,0 +1,98 @@ +extern crate unicode_names; + +use std::marker::PhantomData; + +use irc::client::prelude::*; + +use plugin::*; +use FrippyClient; + +use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; +use failure::Fail; + +#[derive(PluginName, Default, Debug)] +pub struct Unicode<C> { + phantom: PhantomData<C>, +} + +impl<C: FrippyClient> Unicode<C> { + pub fn new() -> Unicode<C> { + Unicode { + phantom: PhantomData, + } + } + + fn get_name(&self, symbol: char) -> String { + match unicode_names::name(symbol) { + Some(sym) => sym.to_string().to_lowercase(), + None => String::from("UNKNOWN"), + } + } + + fn format_response(&self, content: &str) -> String { + let character = content + .chars() + .next() + .expect("content contains at least one character"); + + let mut buf = [0; 4]; + + let byte_string = character + .encode_utf8(&mut buf) + .as_bytes() + .iter() + .map(|b| format!("{:#b}", b)) + .collect::<Vec<String>>() + .join(","); + + let name = self.get_name(character); + + format!( + "{} is '{}' | UTF-8: {2:#x} ({2}), Bytes: [{3}]", + character, name, character as u32, byte_string + ) + } +} + +impl<C: FrippyClient> Plugin for Unicode<C> { + type Client = C; + + fn execute(&self, _: &Self::Client, _: &Message) -> ExecutionStatus { + ExecutionStatus::Done + } + + fn execute_threaded(&self, _: &Self::Client, _: &Message) -> Result<(), FrippyError> { + panic!("Unicode should not use threading") + } + + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { + if command.tokens.is_empty() || command.tokens[0].is_empty() { + let msg = "No non-space character was found."; + + if let Err(e) = client.send_notice(command.source, msg) { + Err(e.context(FrippyErrorKind::Connection))?; + } + + return Ok(()); + } + + let content = &command.tokens[0]; + + if let Err(e) = client.send_privmsg(command.target, &self.format_response(&content)) { + Err(e.context(FrippyErrorKind::Connection))?; + } + + Ok(()) + } + + fn evaluate(&self, _: &Self::Client, command: PluginCommand) -> Result<String, String> { + let tokens = command.tokens; + + if tokens.is_empty() { + return Err(String::from("No non-space character was found.")); + } + + Ok(self.format_response(&tokens[0])) + } +} diff --git a/src/plugins/url.rs b/src/plugins/url.rs index bff840f..a884c66 100644 --- a/src/plugins/url.rs +++ b/src/plugins/url.rs @@ -1,50 +1,59 @@ extern crate htmlescape; -extern crate regex; + +use std::marker::PhantomData; +use std::time::Duration; use irc::client::prelude::*; -use self::regex::Regex; +use regex::Regex; use plugin::*; -use utils; +use utils::Url; +use FrippyClient; use self::error::*; -use error::FrippyError; use error::ErrorKind as FrippyErrorKind; +use error::FrippyError; use failure::Fail; use failure::ResultExt; lazy_static! { - static ref RE: Regex = Regex::new(r"(^|\s)(https?://\S+)").unwrap(); + static ref URL_RE: Regex = Regex::new(r"(^|\s)(https?://\S+)").unwrap(); + static ref WORD_RE: Regex = Regex::new(r"(\w+)").unwrap(); } #[derive(PluginName, Debug)] -pub struct Url { +pub struct UrlTitles<C> { max_kib: usize, + phantom: PhantomData<C>, } -impl Url { - /// If a file is larger than `max_kib` KiB the download is stopped - pub fn new(max_kib: usize) -> Url { - Url { max_kib: max_kib } - } +#[derive(Clone, Debug)] +struct Title(String, Option<usize>); - fn grep_url(&self, msg: &str) -> Option<String> { - let captures = RE.captures(msg)?; - debug!("Url captures: {:?}", captures); +impl From<String> for Title { + fn from(title: String) -> Self { + Title(title, None) + } +} - Some(captures.get(2)?.as_str().to_owned()) +impl From<Title> for String { + fn from(title: Title) -> Self { + title.0 } +} - fn get_title<'a>(&self, body: &str) -> Result<String, UrlError> { - let title = body.find("<title") +impl Title { + fn find_by_delimiters(body: &str, delimiters: [&str; 3]) -> Result<Self, UrlError> { + let title = body + .find(delimiters[0]) .map(|tag| { body[tag..] - .find('>') - .map(|offset| tag + offset + 1) + .find(delimiters[1]) + .map(|offset| tag + offset + delimiters[1].len()) .map(|start| { body[start..] - .find("</title>") + .find(delimiters[2]) .map(|offset| start + offset) .map(|end| &body[start..end]) }) @@ -52,55 +61,145 @@ impl Url { .and_then(|s| s.and_then(|s| s)) .ok_or(ErrorKind::MissingTitle)?; - debug!("Title: {:?}", title); + debug!("Found title {:?} with delimiters {:?}", title, delimiters); - htmlescape::decode_html(title).map_err(|_| ErrorKind::HtmlDecoding.into()) + htmlescape::decode_html(title) + .map(|t| t.into()) + .map_err(|_| ErrorKind::HtmlDecoding.into()) } - fn url(&self, text: &str) -> Result<String, UrlError> { - let url = self.grep_url(text).ok_or(ErrorKind::MissingUrl)?; - let body = utils::download(&url, Some(self.max_kib)).context(ErrorKind::Download)?; + fn find_ogtitle(body: &str) -> Result<Self, UrlError> { + Self::find_by_delimiters(body, ["property=\"og:title\"", "content=\"", "\""]) + } + + fn find_title(body: &str) -> Result<Self, UrlError> { + Self::find_by_delimiters(body, ["<title", ">", "</title>"]) + } + + // TODO Improve logic + fn get_usefulness(self, url: &str) -> Self { + let mut usefulness = 0; + for word in WORD_RE.find_iter(&self.0) { + let w = word.as_str().to_lowercase(); + if w.len() > 2 && !url.to_lowercase().contains(&w) { + usefulness += 1; + } + } + + Title(self.0, Some(usefulness)) + } + + pub fn usefulness(&self) -> usize { + self.1.expect("Usefulness should be calculated already") + } + + fn clean_up(self) -> Self { + Title(self.0.trim().replace('\n', "|").replace('\r', "|"), self.1) + } + + pub fn find_clean_ogtitle(body: &str, url: &str) -> Result<Self, UrlError> { + let title = Self::find_ogtitle(body)?; + Ok(title.get_usefulness(url).clean_up()) + } + + pub fn find_clean_title(body: &str, url: &str) -> Result<Self, UrlError> { + let title = Self::find_title(body)?; + Ok(title.get_usefulness(url).clean_up()) + } +} + +impl<C: FrippyClient> UrlTitles<C> { + /// If a file is larger than `max_kib` KiB the download is stopped + pub fn new(max_kib: usize) -> Self { + UrlTitles { + max_kib, + phantom: PhantomData, + } + } - let title = self.get_title(&body)?; + fn grep_url<'a>(&self, msg: &'a str) -> Option<Url<'a>> { + let captures = URL_RE.captures(msg)?; + debug!("Url captures: {:?}", captures); + + Some(captures.get(2)?.as_str().into()) + } + + fn url(&self, text: &str) -> Result<String, UrlError> { + let url = self + .grep_url(text) + .ok_or(ErrorKind::MissingUrl)? + .max_kib(self.max_kib) + .timeout(Duration::from_secs(5)); + let body = url.request().context(ErrorKind::Download)?; + + let title = Title::find_clean_title(&body, url.as_str()); + let og_title = Title::find_clean_ogtitle(&body, url.as_str()); + + let title = match (title, og_title) { + (Ok(title), Ok(og_title)) => { + if title.usefulness() > og_title.usefulness() { + title + } else { + og_title + } + } + (Ok(title), _) => title, + (_, Ok(title)) => title, + (Err(e), _) => Err(e)?, + }; + + if title.usefulness() == 0 { + Err(ErrorKind::UselessTitle)?; + } - Ok(title.replace('\n', "|").replace('\r', "|")) + Ok(title.into()) } } -impl Plugin for Url { - fn execute(&self, _: &IrcClient, message: &Message) -> ExecutionStatus { +impl<C: FrippyClient> Plugin for UrlTitles<C> { + type Client = C; + fn execute(&self, _: &Self::Client, message: &Message) -> ExecutionStatus { match message.command { - Command::PRIVMSG(_, ref msg) => if RE.is_match(msg) { - ExecutionStatus::RequiresThread - } else { - ExecutionStatus::Done - }, + Command::PRIVMSG(_, ref msg) => { + if URL_RE.is_match(msg) { + ExecutionStatus::RequiresThread + } else { + ExecutionStatus::Done + } + } _ => ExecutionStatus::Done, } } - fn execute_threaded(&self, client: &IrcClient, message: &Message) -> Result<(), FrippyError> { - Ok(match message.command { - Command::PRIVMSG(_, ref content) => match self.url(content) { - Ok(title) => client - .send_privmsg(message.response_target().unwrap(), &title) - .context(FrippyErrorKind::Connection)?, - Err(e) => Err(e).context(FrippyErrorKind::Url)?, - }, - _ => (), - }) + fn execute_threaded( + &self, + client: &Self::Client, + message: &Message, + ) -> Result<(), FrippyError> { + if let Command::PRIVMSG(_, ref content) = message.command { + let title = self.url(content).context(FrippyErrorKind::Url)?; + let response = format!("[URL] {}", title); + + client + .send_privmsg(message.response_target().unwrap(), &response) + .context(FrippyErrorKind::Connection)?; + } + + Ok(()) } - fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { - Ok(client + fn command(&self, client: &Self::Client, command: PluginCommand) -> Result<(), FrippyError> { + client .send_notice( &command.source, "This Plugin does not implement any commands.", ) - .context(FrippyErrorKind::Connection)?) + .context(FrippyErrorKind::Connection)?; + + Ok(()) } - fn evaluate(&self, _: &IrcClient, command: PluginCommand) -> Result<String, String> { + fn evaluate(&self, _: &Self::Client, command: PluginCommand) -> Result<String, String> { self.url(&command.tokens[0]) .map_err(|e| e.cause().unwrap().to_string()) } @@ -123,6 +222,10 @@ pub mod error { #[fail(display = "No title was found")] MissingTitle, + /// Useless title error + #[fail(display = "The titles found were not useful enough")] + UselessTitle, + /// Html decoding error #[fail(display = "Failed to decode Html characters")] HtmlDecoding, diff --git a/src/utils.rs b/src/utils.rs index 06156be..ef4d419 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,49 +1,100 @@ -use std::str; +use std::borrow::Cow; use std::io::{self, Read}; +use std::time::Duration; -use reqwest::Client; -use reqwest::header::Connection; +use reqwest::header::{CONNECTION, HeaderValue}; +use reqwest::{Client, ClientBuilder}; -use failure::ResultExt; use self::error::{DownloadError, ErrorKind}; +use failure::ResultExt; + +#[derive(Clone, Debug)] +pub struct Url<'a> { + url: Cow<'a, str>, + max_kib: Option<usize>, + timeout: Option<Duration>, +} -/// Downloads the file and converts it to a String. -/// Any invalid bytes are converted to a replacement character. -/// -/// The error indicated either a failed download or that the DownloadLimit was reached -pub fn download(url: &str, max_kib: Option<usize>) -> Result<String, DownloadError> { - let mut response = Client::new() - .get(url) - .header(Connection::close()) - .send() - .context(ErrorKind::Connection)?; - - // 100 kibibyte buffer - let mut buf = [0; 100 * 1024]; - let mut written = 0; - let mut bytes = Vec::new(); - - // Read until we reach EOF or max_kib KiB - loop { - let len = match response.read(&mut buf) { - Ok(0) => break, - Ok(len) => len, - Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, - Err(e) => Err(e).context(ErrorKind::Read)?, +impl<'a> From<String> for Url<'a> { + fn from(url: String) -> Self { + Url { + url: Cow::from(url), + max_kib: None, + timeout: None, + } + } +} + +impl<'a> From<&'a str> for Url<'a> { + fn from(url: &'a str) -> Self { + Url { + url: Cow::from(url), + max_kib: None, + timeout: None, + } + } +} + +impl<'a> Url<'a> { + pub fn max_kib(mut self, limit: usize) -> Self { + self.max_kib = Some(limit); + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Downloads the file and converts it to a String. + /// Any invalid bytes are converted to a replacement character. + /// + /// The error indicated either a failed download or + /// that the limit set by max_kib() was reached. + pub fn request(&self) -> Result<String, DownloadError> { + let client = if let Some(timeout) = self.timeout { + ClientBuilder::new().timeout(timeout).build().unwrap() + } else { + Client::new() }; - bytes.extend_from_slice(&buf); - written += len; + let mut response = client + .get(self.url.as_ref()) + .header(CONNECTION, HeaderValue::from_static("close")) + .send() + .context(ErrorKind::Connection)?; - // Check if the file is too large to download - if let Some(max_kib) = max_kib { - if written > max_kib * 1024 { - Err(ErrorKind::DownloadLimit)?; + // 100 kibibyte buffer + let mut buf = [0; 100 * 1024]; + let mut written = 0; + let mut bytes = Vec::new(); + + // Read until we reach EOF or max_kib KiB + loop { + let len = match response.read(&mut buf) { + Ok(0) => break, + Ok(len) => len, + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => Err(e).context(ErrorKind::Read)?, + }; + + bytes.extend_from_slice(&buf[..len]); + written += len; + + // Check if the file is too large to download + if let Some(max_kib) = self.max_kib { + if written > max_kib * 1024 { + Err(ErrorKind::DownloadLimit)?; + } } } + + Ok(String::from_utf8_lossy(&bytes).into_owned()) } - Ok(String::from_utf8_lossy(&bytes).into_owned()) + pub fn as_str(&self) -> &str { + &self.url + } } pub mod error { |
