diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/error.rs | 4 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/plugins/help.rs | 9 | ||||
| -rw-r--r-- | src/plugins/mod.rs | 1 | ||||
| -rw-r--r-- | src/plugins/remind/database.rs | 128 | ||||
| -rw-r--r-- | src/plugins/remind/mod.rs | 290 | ||||
| -rw-r--r-- | src/plugins/remind/parser.rs | 250 |
7 files changed, 685 insertions, 3 deletions
diff --git a/src/error.rs b/src/error.rs index ce56a04..dc34eb7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,4 +32,8 @@ pub enum ErrorKind { /// A Factoids error #[fail(display = "A Factoids error has occured")] Factoids, + + /// A Remind error + #[fail(display = "A Remind error has occured")] + Remind, } diff --git a/src/main.rs b/src/main.rs index aae6035..3f56c50 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ use frippy::plugins::emoji::Emoji; use frippy::plugins::factoids::Factoids; use frippy::plugins::help::Help; use frippy::plugins::keepnick::KeepNick; +use frippy::plugins::remind::Remind; use frippy::plugins::sed::Sed; use frippy::plugins::tell::Tell; use frippy::plugins::url::UrlTitles; @@ -125,11 +126,14 @@ fn run() -> Result<(), Error> { let pool = Arc::new(pool); bot.add_plugin(Factoids::new(pool.clone())); bot.add_plugin(Tell::new(pool.clone())); + // TODO Use mysql pool + bot.add_plugin(Remind::new(HashMap::new())); info!("Connected to MySQL server") } Err(e) => { bot.add_plugin(Factoids::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); + bot.add_plugin(Remind::new(HashMap::new())); error!("Failed to run migrations: {}", e); } }, @@ -138,6 +142,7 @@ fn run() -> Result<(), Error> { } else { bot.add_plugin(Factoids::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); + bot.add_plugin(Remind::new(HashMap::new())); } } #[cfg(not(feature = "mysql"))] @@ -147,6 +152,7 @@ fn run() -> Result<(), Error> { } bot.add_plugin(Factoids::new(HashMap::new())); bot.add_plugin(Tell::new(HashMap::new())); + bot.add_plugin(Remind::new(HashMap::new())); } if let Some(disabled_plugins) = disabled_plugins { diff --git a/src/plugins/help.rs b/src/plugins/help.rs index a02931c..b75086f 100644 --- a/src/plugins/help.rs +++ b/src/plugins/help.rs @@ -26,9 +26,12 @@ impl Plugin for Help { fn command(&self, client: &IrcClient, command: PluginCommand) -> Result<(), FrippyError> { Ok(client - .send_notice(&command.source, "Available commands: help, currency, tell, factoids\r\n\ - For more detailed help call help on the specific command.\r\n\ - Example: 'currency help'") + .send_notice( + &command.source, + "Available commands: help, currency, tell, factoids, remind\r\n\ + For more detailed help call help on the specific command.\r\n\ + Example: 'currency help'", + ) .context(FrippyErrorKind::Connection)?) } diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index a8fc818..99e35db 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -4,6 +4,7 @@ pub mod emoji; pub mod factoids; pub mod help; pub mod keepnick; +pub mod remind; pub mod sed; pub mod tell; pub mod url; diff --git a/src/plugins/remind/database.rs b/src/plugins/remind/database.rs new file mode 100644 index 0000000..c0c127e --- /dev/null +++ b/src/plugins/remind/database.rs @@ -0,0 +1,128 @@ +#[cfg(feature = "mysql")] +extern crate dotenv; + +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::fmt; + +use chrono::NaiveDateTime; + +use super::error::*; + +#[derive(Clone, Debug)] +pub struct Event { + pub id: i64, + pub receiver: String, + pub content: String, + pub author: String, + pub time: NaiveDateTime, + pub repeat: Option<u64>, +} + +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 + ) + } +} + +#[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<u64>, +} + +pub trait Database: Send + Sync { + fn insert_event(&mut self, event: &NewEvent) -> Result<(), RemindError>; + fn update_event_time(&mut self, id: i64, &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 Database for HashMap<i64, Event> { + fn insert_event(&mut self, event: &NewEvent) -> Result<(), RemindError> { + let mut id = 0; + while self.contains_key(&id) { + id += 1; + } + + let event = Event { + id: id, + receiver: event.receiver.to_owned(), + content: event.content.to_owned(), + author: event.author.to_owned(), + time: event.time.clone(), + repeat: event.repeat, + }; + + match self.insert(id, event) { + None => Ok(()), + 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) + .map(|ev| ev.clone()) + .ok_or(ErrorKind::NotFound)?) + } + + fn delete_event(&mut self, id: i64) -> Result<(), RemindError> { + match self.remove(&id) { + Some(_) => Ok(()), + None => Err(ErrorKind::NotFound)?, + } + } +} diff --git a/src/plugins/remind/mod.rs b/src/plugins/remind/mod.rs new file mode 100644 index 0000000..0893589 --- /dev/null +++ b/src/plugins/remind/mod.rs @@ -0,0 +1,290 @@ +use antidote::RwLock; +use irc::client::prelude::*; +use std::thread::{sleep, spawn}; +use std::{fmt, sync::Arc, time::Duration}; + +use chrono::{self, NaiveDateTime}; +use time; + +use plugin::*; +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); + } + } + } + + debug!("Sleeping for {:?}", in_next); + sleep(in_next.to_std().expect("Failed to convert look ahead time")); + } +} + +fn run<T: Database>(client: &IrcClient, 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 as i64); + + if let Err(e) = db.write().update_event_time(event.id, &next_time) { + error!("Failed to update reminder: {}", e); + } else { + debug!("Updated time on: {:?}", event); + } + } 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); + } + } + } + + debug!("Sleeping for {:?}", sleep_time); + sleep(sleep_time); + sleep_time = Duration::from_secs(120); + + events = get_events(&db, look_ahead); + } +} + +#[derive(PluginName)] +pub struct Remind<T: 'static + Database> { + events: Arc<RwLock<T>>, + has_reminder: RwLock<bool>, +} + +impl<T: 'static + Database> Remind<T> { + pub fn new(db: T) -> Self { + let events = Arc::new(RwLock::new(db)); + + Remind { + events: events, + has_reminder: RwLock::new(false), + } + } + + fn set(&self, command: PluginCommand) -> Result<&str, RemindError> { + let parser = CommandParser::try_from_tokens(command.tokens)?; + debug!("parser: {:?}", parser); + + let mut target = parser.get_target(); + if target == "me" { + target = &command.source; + } + + let event = database::NewEvent { + receiver: target, + content: &parser.get_message(), + author: &command.source, + time: &parser.get_time(Duration::from_secs(120))?, + repeat: parser + .get_repeat(Duration::from_secs(300))? + .map(|d| d.as_secs()), + }; + + debug!("New event: {:?}", event); + + Ok(self.events.write().insert_event(&event).map(|()| "Got it")?) + } + + fn list(&self, user: &str) -> Result<String, RemindError> { + let mut events = self.events.read().get_user_events(user)?; + + 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)?; + + 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: new, list, delete, help" + } +} + +impl<T: Database> Plugin for Remind<T> { + fn execute(&self, client: &IrcClient, _: &Message) -> ExecutionStatus { + 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, _: &IrcClient, _: &Message) -> Result<(), FrippyError> { + panic!("Remind should not use frippy's threading") + } + + fn command(&self, client: &IrcClient, mut command: PluginCommand) -> Result<(), FrippyError> { + if command.tokens.is_empty() { + return Ok(client + .send_notice(&command.source, &ErrorKind::InvalidCommand.to_string()) + .context(FrippyErrorKind::Connection)?); + } + + let source = command.source.clone(); + + let sub_command = command.tokens.remove(0); + let response = match sub_command.as_ref() { + "new" => self.set(command).map(|s| s.to_owned()), + "delete" => self.delete(command).map(|s| s.to_owned()), + "list" => self.list(&source), + "help" => Ok(self.help().to_owned()), + _ => Err(ErrorKind::InvalidCommand.into()), + }; + + let result = 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(result) + } + + fn evaluate(&self, _: &IrcClient, _: PluginCommand) -> Result<String, String> { + Err(String::from( + "Evaluation of commands is not implemented for remind at this time", + )) + } +} + +impl<T: Database> fmt::Debug for Remind<T> { + 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 \"currency 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, + } +} diff --git a/src/plugins/remind/parser.rs b/src/plugins/remind/parser.rs new file mode 100644 index 0000000..2dbb040 --- /dev/null +++ b/src/plugins/remind/parser.rs @@ -0,0 +1,250 @@ +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 try_from_tokens(tokens: Vec<String>) -> Result<Self, RemindError> { + if tokens.is_empty() { + return Err(ErrorKind::MissingReceiver.into()); + } + + let mut parser = CommandParser::default(); + let mut state = ParseState::None; + + let mut iter = tokens.into_iter(); + parser.target = iter.next() + .expect("This should be guaranteed by the length check"); + + let mut cur_str = String::new(); + while let Some(token) = iter.next() { + 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 { + parser = parser.add_string_by_state(&state, cur_str)?; + cur_str = String::new(); + } + + state = next_state; + } + } + parser = parser.add_string_by_state(&state, cur_str)?; + + if parser.message.is_none() { + return Err(ErrorKind::MissingMessage.into()); + } + + if parser.in_duration.is_some() && parser.at_time.is_some() + || parser.in_duration.is_some() && parser.on_date.is_some() + { + return Err(ErrorKind::AmbiguousTime.into()); + } + + if parser.in_duration.is_none() && parser.at_time.is_none() && parser.on_date.is_none() { + return Err(ErrorKind::MissingTime.into()); + } + + Ok(parser) + } + + 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() => { + return Ok(CommandParser { + on_date: string, + ..self + }) + } + &At if self.at_time.is_none() => { + return Ok(CommandParser { + at_time: string, + ..self + }) + } + &In if self.in_duration.is_none() => { + return Ok(CommandParser { + in_duration: string, + ..self + }) + } + &Msg if self.message.is_none() => { + return Ok(CommandParser { + message: string, + ..self + }) + } + &Every if self.every_time.is_none() => { + return 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") + } +} |
