pub mod builder; pub mod content; pub mod errors; mod helper; use std::{ collections::HashSet, hash::{Hash, Hasher}, }; use chrono::{DateTime, NaiveDateTime, Utc}; use chrono_tz::Tz; use poise::{ serenity_prelude::{ model::id::{ChannelId, GuildId, UserId}, ButtonStyle, Cache, ChannelType, CreateActionRow, CreateButton, CreateEmbed, ReactionType, }, CreateReply, }; use sqlx::Executor; use crate::{ commands::look::{LookFlags, TimeDisplayType}, component_models::{ComponentDataModel, UndoReminder}, consts::{REGEX_CHANNEL_USER, THEME_COLOR}, interval_parser::parse_duration, models::{ reminder::{ builder::{ChannelWithThread, MultiReminderBuilder, ReminderScope}, content::Content, errors::ReminderError, }, CtxData, }, time_parser::natural_parser, utils::{check_guild_subscription, check_subscription}, Context, Database, Error, }; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct Reminder { pub id: u32, pub uid: String, pub channel: u64, pub utc_time: DateTime, pub interval_seconds: Option, pub interval_days: Option, pub interval_months: Option, pub expires: Option, pub enabled: bool, pub content: String, pub embed_description: String, pub set_by: Option, } impl Hash for Reminder { fn hash(&self, state: &mut H) { self.uid.hash(state); } } impl PartialEq for Reminder { fn eq(&self, other: &Self) -> bool { self.uid == other.uid } } impl Eq for Reminder {} impl Reminder { pub async fn from_uid(pool: impl Executor<'_, Database = Database>, uid: &str) -> Option { sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders INNER JOIN channels ON reminders.channel_id = channels.id WHERE reminders.uid = ? ", uid ) .fetch_one(pool) .await .ok() } pub async fn from_id(pool: impl Executor<'_, Database = Database>, id: u32) -> Option { sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders INNER JOIN channels ON reminders.channel_id = channels.id WHERE reminders.id = ? ", id ) .fetch_one(pool) .await .ok() } pub async fn from_channel>( pool: impl Executor<'_, Database = Database>, channel_id: C, flags: &LookFlags, ) -> Vec { let enabled = if flags.show_disabled { "0,1" } else { "1" }; let channel_id = channel_id.into(); sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders INNER JOIN channels ON reminders.channel_id = channels.id WHERE `status` = 'pending' AND channels.channel = ? AND FIND_IN_SET(reminders.enabled, ?) ORDER BY reminders.utc_time ", channel_id.get(), enabled, ) .fetch_all(pool) .await .unwrap() } pub async fn from_guild( cache: impl AsRef, pool: impl Executor<'_, Database = Database>, guild_id: Option, user: UserId, ) -> Vec { if let Some(guild_id) = guild_id { let channel_query = if let Some(guild) = guild_id.to_guild_cached(&cache) { Some( guild .channels .keys() .into_iter() .map(|k| k.get().to_string()) .collect::>() .join(","), ) } else { None }; match channel_query { Some(channel_query) => { sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders LEFT JOIN channels ON channels.id = reminders.channel_id WHERE `status` = 'pending' AND FIND_IN_SET(channels.channel, ?) ", channel_query ) .fetch_all(pool) .await } None => { sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders LEFT JOIN channels ON channels.id = reminders.channel_id WHERE `status` = 'pending' AND channels.guild_id = (SELECT id FROM guilds WHERE guild = ?) ", guild_id.get() ) .fetch_all(pool) .await } } } else { sqlx::query_as_unchecked!( Self, " SELECT reminders.id, reminders.uid, channels.channel, reminders.utc_time, reminders.interval_seconds, reminders.interval_days, reminders.interval_months, reminders.expires, reminders.enabled, reminders.content, reminders.embed_description, reminders.set_by FROM reminders INNER JOIN channels ON channels.id = reminders.channel_id WHERE `status` = 'pending' AND channels.id = (SELECT dm_channel FROM users WHERE id = ?) ", user.get() ) .fetch_all(pool) .await } .unwrap() } pub async fn delete( &self, db: impl Executor<'_, Database = Database>, ) -> Result<(), sqlx::Error> { sqlx::query!( " UPDATE reminders SET `status` = 'deleted' WHERE uid = ? ", self.uid ) .execute(db) .await .map(|_| ()) } pub fn display_content(&self) -> &str { if self.content.is_empty() { &self.embed_description } else { &self.content } } pub fn display_del(&self, count: usize, timezone: &Tz) -> String { format!( "**{}**: '{}' *<#{}>* at **{}**", count + 1, self.display_content(), self.channel, self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S") ) } pub fn display(&self, flags: &LookFlags, timezone: &Tz) -> String { let time_display = match flags.time_display { TimeDisplayType::Absolute => { self.utc_time.with_timezone(timezone).format("%Y-%m-%d %H:%M:%S").to_string() } TimeDisplayType::Relative => format!("", self.utc_time.timestamp()), }; if self.interval_seconds.is_some() || self.interval_days.is_some() || self.interval_months.is_some() { format!( "'{}' *occurs next at* **{}**, repeating (set by {})\n", self.display_content(), time_display, self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) ) } else { format!( "'{}' *occurs next at* **{}** (set by {})\n", self.display_content(), time_display, self.set_by.map(|i| format!("<@{}>", i)).unwrap_or_else(|| "unknown".to_string()) ) } } } pub async fn create_reminder( ctx: Context<'_>, time: String, content: String, channels: Option, interval: Option, expires: Option, tts: Option, timezone: Option, ) -> Result<(), Error> { fn parse_mention_list(mentions: &str) -> Vec { REGEX_CHANNEL_USER .captures_iter(mentions) .map(|i| { let pref = i.get(1).unwrap().as_str(); let id = i.get(2).unwrap().as_str().parse::().unwrap(); if pref == "#" { let channel_with_thread = ChannelWithThread { channel_id: id, thread_id: None }; ReminderScope::Channel(channel_with_thread) } else { ReminderScope::User(id) } }) .collect::>() } fn create_response( successes: &HashSet<(Reminder, ReminderScope)>, errors: &HashSet, time: i64, ) -> CreateEmbed { let success_part = match successes.len() { 0 => "".to_string(), n => format!( "Reminder{s} for {locations} set for ", s = if n > 1 { "s" } else { "" }, locations = successes.iter().map(|(_, l)| l.mention()).collect::>().join(", "), offset = time ), }; let error_part = match errors.len() { 0 => "".to_string(), n => format!( "{n} reminder{s} failed to set:\n{errors}", s = if n > 1 { "s" } else { "" }, n = n, errors = errors.iter().map(|e| e.to_string()).collect::>().join("\n") ), }; CreateEmbed::default() .title(format!( "{n} Reminder{s} Set", n = successes.len(), s = if successes.len() > 1 { "s" } else { "" } )) .description(format!("{}\n\n{}", success_part, error_part)) .color(*THEME_COLOR) } if interval.is_none() && expires.is_some() { ctx.say("`expires` can only be used with `interval`").await?; return Ok(()); } let ephemeral = ctx.guild_data().await.map_or(false, |gr| gr.map_or(false, |g| g.ephemeral_confirmations)); if ephemeral { ctx.defer_ephemeral().await?; } else { ctx.defer().await?; } let user_data = ctx.author_data().await.unwrap(); let timezone = timezone.unwrap_or(ctx.timezone().await); let time = natural_parser(&time, &timezone.to_string()).await; match time { Some(time) => { let content = { let tts = tts.unwrap_or(false); Content { content, tts, attachment: None, attachment_name: None } }; let scopes = { let list = channels.map(|arg| parse_mention_list(&arg)).unwrap_or_default(); if list.is_empty() { if let Some(channel) = ctx.guild_channel().await { if channel.kind == ChannelType::PublicThread || channel.kind == ChannelType::PrivateThread { let parent = channel.parent_id.unwrap(); let channel_with_threads = ChannelWithThread { channel_id: parent.get(), thread_id: Some(ctx.channel_id().get()), }; vec![ReminderScope::Channel(channel_with_threads)] } else { let channel_with_threads = ChannelWithThread { channel_id: ctx.channel_id().get(), thread_id: None, }; vec![ReminderScope::Channel(channel_with_threads)] } } else { vec![ReminderScope::User(ctx.author().id.get())] } } else { list } }; let (processed_interval, processed_expires) = if let Some(repeat) = &interval { if check_subscription(&ctx, ctx.author().id).await || (ctx.guild_id().is_some() && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) { ( parse_duration(repeat) .or_else(|_| parse_duration(&format!("1 {}", repeat))) .ok(), { if let Some(arg) = &expires { natural_parser(arg, &timezone.to_string()).await } else { None } }, ) } else { ctx.send(CreateReply::default().content( "`repeat` is only available to Patreon subscribers or self-hosted users", )) .await?; return Ok(()); } } else { (None, None) }; if processed_interval.is_none() && interval.is_some() { ctx.send(CreateReply::default().content( "Repeat interval could not be processed. Try similar to `1 hour` or `4 days`", )) .await?; } else if processed_expires.is_none() && expires.is_some() { ctx.send( CreateReply::default().ephemeral(true).content( "Expiry time failed to process. Please make it as clear as possible", ), ) .await?; } else { let mut builder = MultiReminderBuilder::new(&ctx, ctx.guild_id()) .author(user_data) .content(content) .time(time) .timezone(timezone) .expires(processed_expires) .interval(processed_interval); builder.set_scopes(scopes); let (errors, successes) = builder.build().await; let embed = create_response(&successes, &errors, time); if successes.len() == 1 { let reminder = successes.iter().next().map(|(r, _)| r.id).unwrap(); let undo_button = ComponentDataModel::UndoReminder(UndoReminder { user_id: ctx.author().id, reminder_id: reminder, }); ctx.send(CreateReply::default().embed(embed).components(vec![ CreateActionRow::Buttons(vec![ CreateButton::new(undo_button.to_custom_id()) .emoji(ReactionType::Unicode("🔕".to_string())) .label("Cancel") .style(ButtonStyle::Danger), CreateButton::new_link("https://beta.reminder-bot.com/dashboard") .emoji(ReactionType::Unicode("📝".to_string())) .label("Edit"), ]), ])) .await?; } else { ctx.send(CreateReply::default().embed(embed)).await?; } } } None => { ctx.say("Time could not be processed").await?; } } Ok(()) }