diff --git a/Commands/AdminCommands.cs b/Commands/AdminCommands.cs index 7e3ad14..a52d753 100644 --- a/Commands/AdminCommands.cs +++ b/Commands/AdminCommands.cs @@ -244,19 +244,72 @@ public class AdminCommands } [RequireGuild] - [Command("getinterviewtemplates")] - [Description("Provides a copy of the interview templates which you can edit and then reupload.")] - public async Task GetInterviewTemplates(SlashCommandContext command) + [Command("getinterviewtemplate")] + [Description("Provides a copy of the interview template for a category which you can edit and then reupload.")] + public async Task GetInterviewTemplate(SlashCommandContext command, + [Parameter("category")][Description("The category to get the template for.")] DiscordChannel category) { - MemoryStream stream = new(Encoding.UTF8.GetBytes(Database.GetInterviewTemplatesJSON())); - await command.RespondAsync(new DiscordInteractionResponseBuilder().AddFile("interview-templates.json", stream).AsEphemeral()); + if (!category?.IsCategory ?? true) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "That channel is not a category." + }, true); + return; + } + + if (!Database.TryGetCategory(category.Id, out Database.Category categoryData)) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "That category is not registered with the bot." + }, true); + return; + } + + string interviewTemplateJSON = Database.GetInterviewTemplateJSON(category.Id); + if (interviewTemplateJSON == null) + { + string defaultTemplate = + "{\n" + + " \"category-id\": \"" + category.Id + "\",\n" + + " \"interview\":\n" + + " {\n" + + " \"message\": \"\",\n" + + " \"type\": \"\",\n" + + " \"color\": \"\",\n" + + " \"paths\":\n" + + " {\n" + + " \n" + + " }\n" + + " }\n" + + "}"; + MemoryStream stream = new(Encoding.UTF8.GetBytes(defaultTemplate)); + + DiscordInteractionResponseBuilder response = new DiscordInteractionResponseBuilder().AddEmbed(new DiscordEmbedBuilder + { + Color = DiscordColor.Green, + Description = "No interview template found for this category. A default template has been generated." + }).AddFile("interview-template-" + category.Id + ".json", stream).AsEphemeral(); + await command.RespondAsync(response); + } + else + { + MemoryStream stream = new(Encoding.UTF8.GetBytes(interviewTemplateJSON)); + await command.RespondAsync(new DiscordInteractionResponseBuilder().AddFile("interview-template-" + category.Id + ".json", stream).AsEphemeral()); + } } [RequireGuild] - [Command("setinterviewtemplates")] + [Command("setinterviewtemplate")] [Description("Uploads an interview template file.")] - public async Task SetInterviewTemplates(SlashCommandContext command, [Parameter("file")] DiscordAttachment file) + public async Task SetInterviewTemplate(SlashCommandContext command, + [Parameter("file")][Description("The file containing the template.")] DiscordAttachment file) { + await command.DeferResponseAsync(true); + if (!file.MediaType?.Contains("application/json") ?? false) { await command.RespondAsync(new DiscordEmbedBuilder @@ -275,7 +328,7 @@ public class AdminCommands List errors = []; // Convert it to an interview object to validate the template - Dictionary interview = JsonConvert.DeserializeObject>(json, new JsonSerializerSettings() + Interviews.ValidatedTemplate template = JsonConvert.DeserializeObject(json, new JsonSerializerSettings() { //NullValueHandling = NullValueHandling.Include, MissingMemberHandling = MissingMemberHandling.Error, @@ -293,26 +346,41 @@ public class AdminCommands } Logger.Debug("Exception occured when trying to upload interview template:\n" + args.ErrorContext.Error); - args.ErrorContext.Handled = true; + args.ErrorContext.Handled = false; } }); - if (interview != null) + DiscordChannel category = await SupportChild.client.GetChannelAsync(template.categoryID); + if (!category.IsCategory) { - foreach (KeyValuePair interviewRoot in interview) + await command.RespondAsync(new DiscordEmbedBuilder { - interviewRoot.Value.Validate(ref errors, out int summaryCount, out int summaryMaxLength); + Color = DiscordColor.Red, + Description = "The category ID in the uploaded JSON structure is not a valid category." + }, true); + return; + } - if (summaryCount > 25) - { - errors.Add("A summary cannot contain more than 25 fields, but you have " + summaryCount + " fields in one of your interview branches."); - } + if (!Database.TryGetCategory(category.Id, out Database.Category _)) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "The category ID in the uploaded JSON structure is not a category registered with the bot, use /addcategory first." + }, true); + return; + } - if (summaryMaxLength >= 6000) - { - errors.Add("A summary cannot contain more than 6000 characters, but one of your branches has the possibility of its summary reaching " + summaryMaxLength + " characters."); - } - } + template.interview.Validate(ref errors, out int summaryCount, out int summaryMaxLength); + + if (summaryCount > 25) + { + errors.Add("A summary cannot contain more than 25 fields, but you have " + summaryCount + " fields in at least one of your interview branches."); + } + + if (summaryMaxLength >= 6000) + { + errors.Add("A summary cannot contain more than 6000 characters, but at least one of your branches has the possibility of its summary reaching " + summaryMaxLength + " characters."); } if (errors.Count != 0) @@ -335,7 +403,32 @@ public class AdminCommands return; } - Database.SetInterviewTemplates(JsonConvert.SerializeObject(interview, Formatting.Indented)); + if (!Database.SetInterviewTemplate(template)) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "An error occured trying to write the new template to database." + }, true); + return; + } + + try + { + MemoryStream memStream = new(Encoding.UTF8.GetBytes(Database.GetInterviewTemplateJSON(template.categoryID))); + + // Log it if the log channel exists + DiscordChannel logChannel = await SupportChild.client.GetChannelAsync(Config.logChannel); + await logChannel.SendMessageAsync(new DiscordMessageBuilder().AddEmbed(new DiscordEmbedBuilder + { + Color = DiscordColor.Green, + Description = command.User.Mention + " uploaded a new interview template for the `" + category.Name + "` category." + }).AddFile("interview-template-" + template.categoryID + ".json", memStream)); + } + catch (NotFoundException) + { + Logger.Error("Could not find the log channel."); + } } catch (Exception e) { @@ -354,18 +447,5 @@ public class AdminCommands Description = "Uploaded interview template." }, true); - try - { - DiscordChannel logChannel = await SupportChild.client.GetChannelAsync(Config.logChannel); - await logChannel.SendMessageAsync(new DiscordEmbedBuilder - { - Color = DiscordColor.Green, - Description = command.Channel.Mention + " uploaded new interview templates.", - }); - } - catch (NotFoundException) - { - Logger.Error("Could not find the log channel."); - } } } \ No newline at end of file diff --git a/Commands/NewCommand.cs b/Commands/NewCommand.cs index 137f197..a66beff 100644 --- a/Commands/NewCommand.cs +++ b/Commands/NewCommand.cs @@ -8,6 +8,7 @@ using DSharpPlus.Commands.ContextChecks; using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; using DSharpPlus.Exceptions; +using SupportChild.Interviews; namespace SupportChild.Commands; diff --git a/Commands/RestartInterviewCommand.cs b/Commands/RestartInterviewCommand.cs index 4996b59..981b608 100644 --- a/Commands/RestartInterviewCommand.cs +++ b/Commands/RestartInterviewCommand.cs @@ -4,6 +4,7 @@ using DSharpPlus.Commands; using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; using DSharpPlus.Exceptions; +using SupportChild.Interviews; namespace SupportChild.Commands; diff --git a/Database.cs b/Database.cs index a00323a..a626e51 100644 --- a/Database.cs +++ b/Database.cs @@ -114,6 +114,11 @@ public static class Database "channel_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," + "interview JSON NOT NULL)", c); + using MySqlCommand createInterviewTemplates = new MySqlCommand( + "CREATE TABLE IF NOT EXISTS interview_templates(" + + "category_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," + + "template JSON NOT NULL)", + c); c.Open(); createTickets.ExecuteNonQuery(); createBlacklisted.ExecuteNonQuery(); @@ -122,6 +127,7 @@ public static class Database createMessages.ExecuteNonQuery(); createCategories.ExecuteNonQuery(); createInterviews.ExecuteNonQuery(); + createInterviewTemplates.ExecuteNonQuery(); } public static bool IsOpenTicket(ulong channelID) @@ -732,82 +738,91 @@ public static class Database } } - public static string GetInterviewTemplatesJSON() + public static string GetInterviewTemplateJSON(ulong categoryID) { using MySqlConnection c = GetConnection(); c.Open(); - using MySqlCommand selection = new MySqlCommand("SELECT * FROM interviews WHERE channel_id=0", c); + using MySqlCommand selection = new MySqlCommand("SELECT * FROM interview_templates WHERE category_id=@category_id", c); + selection.Parameters.AddWithValue("@category_id", categoryID); selection.Prepare(); MySqlDataReader results = selection.ExecuteReader(); - // Check if messages exist in the database + // Return a default template if it doesn't exist. if (!results.Read()) { - return "{}"; + return null; } - string templates = results.GetString("interview"); + string templates = results.GetString("template"); results.Close(); return templates; } - // Still returns true if there are no templates, returns false if the templates are invalid. - public static bool TryGetInterviewTemplates(out Dictionary templates) + public static bool TryGetInterviewTemplate(ulong categoryID, out Interviews.InterviewQuestion template) { using MySqlConnection c = GetConnection(); c.Open(); - using MySqlCommand selection = new MySqlCommand("SELECT * FROM interviews WHERE channel_id=0", c); + using MySqlCommand selection = new MySqlCommand("SELECT * FROM interview_templates WHERE category_id=@category_id", c); + selection.Parameters.AddWithValue("@category_id", categoryID); selection.Prepare(); MySqlDataReader results = selection.ExecuteReader(); // Check if messages exist in the database if (!results.Read()) { - templates = new Dictionary(); - return true; + template = null; + return false; } - string templatesString = results.GetString("interview"); + string templateString = results.GetString("template"); results.Close(); try { - templates = JsonConvert.DeserializeObject>(templatesString, new JsonSerializerSettings + template = JsonConvert.DeserializeObject(templateString, new JsonSerializerSettings { Error = delegate (object sender, ErrorEventArgs args) { - Logger.Error("Error occured when trying to read interview templates from database: " + args.ErrorContext.Error.Message); + Logger.Error("Error occured when trying to read interview template '" + categoryID + "' from database: " + args.ErrorContext.Error.Message); Logger.Debug("Detailed exception:", args.ErrorContext.Error); args.ErrorContext.Handled = false; } - }); + }).interview; return true; } catch (Exception) { - templates = null; + template = null; return false; } } - public static bool SetInterviewTemplates(string templates) + public static bool SetInterviewTemplate(Interviews.ValidatedTemplate template) { try { - string query; - if (TryGetInterview(0, out _)) + string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings() { - query = "UPDATE interviews SET interview = @interview WHERE channel_id = 0"; + NullValueHandling = NullValueHandling.Ignore, + MissingMemberHandling = MissingMemberHandling.Error, + Formatting = Formatting.Indented + }); + + string query; + if (TryGetInterviewTemplate(template.categoryID, out _)) + { + query = "UPDATE interview_templates SET template = @template WHERE category_id=@category_id"; } else { - query = "INSERT INTO interviews (channel_id,interview) VALUES (0, @interview)"; + query = "INSERT INTO interview_templates (category_id,template) VALUES (@category_id, @template)"; } using MySqlConnection c = GetConnection(); c.Open(); using MySqlCommand cmd = new MySqlCommand(query, c); - cmd.Parameters.AddWithValue("@interview", templates); + cmd.Parameters.AddWithValue("@category_id", template.categoryID); + cmd.Parameters.AddWithValue("@template", templateString); cmd.Prepare(); return cmd.ExecuteNonQuery() > 0; } @@ -817,45 +832,7 @@ public static class Database } } - public static Dictionary GetAllInterviews() - { - using MySqlConnection c = GetConnection(); - c.Open(); - using MySqlCommand selection = new MySqlCommand("SELECT * FROM interviews", c); - selection.Prepare(); - MySqlDataReader results = selection.ExecuteReader(); - - // Check if messages exist in the database - if (!results.Read()) - { - return new Dictionary(); - } - - Dictionary questions = new(); - do - { - try - { - // Channel ID 0 is the interview template, don't read it here. - if (results.GetUInt64("channel_id") != 0) - { - questions.Add(results.GetUInt64("channel_id"), JsonConvert.DeserializeObject(results.GetString("interview"))); - } - } - catch (Exception e) - { - Logger.Warn("Error occured when trying to read interview from database, it will not be loaded until manually fixed in the database.\nError message: " + e.Message); - Logger.Debug("Detailed exception:", e); - } - - } - while (results.Read()); - results.Close(); - - return questions; - } - - public static bool SaveInterview(ulong channelID, Interviewer.InterviewQuestion interview) + public static bool SaveInterview(ulong channelID, Interviews.InterviewQuestion interview) { try { @@ -883,7 +860,7 @@ public static class Database } } - public static bool TryGetInterview(ulong channelID, out Interviewer.InterviewQuestion interview) + public static bool TryGetInterview(ulong channelID, out Interviews.InterviewQuestion interview) { using MySqlConnection c = GetConnection(); c.Open(); @@ -898,7 +875,7 @@ public static class Database interview = null; return false; } - interview = JsonConvert.DeserializeObject(results.GetString("interview")); + interview = JsonConvert.DeserializeObject(results.GetString("interview")); results.Close(); return true; } diff --git a/EventHandler.cs b/EventHandler.cs index ac2455a..5a0d6d8 100644 --- a/EventHandler.cs +++ b/EventHandler.cs @@ -12,6 +12,7 @@ using DSharpPlus.Entities; using DSharpPlus.EventArgs; using DSharpPlus.Exceptions; using SupportChild.Commands; +using SupportChild.Interviews; namespace SupportChild; diff --git a/Interviews/Interview.cs b/Interviews/Interview.cs new file mode 100644 index 0000000..81941d2 --- /dev/null +++ b/Interviews/Interview.cs @@ -0,0 +1,204 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using DSharpPlus.Entities; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace SupportChild.Interviews; + +public enum QuestionType +{ + // Support multiselector as separate type, with only one subpath supported + ERROR, + END_WITH_SUMMARY, + END_WITHOUT_SUMMARY, + BUTTONS, + TEXT_SELECTOR, + USER_SELECTOR, + ROLE_SELECTOR, + MENTIONABLE_SELECTOR, // User or role + CHANNEL_SELECTOR, + TEXT_INPUT +} + +public enum ButtonType +{ + PRIMARY, + SECONDARY, + SUCCESS, + DANGER +} + +// A tree of questions representing an interview. +// The tree is generated by the config file when a new ticket is opened or the restart interview command is used. +// Additional components not specified in the config file are populated as the interview progresses. +// The entire interview tree is serialized and stored in the database in order to record responses as they are made. +public class InterviewQuestion +{ + // Title of the message embed. + [JsonProperty("title")] + public string title; + + // Message contents sent to the user. + [JsonProperty("message")] + public string message; + + // The type of question. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("type")] + public QuestionType type; + + // Colour of the message embed. + [JsonProperty("color")] + public string color; + + // Used as label for this question in the post-interview summary. + [JsonProperty("summary-field")] + public string summaryField; + + // If this question is on a button, give it this style. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("button-style")] + public ButtonType? buttonStyle; + + // If this question is on a selector, give it this placeholder. + [JsonProperty("selector-placeholder")] + public string selectorPlaceholder; + + // If this question is on a selector, give it this description. + [JsonProperty("selector-description")] + public string selectorDescription; + + // The maximum length of a text input. + [JsonProperty("max-length")] + public int? maxLength; + + // The minimum length of a text input. + [JsonProperty("min-length", Required = Required.Default)] + public int? minLength; + + // Possible questions to ask next, an error message, or the end of the interview. + [JsonProperty("paths")] + public Dictionary paths; + + // //////////////////////////////////////////////////////////////////////////// + // The following parameters are populated by the bot, not the json template. // + // //////////////////////////////////////////////////////////////////////////// + + // The ID of this message where the bot asked this question. + [JsonProperty("message-id")] + public ulong messageID; + + // The contents of the user's answer. + [JsonProperty("answer")] + public string answer; + + // The ID of the user's answer message if this is a TEXT_INPUT type. + [JsonProperty("answer-id")] + public ulong answerID; + + // Any extra messages generated by the bot that should be removed when the interview ends. + [JsonProperty("related-message-ids")] + public List relatedMessageIDs; + + public bool TryGetCurrentQuestion(out InterviewQuestion question) + { + // This object has not been initialized, we have checked too deep. + if (messageID == 0) + { + question = null; + return false; + } + + // Check children. + foreach (KeyValuePair path in paths) + { + // This child either is the one we are looking for or contains the one we are looking for. + if (path.Value.TryGetCurrentQuestion(out question)) + { + return true; + } + } + + // This object is the deepest object with a message ID set, meaning it is the latest asked question. + question = this; + return true; + } + + public void GetSummary(ref OrderedDictionary summary) + { + if (messageID == 0) + { + return; + } + + if (!string.IsNullOrWhiteSpace(summaryField)) + { + summary.Add(summaryField, answer); + } + + // This will always contain exactly one or zero children. + foreach (KeyValuePair path in paths) + { + path.Value.GetSummary(ref summary); + } + } + + public void GetMessageIDs(ref List messageIDs) + { + if (messageID != 0) + { + messageIDs.Add(messageID); + } + + if (answerID != 0) + { + messageIDs.Add(answerID); + } + + if (relatedMessageIDs != null) + { + messageIDs.AddRange(relatedMessageIDs); + } + + // This will always contain exactly one or zero children. + foreach (KeyValuePair path in paths) + { + path.Value.GetMessageIDs(ref messageIDs); + } + } + + public void AddRelatedMessageIDs(params ulong[] messageIDs) + { + if (relatedMessageIDs == null) + { + relatedMessageIDs = messageIDs.ToList(); + } + else + { + relatedMessageIDs.AddRange(messageIDs); + } + } + + public DiscordButtonStyle GetButtonStyle() + { + return buttonStyle switch + { + ButtonType.PRIMARY => DiscordButtonStyle.Primary, + ButtonType.SECONDARY => DiscordButtonStyle.Secondary, + ButtonType.SUCCESS => DiscordButtonStyle.Success, + ButtonType.DANGER => DiscordButtonStyle.Danger, + _ => DiscordButtonStyle.Secondary + }; + } +} + +public class Template(ulong categoryID, InterviewQuestion interview) +{ + [JsonProperty("category-id", Required = Required.Always)] + public ulong categoryID = categoryID; + + [JsonProperty("interview", Required = Required.Always)] + public InterviewQuestion interview = interview; +} \ No newline at end of file diff --git a/Interviewer.cs b/Interviews/Interviewer.cs similarity index 62% rename from Interviewer.cs rename to Interviews/Interviewer.cs index 6d3a3b1..54373be 100644 --- a/Interviewer.cs +++ b/Interviews/Interviewer.cs @@ -7,321 +7,11 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -namespace SupportChild; +namespace SupportChild.Interviews; public static class Interviewer { - public enum QuestionType - { - // Support multiselector as separate type, with only one subpath supported - ERROR, - END_WITH_SUMMARY, - END_WITHOUT_SUMMARY, - BUTTONS, - TEXT_SELECTOR, - USER_SELECTOR, - ROLE_SELECTOR, - MENTIONABLE_SELECTOR, // User or role - CHANNEL_SELECTOR, - TEXT_INPUT - } - - public enum ButtonType - { - // Secondary first to make it the default - SECONDARY, - PRIMARY, - SUCCESS, - DANGER - } - - // A tree of questions representing an interview. - // The tree is generated by the config file when a new ticket is opened or the restart interview command is used. - // Additional components not specified in the config file are populated as the interview progresses. - // The entire interview tree is serialized and stored in the database in order to record responses as they are made. - public class InterviewQuestion - { - // Title of the message embed. - [JsonProperty("title")] - public string title; - - // Message contents sent to the user. - [JsonProperty("message")] - public string message; - - // The type of question. - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("type")] - public QuestionType type; - - // Colour of the message embed. - [JsonProperty("color")] - public string color; - - // Used as label for this question in the post-interview summary. - [JsonProperty("summary-field")] - public string summaryField; - - // If this question is on a button, give it this style. - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("button-style")] - public ButtonType buttonStyle; - - // If this question is on a selector, give it this placeholder. - [JsonProperty("selector-placeholder")] - public string selectorPlaceholder; - - // If this question is on a selector, give it this description. - [JsonProperty("selector-description")] - public string selectorDescription; - - // The maximum length of a text input. - [JsonProperty("max-length")] - public int maxLength; - - // The minimum length of a text input. - [JsonProperty("min-length", Required = Required.Default)] - public int minLength; - - // Possible questions to ask next, an error message, or the end of the interview. - [JsonProperty("paths")] - public Dictionary paths; - - // //////////////////////////////////////////////////////////////////////////// - // The following parameters are populated by the bot, not the json template. // - // //////////////////////////////////////////////////////////////////////////// - - // The ID of this message where the bot asked this question. - [JsonProperty("message-id")] - public ulong messageID; - - // The contents of the user's answer. - [JsonProperty("answer")] - public string answer; - - // The ID of the user's answer message if this is a TEXT_INPUT type. - [JsonProperty("answer-id")] - public ulong answerID; - - // Any extra messages generated by the bot that should be removed when the interview ends. - [JsonProperty("related-message-ids")] - public List relatedMessageIDs; - - public bool TryGetCurrentQuestion(out InterviewQuestion question) - { - // This object has not been initialized, we have checked too deep. - if (messageID == 0) - { - question = null; - return false; - } - - // Check children. - foreach (KeyValuePair path in paths) - { - // This child either is the one we are looking for or contains the one we are looking for. - if (path.Value.TryGetCurrentQuestion(out question)) - { - return true; - } - } - - // This object is the deepest object with a message ID set, meaning it is the latest asked question. - question = this; - return true; - } - - public void GetSummary(ref OrderedDictionary summary) - { - if (messageID == 0) - { - return; - } - - if (!string.IsNullOrWhiteSpace(summaryField)) - { - summary.Add(summaryField, answer); - } - - // This will always contain exactly one or zero children. - foreach (KeyValuePair path in paths) - { - path.Value.GetSummary(ref summary); - } - } - - public void GetMessageIDs(ref List messageIDs) - { - if (messageID != 0) - { - messageIDs.Add(messageID); - } - - if (answerID != 0) - { - messageIDs.Add(answerID); - } - - if (relatedMessageIDs != null) - { - messageIDs.AddRange(relatedMessageIDs); - } - - // This will always contain exactly one or zero children. - foreach (KeyValuePair path in paths) - { - path.Value.GetMessageIDs(ref messageIDs); - } - } - - public void AddRelatedMessageIDs(params ulong[] messageIDs) - { - if (relatedMessageIDs == null) - { - relatedMessageIDs = messageIDs.ToList(); - } - else - { - relatedMessageIDs.AddRange(messageIDs); - } - } - - public DiscordButtonStyle GetButtonStyle() - { - return buttonStyle switch - { - ButtonType.PRIMARY => DiscordButtonStyle.Primary, - ButtonType.SECONDARY => DiscordButtonStyle.Secondary, - ButtonType.SUCCESS => DiscordButtonStyle.Success, - ButtonType.DANGER => DiscordButtonStyle.Danger, - _ => DiscordButtonStyle.Primary - }; - } - } - - // This class is identical to the one above and just exists as a hack to get JSON validation when - // new entries are entered but not when read from database in order to be more lenient with old interviews. - // I might do this in a more proper way at some point. - public class ValidatedInterviewQuestion - { - [JsonProperty("title", Required = Required.Default)] - public string title; - - [JsonProperty("message", Required = Required.Always)] - public string message; - - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("type", Required = Required.Always)] - public QuestionType type; - - [JsonProperty("color", Required = Required.Always)] - public string color; - - [JsonProperty("summary-field", Required = Required.Default)] - public string summaryField; - - [JsonConverter(typeof(StringEnumConverter))] - [JsonProperty("button-style", Required = Required.Default)] - public ButtonType buttonStyle; - - [JsonProperty("selector-placeholder", Required = Required.Default)] - public string selectorPlaceholder; - - [JsonProperty("selector-description", Required = Required.Default)] - public string selectorDescription; - - [JsonProperty("max-length", Required = Required.Default)] - public int maxLength; - - [JsonProperty("min-length", Required = Required.Default)] - public int minLength; - - [JsonProperty("paths", Required = Required.Always)] - public Dictionary paths; - - public void Validate(ref List errors, out int summaryCount, out int summaryMaxLength) - { - if (string.IsNullOrWhiteSpace(message)) - { - errors.Add("Message cannot be empty."); - } - - if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY) - { - if (paths.Count > 0) - { - errors.Add("'" + type + "' questions cannot have child paths."); - } - } - else if (paths.Count == 0) - { - errors.Add("'" + type + "' questions must have at least one child path."); - } - - List summaryCounts = new List(); - Dictionary childMaxLengths = new Dictionary(); - foreach (KeyValuePair path in paths) - { - path.Value.Validate(ref errors, out int summaries, out int maxLen); - summaryCounts.Add(summaries); - childMaxLengths.Add(path.Key, maxLen); - } - - summaryCount = summaryCounts.Count == 0 ? 0 : summaryCounts.Max(); - - string childPathString = ""; - int childMaxLength = 0; - if (childMaxLengths.Count != 0) - { - (childPathString, childMaxLength) = childMaxLengths.ToArray().MaxBy(x => x.Key.Length + x.Value); - } - - summaryMaxLength = childMaxLength; - - if (string.IsNullOrWhiteSpace(summaryField)) - { - ++summaryCount; - } - - // Only count summaries that end in a summary question. - if (type == QuestionType.END_WITH_SUMMARY) - { - summaryMaxLength = message?.Length ?? 0; - summaryMaxLength += title?.Length ?? 0; - } - // Only add to the total max length if the summary field is not empty. That way we know this branch ends in a summary. - else if (summaryMaxLength > 0 && !string.IsNullOrEmpty(summaryField)) - { - summaryMaxLength += summaryField.Length; - switch (type) - { - case QuestionType.BUTTONS: - case QuestionType.TEXT_SELECTOR: - summaryMaxLength += childPathString.Length; - break; - case QuestionType.USER_SELECTOR: - case QuestionType.ROLE_SELECTOR: - case QuestionType.MENTIONABLE_SELECTOR: - case QuestionType.CHANNEL_SELECTOR: - // Approximate length of a mention - summaryMaxLength += 23; - break; - case QuestionType.TEXT_INPUT: - summaryMaxLength += (maxLength == 0 ? 1024 : Math.Min(maxLength, 1024)); - break; - case QuestionType.END_WITH_SUMMARY: - case QuestionType.END_WITHOUT_SUMMARY: - case QuestionType.ERROR: - default: - break; - } - } - } - } - public static async void StartInterview(DiscordChannel channel) { if (channel.Parent == null) @@ -329,22 +19,13 @@ public static class Interviewer return; } - if (!Database.TryGetInterviewTemplates(out Dictionary templates)) + if (!Database.TryGetInterviewTemplate(channel.Parent.Id, out InterviewQuestion template)) { - await channel.SendMessageAsync(new DiscordEmbedBuilder - { - Description = "Attempted to create interview from template, but an error occured when reading it from the database.\n\n" + - "Tell a staff member to check the bot log and fix the template.", - Color = DiscordColor.Red - }); return; } - if (templates.TryGetValue(channel.Parent.Id, out InterviewQuestion interview)) - { - await CreateQuestion(channel, interview); - Database.SaveInterview(channel.Id, interview); - } + await CreateQuestion(channel, template); + Database.SaveInterview(channel.Id, template); } public static async Task RestartInterview(SlashCommandContext command) @@ -474,11 +155,13 @@ public static class Interviewer } Logger.Error("The interview for channel " + interaction.Channel.Id + " reached a question of type " + currentQuestion.type + " which has no valid next question. Their selection was:\n" + answer); - await interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().AddEmbed(new DiscordEmbedBuilder + DiscordMessage followupMessage = await interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().AddEmbed(new DiscordEmbedBuilder { Color = DiscordColor.Red, Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect." }).AsEphemeral()); + currentQuestion.AddRelatedMessageIDs(followupMessage.Id); + Database.SaveInterview(interaction.Channel.Id, interviewRoot); } else { @@ -532,7 +215,7 @@ public static class Interviewer } // The length requirement is less than 1024 characters, and must be less than the configurable limit if it is set. - int maxLength = currentQuestion.maxLength == 0 ? 1024 : Math.Min(currentQuestion.maxLength, 1024); + int maxLength = Math.Min(currentQuestion.maxLength ?? 1024, 1024); if (answerMessage.Content.Length > maxLength) { @@ -542,10 +225,11 @@ public static class Interviewer Color = DiscordColor.Red }); currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); + Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); return; } - if (answerMessage.Content.Length < currentQuestion.minLength) + if (answerMessage.Content.Length < (currentQuestion.minLength ?? 0)) { DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder { @@ -553,13 +237,17 @@ public static class Interviewer Color = DiscordColor.Red }); currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); + Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); return; } foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths) { // Skip to the first matching path. - if (!Regex.IsMatch(answerMessage.Content, questionString)) continue; + if (!Regex.IsMatch(answerMessage.Content, questionString)) + { + continue; + } await HandleAnswer(answerMessage.Content, nextQuestion, interviewRoot, currentQuestion, answerMessage.Channel, answerMessage); return; @@ -571,7 +259,8 @@ public static class Interviewer Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect.", Color = DiscordColor.Red }); - currentQuestion.AddRelatedMessageIDs(errorMessage.Id); + currentQuestion.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id); + Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); } private static async Task HandleAnswer(string answer, diff --git a/Interviews/ValidatedInterview.cs b/Interviews/ValidatedInterview.cs new file mode 100644 index 0000000..464800d --- /dev/null +++ b/Interviews/ValidatedInterview.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using SupportChild.Interviews; + +namespace SupportChild.Interviews; + +// This class is identical to the normal interview question and just exists as a hack to get JSON validation when +// new entries are entered but not when read from database in order to be more lenient with old interviews. +// I might do this in a more proper way at some point. +public class ValidatedInterviewQuestion +{ + [JsonProperty("title", Required = Required.Default)] + public string title; + + [JsonProperty("message", Required = Required.Always)] + public string message; + + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("type", Required = Required.Always)] + public QuestionType type; + + [JsonProperty("color", Required = Required.Always)] + public string color; + + [JsonProperty("summary-field", Required = Required.Default)] + public string summaryField; + + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("button-style", Required = Required.Default)] + public ButtonType? buttonStyle; + + [JsonProperty("selector-placeholder", Required = Required.Default)] + public string selectorPlaceholder; + + [JsonProperty("selector-description", Required = Required.Default)] + public string selectorDescription; + + [JsonProperty("max-length", Required = Required.Default)] + public int? maxLength; + + [JsonProperty("min-length", Required = Required.Default)] + public int? minLength; + + [JsonProperty("paths", Required = Required.Always)] + public Dictionary paths; + + public void Validate(ref List errors, out int summaryCount, out int summaryMaxLength) + { + if (string.IsNullOrWhiteSpace(message)) + { + errors.Add("Message cannot be empty."); + } + + if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY) + { + if (paths.Count > 0) + { + errors.Add("'" + type + "' questions cannot have child paths."); + } + } + else if (paths.Count == 0) + { + errors.Add("'" + type + "' questions must have at least one child path."); + } + + List summaryCounts = []; + Dictionary childMaxLengths = new Dictionary(); + foreach (KeyValuePair path in paths) + { + path.Value.Validate(ref errors, out int summaries, out int maxLen); + summaryCounts.Add(summaries); + childMaxLengths.Add(path.Key, maxLen); + } + + summaryCount = summaryCounts.Count == 0 ? 0 : summaryCounts.Max(); + + string childPathString = ""; + int childMaxLength = 0; + if (childMaxLengths.Count != 0) + { + (childPathString, childMaxLength) = childMaxLengths.ToArray().MaxBy(x => x.Key.Length + x.Value); + } + + summaryMaxLength = childMaxLength; + + if (string.IsNullOrWhiteSpace(summaryField)) + { + ++summaryCount; + } + + // Only count summaries that end in a summary question. + if (type == QuestionType.END_WITH_SUMMARY) + { + summaryMaxLength = message?.Length ?? 0; + summaryMaxLength += title?.Length ?? 0; + } + // Only add to the total max length if the summary field is not empty. That way we know this branch ends in a summary. + else if (summaryMaxLength > 0 && !string.IsNullOrEmpty(summaryField)) + { + summaryMaxLength += summaryField.Length; + switch (type) + { + case QuestionType.BUTTONS: + case QuestionType.TEXT_SELECTOR: + summaryMaxLength += childPathString.Length; + break; + case QuestionType.USER_SELECTOR: + case QuestionType.ROLE_SELECTOR: + case QuestionType.MENTIONABLE_SELECTOR: + case QuestionType.CHANNEL_SELECTOR: + // Approximate length of a mention + summaryMaxLength += 23; + break; + case QuestionType.TEXT_INPUT: + summaryMaxLength += Math.Min(maxLength ?? 1024, 1024); + break; + case QuestionType.END_WITH_SUMMARY: + case QuestionType.END_WITHOUT_SUMMARY: + case QuestionType.ERROR: + default: + break; + } + } + } +} + +public class ValidatedTemplate(ulong categoryID, ValidatedInterviewQuestion interview) +{ + [JsonProperty("category-id", Required = Required.Always)] + public ulong categoryID = categoryID; + + [JsonProperty("interview", Required = Required.Always)] + public ValidatedInterviewQuestion interview = interview; +} \ No newline at end of file