diff options
| author | Jokler <jokler.contact@gmail.com> | 2019-06-22 15:51:21 +0200 |
|---|---|---|
| committer | Jokler <jokler.contact@gmail.com> | 2019-06-22 15:51:21 +0200 |
| commit | 3592c7b6fb2522ff57c7f312b8927eb680d6dc5c (patch) | |
| tree | d484a367c205afe43ba7327a888b06844fd24c0c /src/plugins/remind | |
| parent | 237f6ebe59c90d4ceddd9af6a8a19e562d304aaa (diff) | |
| parent | a92e622a0d42911e8e46239c3bde17169ed60c92 (diff) | |
| download | frippy-3592c7b6fb2522ff57c7f312b8927eb680d6dc5c.tar.gz frippy-3592c7b6fb2522ff57c7f312b8927eb680d6dc5c.zip | |
Diffstat (limited to 'src/plugins/remind')
| -rw-r--r-- | src/plugins/remind/database.rs | 236 | ||||
| -rw-r--r-- | src/plugins/remind/mod.rs | 337 | ||||
| -rw-r--r-- | src/plugins/remind/parser.rs | 251 |
3 files changed, 824 insertions, 0 deletions
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") + } +} |
