diff --git a/Commands/GlobalCmds.cs b/Commands/HelpCmds.cs similarity index 76% rename from Commands/GlobalCmds.cs rename to Commands/HelpCmds.cs index b06ecd1c..6b01d523 100644 --- a/Commands/GlobalCmds.cs +++ b/Commands/HelpCmds.cs @@ -1,11 +1,9 @@ -using System.Reflection; +using System.Reflection; namespace Cliptok.Commands { - public class GlobalCmds + public class HelpCmds { - // These commands will be registered outside of the home server and can be used anywhere, even in DMs. - // Most of this is taken from DSharpPlus.CommandsNext and adapted to fit here. // https://github.com/DSharpPlus/DSharpPlus/blob/1c1aa15/DSharpPlus.CommandsNext/CommandsNextExtension.cs#L829 [Command("helptextcmd"), Description("Displays command help.")] @@ -223,105 +221,6 @@ await ctx.RespondAsync( await ctx.RespondAsync(builder); } - [Command("pingtextcmd")] - [TextAlias("ping")] - [Description("Pong? This command lets you know whether I'm working well.")] - [AllowedProcessors(typeof(TextCommandProcessor))] - public async Task Ping(TextCommandContext ctx) - { - ctx.Client.Logger.LogDebug(ctx.Client.GetConnectionLatency(Program.cfgjson.ServerID).ToString()); - DiscordMessage return_message = await ctx.Message.RespondAsync("Pinging..."); - ulong ping = (return_message.Id - ctx.Message.Id) >> 22; - char[] choices = new char[] { 'a', 'e', 'o', 'u', 'i', 'y' }; - char letter = choices[Program.rand.Next(0, choices.Length)]; - await return_message.ModifyAsync($"P{letter}ng! 🏓\n" + - $"• It took me `{ping}ms` to reply to your message!\n" + - $"• Last Websocket Heartbeat took `{Math.Round(ctx.Client.GetConnectionLatency(0).TotalMilliseconds, 0)}ms`!"); - } - - [Command("userinfo")] - [TextAlias("user-info", "whois")] - [Description("Show info about a user.")] - [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] - public async Task UserInfoSlashCommand(CommandContext ctx, [Parameter("user"), Description("The user to retrieve information about.")] DiscordUser user = null, [Parameter("public"), Description("Whether to show the output publicly.")] bool publicMessage = false) - { - if (user is null) - user = ctx.User; - - await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(user, ctx.Guild), ephemeral: !publicMessage); - } - - [Command("remindmetextcmd")] - [Description("Set a reminder for yourself. Example: !reminder 1h do the thing")] - [TextAlias("remindme", "reminder", "rember", "wemember", "remember", "remind")] - [AllowedProcessors(typeof(TextCommandProcessor))] - [RequireHomeserverPerm(ServerPermLevel.Tier4, WorkOutside = true)] - public async Task RemindMe( - TextCommandContext ctx, - [Description("When to trigger the reminder. Accepts many formats. Surround with quotes if you need to use spaces.")] string timetoParse, - [RemainingText, Description("The text to send when the reminder triggers.")] string reminder = "..." - ) - { - DateTime t = TimeHelpers.ParseAnyDateFormat(timetoParse); - - if (t <= DateTime.UtcNow) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time can't be in the past!"); - return; - } -#if !DEBUG - else if (t < (DateTime.UtcNow + TimeSpan.FromSeconds(59))) - { - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Error} Time must be at least a minute in the future!"); - return; - } -#endif - string guildId; - - if (ctx.Channel.IsPrivate) - guildId = "@me"; - else - guildId = ctx.Guild.Id.ToString(); - - var reminderObject = new Reminder() - { - UserID = ctx.User.Id, - ChannelID = ctx.Channel.Id, - MessageID = ctx.Message.Id, - MessageLink = $"https://discord.com/channels/{guildId}/{ctx.Channel.Id}/{ctx.Message.Id}", - ReminderText = reminder, - ReminderTime = t, - OriginalTime = DateTime.UtcNow - }; - - await Program.redis.ListRightPushAsync("reminders", JsonConvert.SerializeObject(reminderObject)); - await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()"); // (In roughly **{TimeHelpers.TimeToPrettyFormat(t.Subtract(ctx.Message.Timestamp.DateTime), false)}**)"); - } - - public class Reminder - { - [JsonProperty("userID")] - public ulong UserID { get; set; } - - [JsonProperty("channelID")] - public ulong ChannelID { get; set; } - - [JsonProperty("messageID")] - public ulong MessageID { get; set; } - - [JsonProperty("messageLink")] - public string MessageLink { get; set; } - - [JsonProperty("reminderText")] - public string ReminderText { get; set; } - - [JsonProperty("reminderTime")] - public DateTime ReminderTime { get; set; } - - [JsonProperty("originalTime")] - public DateTime OriginalTime { get; set; } - } - // Runs command context checks manually. Returns a list of failed checks. // Unfortunately DSharpPlus.Commands does not provide a way to execute a command's context checks manually, // so this will have to do. This may not include all checks, but it includes everything I could think of. -Milkshake @@ -418,4 +317,4 @@ private static string GetRequiredPermissionLevel(Command command) return permLevelAttribute.TargetLvl.ToString() + (permLevelAttribute.OwnerOverride ? " or Bot Owner" : ""); } } -} \ No newline at end of file +} diff --git a/Commands/PingCmds.cs b/Commands/PingCmds.cs new file mode 100644 index 00000000..bdf19cb9 --- /dev/null +++ b/Commands/PingCmds.cs @@ -0,0 +1,21 @@ +namespace Cliptok.Commands +{ + public class PingCmds + { + [Command("pingtextcmd")] + [TextAlias("ping")] + [Description("Pong? This command lets you know whether I'm working well.")] + [AllowedProcessors(typeof(TextCommandProcessor))] + public async Task Ping(TextCommandContext ctx) + { + ctx.Client.Logger.LogDebug(ctx.Client.GetConnectionLatency(Program.cfgjson.ServerID).ToString()); + DiscordMessage return_message = await ctx.Message.RespondAsync("Pinging..."); + ulong ping = (return_message.Id - ctx.Message.Id) >> 22; + char[] choices = new char[] { 'a', 'e', 'o', 'u', 'i', 'y' }; + char letter = choices[Program.rand.Next(0, choices.Length)]; + await return_message.ModifyAsync($"P{letter}ng! 🏓\n" + + $"• It took me `{ping}ms` to reply to your message!\n" + + $"• Last Websocket Heartbeat took `{Math.Round(ctx.Client.GetConnectionLatency(0).TotalMilliseconds, 0)}ms`!"); + } + } +} diff --git a/Commands/ReminderCmds.cs b/Commands/ReminderCmds.cs new file mode 100644 index 00000000..38e4b6ea --- /dev/null +++ b/Commands/ReminderCmds.cs @@ -0,0 +1,217 @@ +using static Cliptok.Helpers.ReminderHelpers; + +namespace Cliptok.Commands +{ + public class ReminderCmds + { + // Used to pass context to modal handling + // + public static Dictionary ReminderInteractionCache = new(); + + [Command("Remind Me About This")] + [AllowedProcessors(typeof(MessageCommandProcessor))] + [SlashCommandTypes(DiscordApplicationCommandType.MessageContextMenu)] + + public static async Task ContextReminder(MessageCommandContext ctx, DiscordMessage targetMessage) + { + await ctx.RespondWithModalAsync(new DiscordModalBuilder() + .WithTitle("Remind Me About This") + .WithCustomId("remind-me-about-this-modal-callback") + .AddTextInput(new DiscordTextInputComponent("remind-me-about-this-time-input"), "When do you want to be reminded?") + ); + + ReminderInteractionCache[ctx.User.Id] = targetMessage; + } + + [Command("reminder")] + [Description("Set, modify and delete reminders.")] + [TextAlias("remindme", "rember", "wemember", "remember", "remind")] + [RequireHomeserverPerm(ServerPermLevel.Tier4, WorkOutside = true)] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + public class ReminderCommand + { + [Command("set")] + [Description("Set a reminder.")] + [DefaultGroupCommand] + public static async Task SetReminder(CommandContext ctx, + [Parameter("time"), Description("When do you want to be reminded?")] + string time, + [Parameter("text"), Description("What should the reminder say?")] [MinMaxLength(maxLength: 1000)] [RemainingText] + string text = "") + { + if (ctx is SlashCommandContext) + await ctx.As().DeferResponseAsync(); + + var (parsedTime, error) = ParseReminderTime(time); + if (parsedTime is null) + { + await ctx.RespondAsync(error, ephemeral: true); + return; + } + + Reminder reminder = new() + { + UserId = ctx.User.Id, + ChannelId = ctx.Channel.Id, + GuildId = ctx.Channel.IsPrivate ? "@me" : ctx.Guild.Id.ToString(), + ReminderId = await GenerateUniqueReminderIdAsync(), + ReminderText = text, + ReminderTime = parsedTime.Value, + SetTime = DateTime.UtcNow + }; + + var unixTime = ((DateTimeOffset)parsedTime).ToUnixTimeSeconds(); + + DiscordMessage message; + if (ctx is SlashCommandContext) + { + message = await ctx.As().FollowupAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()")); + reminder.MessageId = message.Id; + } + else + { + await ctx.RespondAsync($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()"); + reminder.MessageId = ctx.As().Message.Id; + } + + await Program.redis.HashSetAsync("reminders", reminder.ReminderId, JsonConvert.SerializeObject(reminder)); + } + + [Command("list")] + [Description("List your reminders.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public static async Task ListReminders(SlashCommandContext ctx) + { + await ctx.DeferResponseAsync(true); + + var userReminders = await GetUserRemindersAsync(ctx.User.Id); + + if (userReminders.Count == 0) + { + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} You don't have any reminders!") + .AsEphemeral()); + return; + } + + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder() + .AddEmbed(await CreateReminderListEmbedAsync(userReminders, ctx.User)) + .AsEphemeral()); + } + + [Command("delete")] + [Description("Delete a reminder.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public static async Task DeleteReminder(SlashCommandContext ctx) + { + // We can't defer this!! Want to respond with a modal if the user has >25 reminders. + + var userReminders = await GetUserRemindersAsync(ctx.User.Id); + if (userReminders.Count == 0) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} You don't have any reminders!") + .AsEphemeral()); + return; + } + else if (userReminders.Count <= 25) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder().WithContent("Please choose a reminder to delete.") + .AddActionRowComponent(CreateSelectComponentFromReminders(userReminders, "reminder-delete-dropdown-callback")) + .AsEphemeral()); + } + else + { + // User has more than 25 reminders. Show a modal where they are prompted to enter the ID for the reminder they want to delete. + // I wanted to paginate a select menu instead, but Discord and D#+ limitations make that really difficult for now. (cba writing my own pagination) + + var modalText = "You have a lot of reminders! Please enter the ID of the reminder you wish to delete."; + + await ctx.RespondWithModalAsync(new DiscordModalBuilder().WithCustomId("reminder-delete-modal-callback").WithTitle("Delete a Reminder") + .AddTextDisplay(modalText) + .AddTextInput(new DiscordTextInputComponent("reminder-delete-id-input"), "Reminder ID")); + } + } + + [Command("modify")] + [Description("Modify a reminder.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public static async Task ModifyReminder(SlashCommandContext ctx) + { + // We can't defer this!! Want to respond with a modal if the user has >25 reminders. + + var userReminders = await GetUserRemindersAsync(ctx.User.Id); + if (userReminders.Count == 0) + { + await ctx.RespondAsync(new DiscordInteractionResponseBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} You don't have any reminders!") + .AsEphemeral()); + return; + } + else if (userReminders.Count <= 25) + { + await ctx.RespondAsync( + new DiscordInteractionResponseBuilder().WithContent("Please choose a reminder to modify.") + .AddActionRowComponent(CreateSelectComponentFromReminders(userReminders, "reminder-modify-dropdown-callback")) + .AsEphemeral()); + } + else + { + // User has more than 25 reminders. Show a modal where they are prompted to enter the ID for the reminder they want to modify. + // I wanted to paginate a select menu instead, but Discord and D#+ limitations make that really difficult for now. (cba writing my own pagination) + + var modalText = "You have a lot of reminders! Please enter the ID of the reminder you wish to modify."; + + await ctx.RespondWithModalAsync(new DiscordModalBuilder().WithCustomId("reminder-modify-modal-callback").WithTitle("Modify a Reminder") + .AddTextDisplay(modalText) + .AddTextInput(new DiscordTextInputComponent("reminder-modify-id-input"), "Reminder ID") + .AddTextInput(new DiscordTextInputComponent("reminder-modify-time-input", required: false), "(Optional) Enter the new reminder time:") + .AddTextInput(new DiscordTextInputComponent("reminder-modify-text-input", required: false), "(Optional) Enter the new reminder text:")); + } + } + + [Command("show")] + [Description("Show the details for a reminder.")] + [AllowedProcessors(typeof(SlashCommandProcessor))] + public static async Task ReminderShow(SlashCommandContext ctx, + [Parameter("id"), Description("The ID of the reminder to show.")] string id) + { + await ctx.DeferResponseAsync(true); + + var (reminder, error) = await GetReminderAsync(id, ctx.User.Id); + if (reminder is null) + { + await ctx.RespondAsync(error, ephemeral: true); + return; + } + + DiscordEmbedBuilder embed = new() + { + Title = $"Reminder `{id}`", + Description = reminder.ReminderText, + Color = new DiscordColor(0xFEC13D) + }; + + if (reminder.GuildId != "@me") + { + embed.AddField("Server", + $"{(await Program.discord.GetGuildAsync(Convert.ToUInt64(reminder.GuildId))).Name}"); + embed.AddField("Channel", $"<#{reminder.ChannelId}>"); + } + + embed.AddField("Context", $"https://discord.com/channels/{reminder.GuildId}/{reminder.ChannelId}/{reminder.MessageId}"); + + var setTime = ((DateTimeOffset)reminder.SetTime).ToUnixTimeSeconds(); + + long reminderTime = ((DateTimeOffset)reminder.ReminderTime).ToUnixTimeSeconds(); + + embed.AddField("Set At", $" ()"); + + embed.AddField("Set For", $" ()"); + + await ctx.FollowupAsync(new DiscordFollowupMessageBuilder().AddEmbed(embed).AsEphemeral()); + } + } + } +} diff --git a/Commands/UserInfoCmds.cs b/Commands/UserInfoCmds.cs new file mode 100644 index 00000000..7f195225 --- /dev/null +++ b/Commands/UserInfoCmds.cs @@ -0,0 +1,17 @@ +namespace Cliptok.Commands +{ + public class UserInfoCmds + { + [Command("userinfo")] + [TextAlias("user-info", "whois")] + [Description("Show info about a user.")] + [AllowedProcessors(typeof(SlashCommandProcessor), typeof(TextCommandProcessor))] + public async Task UserInfoSlashCommand(CommandContext ctx, [Parameter("user"), Description("The user to retrieve information about.")] DiscordUser user = null, [Parameter("public"), Description("Whether to show the output publicly.")] bool publicMessage = false) + { + if (user is null) + user = ctx.User; + + await ctx.RespondAsync(embed: await DiscordHelpers.GenerateUserEmbed(user, ctx.Guild), ephemeral: !publicMessage); + } + } +} diff --git a/Constants/RegexConstants.cs b/Constants/RegexConstants.cs index 8c220356..c97720cc 100644 --- a/Constants/RegexConstants.cs +++ b/Constants/RegexConstants.cs @@ -21,5 +21,6 @@ public class RegexConstants readonly public static Regex webhook_rx = new("(?:https?:\\/\\/)?discord(?:app)?.com\\/api\\/(?:v\\d\\/)?webhooks\\/(?\\d+)\\/(?[A-Za-z0-9_\\-]+)", RegexOptions.ECMAScript); readonly public static Regex id_rx = new("[0-9]{17,}"); readonly public static Regex image_url_rx = new(@"https:\/\/(?:(?:i\.)?imgur\.com\/(?:a\/)?[A-Za-z0-9]+(?:\.[A-Za-z]{3,4})?|i\.ibb\.co\/[A-Za-z0-9]+\/[A-Za-z0-9]+\.[A-Za-z]{3,4}|(?:cdn\.discordapp\.com|media\.discordapp\.net)\/attachments\/[0-9]{17,}\/[0-9]{17,}\/[A-Za-z0-9]+\.[A-Za-z]{3,4}\?ex=[A-Za-z0-9]+&is=[A-Za-z0-9]+&hm=[A-Za-z0-9]+&?(?:=&format=[A-Za-z0-9]+&width=[0-9]+&height=[0-9]+)?)"); + readonly public static Regex reminder_id_rx = new("^[0-9]{4}$"); } } diff --git a/Events/ErrorEvents.cs b/Events/ErrorEvents.cs index d7aa292d..c2eb68de 100644 --- a/Events/ErrorEvents.cs +++ b/Events/ErrorEvents.cs @@ -40,9 +40,9 @@ public static async Task TextCommandErrored(CommandErroredEventArgs e) // If this is a command with subcommands, we are looking at the default subcommand if there is one; // if the user did not explicitly specify a subcommand however, we should show help for the [group] command, not the default subcommand if (e.Context.As().Message.Content.Contains(' ')) - await Commands.GlobalCmds.Help(e.Context, e.Context.Command.FullName); + await Commands.HelpCmds.Help(e.Context, e.Context.Command.FullName); else - await Commands.GlobalCmds.Help(e.Context, e.Context.Command.FullName.Split(' ').First()); + await Commands.HelpCmds.Help(e.Context, e.Context.Command.FullName.Split(' ').First()); return; } diff --git a/Events/InteractionEvents.cs b/Events/InteractionEvents.cs index 6fc48e0e..c33e442a 100644 --- a/Events/InteractionEvents.cs +++ b/Events/InteractionEvents.cs @@ -4,6 +4,10 @@ namespace Cliptok.Events { public class InteractionEvents { + // Used to pass context between reminder modify interactions + // + public static Dictionary ReminderModifyCache = new(); + public static async Task ComponentInteractionCreateEvent(DiscordClient _, ComponentInteractionCreatedEventArgs e) { // Edits need a webhook rather than interaction..? @@ -389,6 +393,66 @@ await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder await e.Interaction.EditFollowupMessageAsync(e.Message.Id, new DiscordWebhookBuilder().WithContent($"{cfgjson.Emoji.Success} You have been removed from the {insiderChatRole.Mention} role!")); } + else if (e.Id == "reminder-delete-dropdown-callback") + { + Reminder reminder; + try + { + reminder = + JsonConvert.DeserializeObject( + await Program.redis.HashGetAsync("reminders", e.Values[0])); + } + catch + { + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, + new DiscordInteractionResponseBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} That reminder was already deleted!") + .AsEphemeral()); + return; + } + + await Program.redis.HashDeleteAsync("reminders", e.Values[0]); + + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, + new DiscordInteractionResponseBuilder() + .WithContent($"{Program.cfgjson.Emoji.Success} Reminder deleted successfully!").AsEphemeral()); + } + else if (e.Id == "reminder-modify-dropdown-callback") + { + Reminder reminder; + try + { + reminder = JsonConvert.DeserializeObject(await Program.redis.HashGetAsync("reminders", e.Values[0])); + } + catch + { + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, + new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Sorry, something unexpected happened! Please try again or contact the bot owner(s) for help.")); + return; + } + + if (reminder is null) + { + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, + new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Sorry, something unexpected happened! Please try again or contact the bot owner(s) for help.")); + return; + } + + if (reminder.UserId != e.Interaction.User.Id) + { + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, + new DiscordInteractionResponseBuilder() + .WithContent("Only the person who set that reminder can modify it!").AsEphemeral()); + return; + } + + ReminderModifyCache[e.Interaction.User.Id] = reminder; + + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.Modal, + new DiscordModalBuilder().WithCustomId("reminder-modify-modal-callback").WithTitle("Modify a Reminder") + .AddTextInput(new DiscordTextInputComponent("reminder-modify-time-input", placeholder: "in about " + TimeHelpers.TimeToPrettyFormat(reminder.ReminderTime.Subtract(DateTime.UtcNow).Add(TimeSpan.FromMinutes(1)), false), required: false), "When do you want to be reminded?") + .AddTextInput(new DiscordTextInputComponent("reminder-modify-text-input", placeholder: reminder.ReminderText, required: false), "What do you want to be reminded about?")); + } else { await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent("Unknown interaction. I don't know what you are asking me for.").AsEphemeral(true)); @@ -435,6 +499,135 @@ public static async Task ModalSubmitted(DiscordClient _, ModalSubmittedEventArgs if (role2 is not null) await role2.ModifyAsync(mentionable: false); } + else if (e.Id == "remind-me-about-this-modal-callback") + { + await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource, + new DiscordInteractionResponseBuilder().AsEphemeral(true)); + + var targetMessage = Commands.ReminderCmds.ReminderInteractionCache[e.Interaction.User.Id]; + + var timeInput = (e.Values["remind-me-about-this-time-input"] as TextInputModalSubmission).Value; + + var (time, error) = ReminderHelpers.ParseReminderTime(timeInput); + if (time is null) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent(error).AsEphemeral()); + return; + } + + var reminder = new Reminder + { + UserId = e.Interaction.User.Id, + ChannelId = e.Interaction.Channel.Id, + MessageId = targetMessage.Id, + SetTime = DateTime.UtcNow, + ReminderTime = time.Value, + ReminderId = await ReminderHelpers.GenerateUniqueReminderIdAsync(), + ReminderText = "", + GuildId = e.Interaction.Guild is null ? "@me" : e.Interaction.Guild.Id.ToString() + }; + + await Program.redis.HashSetAsync("reminders", reminder.ReminderId.ToString(), JsonConvert.SerializeObject(reminder)); + + var unixTime = ((DateTimeOffset)time).ToUnixTimeSeconds(); + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Success} I'll try my best to remind you about that on ()")); + + Commands.ReminderCmds.ReminderInteractionCache.Remove(e.Interaction.User.Id); + } + else if (e.Id == "reminder-modify-modal-callback") + { + await e.Interaction.DeferAsync(true); + + var time = (e.Values["reminder-modify-time-input"] as TextInputModalSubmission).Value; + var text = (e.Values["reminder-modify-text-input"] as TextInputModalSubmission).Value; + string id = null; + if (e.Values.ContainsKey("reminder-modify-id-input")) + id = (e.Values["reminder-modify-id-input"] as TextInputModalSubmission).Value; + + Reminder reminder; + try + { + if (!ReminderModifyCache.TryGetValue(e.Interaction.User.Id, out reminder)) + { + if (!Constants.RegexConstants.reminder_id_rx.IsMatch(id)) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} The reminder ID you provided isn't correct! Please try again.") + .AsEphemeral()); + return; + } + + reminder = JsonConvert.DeserializeObject(await Program.redis.HashGetAsync("reminders", id)); + } + } + catch (ArgumentNullException) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} I couldn't find a reminder with that ID! Please try again.") + .AsEphemeral()); + return; + } + catch + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Sorry, something unexpected happened! Please try again or contact the bot owner(s) for help.")); + return; + } + + if (reminder.UserId != e.Interaction.User.Id) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Error} Only the person who set that reminder can modify it!").AsEphemeral()); + return; + } + + if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(time)) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent($"{Program.cfgjson.Emoji.Information} Reminder unchanged.")); + return; + } + + if (!string.IsNullOrWhiteSpace(text)) reminder.ReminderText = text; + + if (!string.IsNullOrWhiteSpace(time)) + { + var (parsedTime, error) = ReminderHelpers.ParseReminderTime(time); + if (parsedTime is null) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent(error).AsEphemeral()); + return; + } + + reminder.ReminderTime = parsedTime.Value; + } + + await Program.redis.HashSetAsync("reminders", reminder.ReminderId, JsonConvert.SerializeObject(reminder)); + + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Success} Reminder modified successfully!")); + + ReminderModifyCache.Remove(e.Interaction.User.Id); + } + else if (e.Id == "reminder-delete-modal-callback") + { + await e.Interaction.DeferAsync(true); + + var id = (e.Values["reminder-delete-id-input"] as TextInputModalSubmission).Value; + + var (reminder, error) = await ReminderHelpers.GetReminderAsync(id, e.Interaction.User.Id); + if (reminder is null) + { + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().WithContent(error).AsEphemeral()); + return; + } + + await Program.redis.HashDeleteAsync("reminders", id); + + await e.Interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder() + .WithContent($"{Program.cfgjson.Emoji.Success} Reminder deleted successfully!")); + + ReminderModifyCache.Remove(e.Interaction.User.Id); + } else { await e.Interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent($"{Program.cfgjson.Emoji.Error} Unknown interaction! This should never happen. Please contact the bot owner(s)!").AsEphemeral(true)); diff --git a/Events/ReadyEvent.cs b/Events/ReadyEvent.cs index c6b5bd52..fb247ce9 100644 --- a/Events/ReadyEvent.cs +++ b/Events/ReadyEvent.cs @@ -187,6 +187,7 @@ public static async Task OnStartup(DiscordClient client) { await Migrations.JoinwatchMigration.MigrateJoinwatchesToNotesAsync(); await Migrations.LinePardonMigrations.MigrateLinePardonToSetAsync(); + await RedisMigrations.ReminderMigrations.MigrateRemindersToHashAsync(); } catch (Exception ex) { diff --git a/Helpers/ReminderHelpers.cs b/Helpers/ReminderHelpers.cs new file mode 100644 index 00000000..8bb19d19 --- /dev/null +++ b/Helpers/ReminderHelpers.cs @@ -0,0 +1,157 @@ +using static Cliptok.Constants.RegexConstants; + +namespace Cliptok.Helpers +{ + internal class ReminderHelpers + { + internal static (DateTime? parsedTime, string error) ParseReminderTime(string reminderTime) + { + DateTime parsedTime; + try + { + parsedTime = TimeHelpers.ParseAnyDateFormat(reminderTime); + } + catch + { + return (null, $"{Program.cfgjson.Emoji.Error} I couldn't parse the time you entered! Please try again."); + } + + if (parsedTime <= DateTime.UtcNow) + return (null, $"{Program.cfgjson.Emoji.Error} Time can't be in the past!"); +#if !DEBUG + else if (parsedTime < (DateTime.UtcNow + TimeSpan.FromSeconds(59))) + return (null, $"{Program.cfgjson.Emoji.Error} Time must be at least a minute in the future!"); +#endif + + return (parsedTime, null); + } + + internal static async Task<(Reminder reminder, string error)> GetReminderAsync(string reminderId, ulong requestingUserId) + { + if (!reminder_id_rx.IsMatch(reminderId)) + return (null, $"{Program.cfgjson.Emoji.Error} The reminder ID you provided isn't correct! Please try again."); + + Reminder reminder; + try + { + reminder = JsonConvert.DeserializeObject(await Program.redis.HashGetAsync("reminders", reminderId)); + } + catch + { + return (null, $"{Program.cfgjson.Emoji.Error} I couldn't find a reminder with that ID! Please try again."); + } + + if (reminder is null || reminder.UserId != requestingUserId) + return (null, $"{Program.cfgjson.Emoji.Error} I couldn't find a reminder with that ID! Please try again."); + + return (reminder, null); + } + + internal static async Task> GetUserRemindersAsync(ulong userId) + { + return (await Program.redis.HashGetAllAsync("reminders")) + .Select(x => JsonConvert.DeserializeObject(x.Value)).Where(r => r is not null && r.UserId == userId) + .OrderBy(x => x.ReminderTime) + .ToList(); + } + + internal static async Task GenerateUniqueReminderIdAsync() + { + Random random = new(); + var reminderId = random.Next(1000, 9999); + + var reminders = await Program.redis.HashGetAllAsync("reminders"); + while (reminders.Any(x => x.Name == reminderId)) + reminderId = random.Next(1000, 9999); + + return reminderId; + } + + internal static DiscordSelectComponent CreateSelectComponentFromReminders(List reminders, string componentCustomId) + { + List options = reminders.Select(reminder => + new DiscordSelectComponentOption(string.IsNullOrWhiteSpace(reminder.ReminderText) + ? "..." + : StringHelpers.Truncate(reminder.ReminderText, 100, true), + reminder.ReminderId.ToString(), + "in about " + TimeHelpers.TimeToPrettyFormat(reminder.ReminderTime.Subtract(DateTime.UtcNow).Add(TimeSpan.FromMinutes(1)), false))) + .ToList(); + + return new DiscordSelectComponent(componentCustomId, null, options); + } + + internal static async Task CreateReminderListEmbedAsync(List reminders, DiscordUser user) + { + string output = ""; + foreach (var reminder in reminders) + { + var setTime = ((DateTimeOffset)reminder.SetTime).ToUnixTimeSeconds(); + + long reminderTime = ((DateTimeOffset)reminder.ReminderTime).ToUnixTimeSeconds(); + + string guildName; + if (id_rx.IsMatch(reminder.GuildId)) + { + var targetGuild = await Program.discord.GetGuildAsync(Convert.ToUInt64(reminder.GuildId)); + guildName = targetGuild.Name; + } + else + { + guildName = "DMs"; + } + + var reminderLink = $""; + + var reminderText = StringHelpers.Truncate(reminder.ReminderText, 300, true); + + var reminderLocation = $" in {guildName}"; + if (guildName != "DMs") + reminderLocation += $" <#{reminder.ChannelId}>"; + + output += $"`{reminder.ReminderId}`:\n" + + (string.IsNullOrWhiteSpace(reminderText) + ? "" + : $"> {reminderText}\n") + + $"[Set ]({reminderLink}) to remind you "; + + output += reminderLocation; + + output += "\n\n"; + } + + DiscordEmbedBuilder embed = new() + { + Author = new DiscordEmbedBuilder.EmbedAuthor() + { + Name = $"Reminders for {user.Username}", + IconUrl = user.AvatarUrl + }, + Color = new DiscordColor(0xFEC13C) + }; + + if (output.Length > 4096) + { + embed.WithColor(DiscordColor.Red); + + var desc = "You have too many reminders to list here! Here are the IDs of each one.\n\n"; + + foreach (var reminder in reminders) + { + var setTime = ((DateTimeOffset)reminder.SetTime).ToUnixTimeSeconds(); + + long reminderTime = ((DateTimeOffset)reminder.ReminderTime).ToUnixTimeSeconds(); + + desc += $"`{reminder.ReminderId}` - set to remind you \n"; + } + + embed.WithDescription(desc.Trim()); + } + else + { + embed.WithDescription(output); + } + + return embed; + } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index f6c50923..e62c8e3d 100644 --- a/Program.cs +++ b/Program.cs @@ -235,10 +235,13 @@ static async Task Main(string[] _) continue; } - if (type.Name == "GlobalCmds") - builder.AddCommands(type); - else - builder.AddCommands(type, cfgjson.ServerID); + if (type.Name == "HelpCmds" + || type.Name == "ReminderCmds" + || type.Name == "PingCmds" + || type.Name == "UserInfoCmds") + builder.AddCommands(type); + else + builder.AddCommands(type, cfgjson.ServerID); } diff --git a/RedisMigrations/ReminderMigrations.cs b/RedisMigrations/ReminderMigrations.cs new file mode 100644 index 00000000..4c5a8aed --- /dev/null +++ b/RedisMigrations/ReminderMigrations.cs @@ -0,0 +1,80 @@ +namespace Cliptok.RedisMigrations +{ + internal class ReminderMigrations + { + internal static async Task MigrateRemindersToHashAsync() + { + if (!await Program.redis.KeyExistsAsync("reminders") || await Program.redis.KeyTypeAsync("reminders") == RedisType.Hash) + return; + + var numRemindersToMigrate = await Program.redis.ListLengthAsync("reminders"); + var numRemindersMigrated = 0; + + // archive old data + await Program.redis.KeyRenameAsync("reminders", "remindersOld"); + + // migrate to hash + var remindersList = await Program.redis.ListRangeAsync("remindersOld"); + foreach (var reminder in remindersList) + { + var oldReminderObject = JsonConvert.DeserializeObject(reminder); + + ulong guildId; + try + { + guildId = (await Program.discord.GetChannelAsync(oldReminderObject.ChannelID)).Guild.Id; + } + catch + { + guildId = Program.homeGuild.Id; + } + + var newReminderObject = new Reminder() + { + UserId = oldReminderObject.UserID, + ChannelId = oldReminderObject.ChannelID, + GuildId = guildId.ToString(), + MessageId = oldReminderObject.MessageID, + ReminderId = await ReminderHelpers.GenerateUniqueReminderIdAsync(), + ReminderText = oldReminderObject.ReminderText, + ReminderTime = oldReminderObject.ReminderTime, + SetTime = oldReminderObject.OriginalTime + }; + + await Program.redis.HashSetAsync("reminders", newReminderObject.ReminderId, JsonConvert.SerializeObject(newReminderObject)); + + numRemindersMigrated++; + } + + if (numRemindersMigrated > 0) + Program.discord.Logger.LogInformation("Successfully migrated {count}/{total} reminders to hash!", numRemindersMigrated, numRemindersToMigrate); + + if (numRemindersToMigrate != 0 && numRemindersMigrated != numRemindersToMigrate) + Program.discord.Logger.LogError("Failed to migrate {count}/{total} reminders to hash!", numRemindersToMigrate - numRemindersMigrated, numRemindersToMigrate); + } + } + + internal class OldReminder + { + [JsonProperty("userID")] + public ulong UserID { get; set; } + + [JsonProperty("channelID")] + public ulong ChannelID { get; set; } + + [JsonProperty("messageID")] + public ulong MessageID { get; set; } + + [JsonProperty("messageLink")] + public string MessageLink { get; set; } + + [JsonProperty("reminderText")] + public string ReminderText { get; set; } + + [JsonProperty("reminderTime")] + public DateTime ReminderTime { get; set; } + + [JsonProperty("originalTime")] + public DateTime OriginalTime { get; set; } + } +} diff --git a/Tasks/ReminderTasks.cs b/Tasks/ReminderTasks.cs index afa5f95a..4e45bbf0 100644 --- a/Tasks/ReminderTasks.cs +++ b/Tasks/ReminderTasks.cs @@ -5,88 +5,101 @@ internal class ReminderTasks public static async Task CheckRemindersAsync() { bool success = false; - foreach (var reminder in Program.redis.ListRange("reminders", 0, -1)) + + var reminders = await Program.redis.HashGetAllAsync("reminders"); + foreach (var reminder in reminders.Select(x => JsonConvert.DeserializeObject(x.Value))) { bool DmFallback = false; - var reminderObject = JsonConvert.DeserializeObject(reminder); - if (reminderObject.ReminderTime <= DateTime.UtcNow) + + if (reminder.ReminderTime > DateTime.UtcNow) + continue; + + var user = await Program.discord.GetUserAsync(reminder.UserId); + DiscordChannel channel = null; + try { - var user = await Program.discord.GetUserAsync(reminderObject.UserID); - DiscordChannel channel = null; - try - { - channel = await Program.discord.GetChannelAsync(reminderObject.ChannelID); - } - catch + channel = await Program.discord.GetChannelAsync(reminder.ChannelId); + } + catch + { + // channel likely doesnt exist + } + if (channel is null) + { + var guild = Program.homeGuild; + var member = await guild.GetMemberAsync(reminder.UserId); + + if ((await GetPermLevelAsync(member)) >= ServerPermLevel.TrialModerator) { - // channel likely doesnt exist + channel = await Program.discord.GetChannelAsync(Program.cfgjson.HomeChannel); } - if (channel is null) + else { - var guild = Program.homeGuild; - var member = await guild.GetMemberAsync(reminderObject.UserID); - - if ((await GetPermLevelAsync(member)) >= ServerPermLevel.TrialModerator) - { - channel = await Program.discord.GetChannelAsync(Program.cfgjson.HomeChannel); - } - else - { - channel = await member.CreateDmChannelAsync(); - DmFallback = true; - } + channel = await member.CreateDmChannelAsync(); + DmFallback = true; } + } - await Program.redis.ListRemoveAsync("reminders", reminder); - success = true; + await Program.redis.HashDeleteAsync("reminders", reminder.ReminderId); + success = true; - var embed = new DiscordEmbedBuilder() - .WithDescription(reminderObject.ReminderText) - .WithColor(new DiscordColor(0xD084)) - .WithFooter( - "Reminder was set", - null - ) - .WithTimestamp(reminderObject.OriginalTime) - .WithAuthor( - $"Reminder from {TimeHelpers.TimeToPrettyFormat(DateTime.UtcNow.Subtract(reminderObject.OriginalTime), true)}", - null, - user.AvatarUrl - ) - .AddField("Context", $"{reminderObject.MessageLink}", true); + var embed = new DiscordEmbedBuilder() + .WithDescription(reminder.ReminderText) + .WithColor(new DiscordColor(0xD084)) + .WithFooter( + "Reminder was set", + null + ) + .WithTimestamp(reminder.SetTime) + .WithAuthor( + $"Reminder from {TimeHelpers.TimeToPrettyFormat(DateTime.UtcNow.Subtract(reminder.SetTime), true)}", + null, + user.AvatarUrl + ) + .AddField("Context", $"https://discord.com/channels/{reminder.GuildId}/{reminder.ChannelId}/{reminder.MessageId}", true); - var msg = new DiscordMessageBuilder() - .AddEmbed(embed) - .WithContent($"<@{reminderObject.UserID}>, you asked to be reminded of something:"); + var msg = new DiscordMessageBuilder() + .AddEmbed(embed) + .WithContent($"<@{reminder.UserId}>, you asked to be reminded of something:"); - if (DmFallback) + if (DmFallback) + { + msg.WithContent("You asked to be reminded of something:"); + } + else if (reminder.MessageId != default) + { + DiscordMessage originalMessage = default; + try + { + originalMessage = await channel.GetMessageAsync(reminder.MessageId); + } + catch { - msg.WithContent("You asked to be reminded of something:"); - await channel.SendMessageAsync(msg); + // message was probably deleted + + msg.WithContent($"<@{reminder.UserId}>, you asked to be reminded of something:"); + msg.WithAllowedMention(new UserMention(reminder.UserId)); } - else if (reminderObject.MessageID != default) + + if (originalMessage is not null) { - try + if (originalMessage.Author.Id == Program.discord.CurrentUser.Id) { - msg.WithReply(reminderObject.MessageID, mention: true, failOnInvalidReply: true) - .WithContent("You asked to be reminded of something:"); - await channel.SendMessageAsync(msg); + msg.WithReply(reminder.MessageId, mention: true) + .WithContent($"<@{reminder.UserId}>, you asked to be reminded of something:") + .WithAllowedMention(new UserMention(reminder.UserId)); } - catch (DSharpPlus.Exceptions.BadRequestException) + else { - msg.WithContent($"<@{reminderObject.UserID}>, you asked to be reminded of something:"); - msg.WithReply(null); - msg.WithAllowedMentions(Mentions.All); - await channel.SendMessageAsync(msg); + msg.WithReply(reminder.MessageId, mention: true) + .WithContent("You asked to be reminded of something:"); } } - else - { - await channel.SendMessageAsync(msg); - } } + await channel.SendMessageAsync(msg); } + Program.discord.Logger.LogDebug(Program.CliptokEventID, "Checked reminders at {time} with result: {success}", DateTime.UtcNow, success); return success; } diff --git a/Types/RedisData.cs b/Types/RedisData.cs index 706dc5f3..7756e558 100644 --- a/Types/RedisData.cs +++ b/Types/RedisData.cs @@ -120,4 +120,31 @@ public class PendingUserOverride public MockUserOverwrite Overwrite { get; set; } } + public class Reminder + { + [JsonProperty("userId")] + public ulong UserId { get; set; } + + [JsonProperty("channelId")] + public ulong ChannelId { get; set; } + + [JsonProperty("guildId")] + public string GuildId { get; set; } + + [JsonProperty("messageId")] + public ulong MessageId { get; set; } + + [JsonProperty("reminderId")] + public int ReminderId { get; set; } + + [JsonProperty("reminderText")] + public string ReminderText { get; set; } + + [JsonProperty("reminderTime")] + public DateTime ReminderTime { get; set; } + + [JsonProperty("setTime")] + public DateTime SetTime { get; set; } + } + }