From 8fb01ba1a45e7615bcc9550d809d8dfd117e73cd Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Thu, 28 Jan 2021 16:31:07 -0600 Subject: [PATCH] Added reminder system + Reminders are stored in the Reminders struct + Every 5 seconds, a async task is run to check if any reminders need to be sent + Events are not pulled from the database until a reminder needs to be sent --- src/database/mod.rs | 9 ++ src/database/models.rs | 2 +- src/discord/events.rs | 32 ++---- src/discord/mod.rs | 219 ++++++++++++++++++++++++++++++----------- src/main.rs | 101 +++---------------- src/reminder/mod.rs | 156 +++++++++++++++++++++++++++++ 6 files changed, 344 insertions(+), 175 deletions(-) create mode 100644 src/reminder/mod.rs diff --git a/src/database/mod.rs b/src/database/mod.rs index 746e8f9..6a0019c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -64,3 +64,12 @@ pub fn get_all_events(database_url: String) -> Result, Error> { events.order(event_time).load(&connection) } + +/// Get event with id +pub fn get_event_by_id(database_url: String, event_id: i32) -> Result { + use schema::events::dsl::{events, id}; + + let connection = establish_connection(database_url); + + events.filter(id.eq(event_id)).first::(&connection) +} diff --git a/src/database/models.rs b/src/database/models.rs index ebcfe9f..c368b51 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -1,7 +1,7 @@ use super::schema::events; use chrono::{NaiveDateTime, Utc}; -#[derive(Queryable, Clone, Debug)] +#[derive(Queryable, Clone, Debug, Hash)] pub struct Event { /// Event ID pub id: i32, diff --git a/src/discord/events.rs b/src/discord/events.rs index 0b9b7d8..ae3c512 100644 --- a/src/discord/events.rs +++ b/src/discord/events.rs @@ -1,9 +1,8 @@ use super::{get_config, send_event_msg}; -use crate::database::{get_event_by_name, insert_event, remove_event}; +use crate::database::{get_event_by_name, insert_event}; use crate::discord::{ - get_draft_event, schedule_event, send_dm_message, send_draft_event, update_draft_event, + delete_event, get_draft_event, schedule_event, send_draft_event, update_draft_event, }; -use crate::INTERESTED_EMOJI; use chrono::offset::TimeZone; use chrono::{Datelike, NaiveDateTime, Timelike, Utc}; use chrono_tz::Tz; @@ -179,30 +178,11 @@ async fn cancel(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let event_name = args.single::()?.replace("\"", ""); let event = get_event_by_name(config.db_url.clone(), event_name)?; - let message_id = event.message_id.parse::()?; - let message = ctx - .http - .get_message(config.event_channel, message_id) + + delete_event(ctx, &event).await?; + + msg.reply(&ctx, format!("{} has been cancelled", event.event_name)) .await?; - let cancel_msg = format!("**{}** has been canceled!", event.event_name.clone()); - - // Only send a cancel message if the even has not already happened - if event.event_time > Utc::now().naive_utc() { - if let Ok(reaction_users) = message - .reaction_users(&ctx.http, INTERESTED_EMOJI, None, None) - .await - { - for user in reaction_users { - send_dm_message(ctx, user, &cancel_msg).await; - } - } - } - - remove_event(config.db_url.clone(), event.id)?; - - message.delete(&ctx).await?; - - msg.reply(&ctx, &cancel_msg).await?; Ok(()) } diff --git a/src/discord/mod.rs b/src/discord/mod.rs index acca503..b0a55ad 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -1,21 +1,29 @@ -use crate::database::models::{Event, NewEvent}; -use crate::database::{get_event_by_msg_id, remove_event}; -use crate::hypebot_config::HypeBotConfig; -use crate::{INTERESTED_EMOJI, UNINTERESTED_EMOJI}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + use chrono::{DateTime, NaiveDateTime, Utc}; +use serenity::async_trait; +use serenity::framework::standard::macros::help; use serenity::framework::standard::macros::hook; -use serenity::framework::standard::{CommandError, CommandResult}; -use serenity::http::Http; +use serenity::framework::standard::{ + help_commands, Args, CommandError, CommandGroup, CommandResult, HelpOptions, +}; +use serenity::model::gateway::Ready; +use serenity::model::id::UserId; use serenity::model::prelude::{ChannelId, Message, Reaction, User}; -use serenity::prelude::{Context, RwLock}; +use serenity::prelude::{Context, EventHandler}; use serenity::prelude::{TypeMap, TypeMapKey}; use serenity::utils::Colour; use serenity::Result; -use std::collections::HashMap; -use std::sync::Arc; use strfmt::strfmt; use tokio::sync::RwLockReadGuard; +use crate::database::models::{Event, NewEvent}; +use crate::database::{get_all_events, get_event_by_id, get_event_by_msg_id, remove_event}; +use crate::hypebot_config::HypeBotConfig; +use crate::reminder::Reminders; +use crate::{INTERESTED_EMOJI, UNINTERESTED_EMOJI}; + pub mod events; /// Struct for storing drafted events @@ -251,56 +259,53 @@ pub async fn schedule_event(ctx: &Context, event: &Event) { .await .expect("Unable to get config"); - if let Some(reminders) = &config.reminders { - let event_time: DateTime = DateTime::::from_utc(event.event_time, Utc); + let mut data = ctx.data.write().await; - for reminder in reminders { - let reminder_time = - event_time - chrono::Duration::minutes(reminder.reminder_time as i64); + let reminders = data.get_mut::().unwrap(); - if reminder_time > chrono::offset::Utc::now() { - let ctx = ctx.clone(); - let reminder_msg = reminder.msg.clone(); - let event = event.clone(); - - tokio::task::spawn(async move { - send_reminders_task(&ctx, reminder_time, &event, &reminder_msg).await; - }); - } - } - } + reminders.add_reminders(event, &*config); } /// Send reminders -pub async fn send_reminders_task( - ctx: &Context, - datetime: DateTime, - event: &Event, - reminder_msg: &str, -) { - let duration = datetime - chrono::offset::Utc::now(); - tokio::time::sleep(tokio::time::Duration::from_millis( - duration.num_milliseconds() as u64, - )) - .await; - let config = get_config(&ctx.data.read().await) - .await - .expect("Unable to get config"); - let event_channel_id = config.event_channel; +pub async fn send_reminders_task(ctx: &Context) { + let duration = tokio::time::Duration::from_secs(1); + loop { + tokio::time::sleep(duration).await; + let config = get_config(&ctx.data.read().await) + .await + .expect("Unable to get config"); + let event_channel_id = config.event_channel; - if let Ok(message_id) = event.message_id.parse::() { - // Get message id - if let Ok(message) = ctx.http.get_message(event_channel_id, message_id).await { - if let Ok(reaction_users) = message - .reaction_users(ctx, INTERESTED_EMOJI, None, None) - .await - { - // Build reminder message - let msg: String = reminder_msg.replace("{EVENT_NAME}", event.event_name.as_str()); + let reminder_msg: Vec<&String> = match &config.reminders { + None => return, + Some(reminder_msg) => reminder_msg.iter().map(|r| &r.msg).collect(), + }; - // Send reminder to each reacted user - for user in reaction_users { - send_dm_message(ctx, user, &msg).await; + let mut data = ctx.data.write().await; + let reminders = data + .get_mut::() + .unwrap(); + + let current_reminders = reminders.get_reminders(); + + for reminder in current_reminders { + if let Ok(event) = get_event_by_id(config.db_url.clone(), reminder.event_id) { + let message_id = event.message_id.parse::().unwrap_or_default(); + // Get message id + if let Ok(message) = ctx.http.get_message(event_channel_id, message_id).await { + if let Ok(reaction_users) = message + .reaction_users(ctx, INTERESTED_EMOJI, None, None) + .await + { + // Build reminder message + let msg: String = reminder_msg[reminder.reminder_id] + .replace("{EVENT_NAME}", event.event_name.as_str()); + + // Send reminder to each reacted user + for user in reaction_users { + send_dm_message(ctx, user, &msg).await; + } + } } } } @@ -308,16 +313,112 @@ pub async fn send_reminders_task( } /// Delete event -#[allow(dead_code)] -pub async fn delete_event(http: &Arc, data: &Arc>, event: &Event) { - let config = get_config(&data.read().await) +pub async fn delete_event(ctx: &Context, event: &Event) -> CommandResult { + let config = get_config(&ctx.data.read().await) .await .expect("Unable to get config"); - remove_event(config.db_url.clone(), event.id).ok(); - if let Ok(message_id) = event.message_id.parse::() { - http.delete_message(config.event_channel, message_id) + let message_id = event.message_id.parse::()?; + let message = ctx + .http + .get_message(config.event_channel, message_id) + .await?; + let cancel_msg = format!("**{}** has been canceled!", event.event_name.clone()); + + // Only send a cancel message if the even has not already happened + if event.event_time > Utc::now().naive_utc() { + if let Ok(reaction_users) = message + .reaction_users(&ctx.http, INTERESTED_EMOJI, None, None) .await - .ok(); + { + for user in reaction_users { + send_dm_message(ctx, user, &cancel_msg).await; + } + } + } + + remove_event(config.db_url.clone(), event.id)?; + + message.delete(ctx).await?; + + if let Some(reminders) = ctx.data.write().await.get_mut::() { + reminders.remove_reminders(&event); + } + + Ok(()) +} + +#[help] +#[command_not_found_text = "Could not find: `{}`."] +#[strikethrough_commands_tip_in_guild("")] +async fn bot_help( + context: &Context, + msg: &Message, + args: Args, + help_options: &'static HelpOptions, + groups: &[&'static CommandGroup], + owners: HashSet, +) -> CommandResult { + help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; + Ok(()) +} + +/// Handler for Discord events +pub struct Handler; + +#[async_trait] +impl EventHandler for Handler { + /// On reaction add + async fn reaction_add(&self, ctx: Context, reaction: Reaction) { + let config = get_config(&ctx.data.read().await) + .await + .expect("Unable to get config"); + if reaction.channel_id.0 == config.event_channel + && reaction.emoji.as_data().chars().next().unwrap() == INTERESTED_EMOJI + { + send_message_to_reaction_users( + &ctx, + &reaction, + "Hello, you are now receiving reminders for **{event}**", + ) + .await; + } + } + + /// On reaction remove + async fn reaction_remove(&self, ctx: Context, reaction: Reaction) { + let config = get_config(&ctx.data.read().await) + .await + .expect("Unable to get config"); + + if reaction.channel_id.0 == config.event_channel + && reaction.emoji.as_data().chars().next().unwrap() == INTERESTED_EMOJI + { + send_message_to_reaction_users( + &ctx, + &reaction, + "Hello, you are no longer receiving reminders for **{event}**", + ) + .await; + } + } + + /// On bot ready + async fn ready(&self, ctx: Context, ready: Ready) { + info!("Connected to Discord as {}", ready.user.name); + + // Schedule current events + let config = get_config(&ctx.data.read().await) + .await + .expect("Unable to get config"); + for event in get_all_events(config.db_url.clone()).unwrap() { + if event.reminder_sent == 0 { + schedule_event(&ctx, &event).await; + } + } + + tokio::spawn(async move { + send_reminders_task(&ctx).await; + }); } } diff --git a/src/main.rs b/src/main.rs index 3fcd406..a03cf0f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,9 +7,14 @@ extern crate log; extern crate log4rs; extern crate serde; -use std::collections::HashSet; +mod database; +mod discord; +mod hypebot_config; +mod reminder; + use std::path::Path; use std::process::exit; +use std::sync::Arc; use clap::{App, Arg}; use log::LevelFilter; @@ -22,28 +27,16 @@ use log4rs::config::{Appender, Config, Root}; use log4rs::encode::pattern::PatternEncoder; use log4rs::filter::threshold::ThresholdFilter; use log4rs::init_config; -use serenity::async_trait; use serenity::client::Client; -use serenity::framework::standard::macros::{group, help}; -use serenity::framework::standard::{help_commands, Args, CommandGroup, HelpOptions}; -use serenity::framework::standard::{CommandResult, StandardFramework}; -use serenity::model::channel::{Message, Reaction}; -use serenity::model::id::UserId; -use serenity::model::prelude::Ready; -use serenity::prelude::{Context, EventHandler}; +use serenity::framework::standard::macros::group; +use serenity::framework::standard::StandardFramework; use database::*; use discord::events::{CANCEL_COMMAND, CONFIRM_COMMAND, CREATE_COMMAND}; -use discord::{ - get_config, log_error, permission_check, schedule_event, send_message_to_reaction_users, - DraftEvent, -}; +use discord::BOT_HELP; +use discord::{log_error, permission_check, DraftEvent, Handler}; use hypebot_config::HypeBotConfig; -use std::sync::Arc; - -mod database; -mod discord; -mod hypebot_config; +use reminder::Reminders; const INTERESTED_EMOJI: char = '\u{2705}'; const UNINTERESTED_EMOJI: char = '\u{274C}'; @@ -57,77 +50,6 @@ type HypeBotResult = std::result::Result>; #[commands(create, confirm, cancel)] struct EventCommands; -/// Handler for Discord events -struct Handler; - -#[async_trait] -impl EventHandler for Handler { - /// On reaction add - async fn reaction_add(&self, ctx: Context, reaction: Reaction) { - let config = get_config(&ctx.data.read().await) - .await - .expect("Unable to get config"); - if reaction.channel_id.0 == config.event_channel - && reaction.emoji.as_data().chars().next().unwrap() == INTERESTED_EMOJI - { - send_message_to_reaction_users( - &ctx, - &reaction, - "Hello, you are now receiving reminders for **{event}**", - ) - .await; - } - } - - /// On reaction remove - async fn reaction_remove(&self, ctx: Context, reaction: Reaction) { - let config = get_config(&ctx.data.read().await) - .await - .expect("Unable to get config"); - - if reaction.channel_id.0 == config.event_channel - && reaction.emoji.as_data().chars().next().unwrap() == INTERESTED_EMOJI - { - send_message_to_reaction_users( - &ctx, - &reaction, - "Hello, you are no longer receiving reminders for **{event}**", - ) - .await; - } - } - - /// On bot ready - async fn ready(&self, ctx: Context, ready: Ready) { - info!("Connected to Discord as {}", ready.user.name); - - // Schedule current events - let config = get_config(&ctx.data.read().await) - .await - .expect("Unable to get config"); - for event in get_all_events(config.db_url.clone()).unwrap() { - if event.reminder_sent == 0 { - schedule_event(&ctx, &event).await; - } - } - } -} - -#[help] -#[command_not_found_text = "Could not find: `{}`."] -#[strikethrough_commands_tip_in_guild("")] -async fn bot_help( - context: &Context, - msg: &Message, - args: Args, - help_options: &'static HelpOptions, - groups: &[&'static CommandGroup], - owners: HashSet, -) -> CommandResult { - help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; - Ok(()) -} - /// Does the setup for logging fn setup_logging(config: &HypeBotConfig) -> HypeBotResult<()> { // Build log file path @@ -245,6 +167,7 @@ async fn main() -> HypeBotResult<()> { let mut data = client.data.write().await; data.insert::(Arc::new(cfg)); data.insert::(Arc::new(DraftEvent::default())); + data.insert::(Reminders::default()); } // Start bot diff --git a/src/reminder/mod.rs b/src/reminder/mod.rs new file mode 100644 index 0000000..4555bcd --- /dev/null +++ b/src/reminder/mod.rs @@ -0,0 +1,156 @@ +use crate::database::models::Event; +use crate::hypebot_config::HypeBotConfig; +use chrono::{Duration, NaiveDateTime, Utc}; +use serenity::prelude::TypeMapKey; + +/// Event Reminder +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub struct Reminder { + pub event_id: i32, + pub time: NaiveDateTime, + pub reminder_id: usize, +} + +/// Set of reminders for currently active events +#[derive(Debug, Clone, Default)] +pub struct Reminders { + reminders: Vec, +} + +impl TypeMapKey for Reminders { + type Value = Reminders; +} + +impl Reminders { + /// Add all the reminders for an event + pub fn add_reminders(&mut self, event: &Event, config: &HypeBotConfig) { + if let Some(event_reminders) = &config.reminders { + for (reminder_id, event_reminder) in event_reminders.iter().enumerate() { + let reminder_time = event.event_time + - chrono::Duration::minutes(event_reminder.reminder_time as i64); + + if reminder_time > Utc::now().naive_utc() { + self.reminders.push(Reminder { + event_id: event.id, + time: reminder_time, + reminder_id, + }); + } + } + } + } + + /// Get reminders that need to be sent + pub fn get_reminders(&mut self) -> Vec { + let reminders: Vec = self + .reminders + .iter() + .filter(|r| { + let time_diff = r.time - Utc::now().naive_utc(); + time_diff > Duration::seconds(-5) && time_diff < Duration::seconds(5) + }) + .copied() + .collect(); + + self.reminders.retain(|r| !reminders.contains(r)); + + reminders + } + + /// Removes reminders for an event + pub fn remove_reminders(&mut self, event: &Event) { + self.reminders.retain(|e| e.event_id != event.id) + } + + /// Update reminders for an event + #[allow(dead_code)] + pub fn update_reminders(&mut self, event: &Event, config: &HypeBotConfig) { + self.remove_reminders(event); + self.add_reminders(event, config); + } +} + +#[cfg(test)] +mod tests { + use crate::hypebot_config::{EventReminder, HypeBotConfig}; + use crate::models::Event; + use crate::reminder::Reminders; + use chrono::{Duration, Utc}; + + fn setup() -> (Reminders, HypeBotConfig, Event) { + let r = Reminders::default(); + + let c = HypeBotConfig { + db_url: "".to_string(), + default_thumbnail_link: "".to_string(), + discord_key: "".to_string(), + prefix: "".to_string(), + event_channel: 0, + event_roles: vec![], + ping_roles: vec![], + event_timezone: chrono_tz::UTC, + log_path: "".to_string(), + reminders: Some( + [EventReminder { + msg: "".to_string(), + reminder_time: 5, + }, + EventReminder { + msg: "".to_string(), + reminder_time: 1, + }] + .to_vec(), + ), + }; + + let e = Event { + id: 0, + event_name: "".to_string(), + event_desc: "".to_string(), + event_loc: "".to_string(), + organizer: "".to_string(), + event_time: Utc::now().naive_utc() + Duration::minutes(5) + Duration::seconds(5), + message_id: "".to_string(), + thumbnail_link: "".to_string(), + reminder_sent: 0, + }; + + (r, c, e) + } + + #[test] + fn test_add_reminders() { + let (mut r, c, e) = setup(); + r.add_reminders(&e, &c); + + assert_eq!(r.reminders.len(), c.reminders.unwrap().len()); + } + + #[test] + fn test_get_reminders() { + let (mut r, c, e) = setup(); + r.add_reminders(&e, &c); + + assert_eq!(r.get_reminders().len(), 1); + assert_eq!(r.reminders.len(), c.reminders.unwrap().len()-1); + } + + #[test] + fn test_remove_reminders() { + let (mut r, c, e) = setup(); + r.add_reminders(&e, &c); + + r.remove_reminders(&e); + assert_eq!(r.reminders.len(), 0); + } + + #[test] + fn test_update_reminders() { + let (mut r, c, e) = setup(); + r.add_reminders(&e, &c); + + r.update_reminders(&e, &c); + + assert_eq!(r.get_reminders().len(), 1); + } +}