From d212f13b120fb2848aca092ed8c18ee955db4db9 Mon Sep 17 00:00:00 2001 From: Toastie Date: Thu, 26 Dec 2024 19:50:47 +1300 Subject: [PATCH] Add commands to update interview templates --- Commands/AdminCommands.cs | 102 ++++++++++++++++++++++++++++++++++++++ Config.cs | 5 -- Database.cs | 85 +++++++++++++++++++++++-------- Interviewer.cs | 71 ++++++++++++++++++++++++-- SupportChild.cs | 21 +++++--- SupportChild.csproj | 3 +- Utilities.cs | 1 - default_config.yml | 23 +-------- 8 files changed, 250 insertions(+), 61 deletions(-) diff --git a/Commands/AdminCommands.cs b/Commands/AdminCommands.cs index 08f15a2..d561a96 100644 --- a/Commands/AdminCommands.cs +++ b/Commands/AdminCommands.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Linq; +using System.Net.Http; +using System.Runtime.Serialization; +using System.Text; using System.Threading.Tasks; using DSharpPlus.Commands; using DSharpPlus.Commands.ContextChecks; @@ -10,6 +14,10 @@ using DSharpPlus.Entities; using DSharpPlus.Exceptions; using DSharpPlus.Interactivity; using DSharpPlus.Interactivity.Extensions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Schema; +using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; namespace SupportChild.Commands; @@ -215,4 +223,98 @@ public class AdminCommands Logger.Log("Reloading bot..."); SupportChild.Reload(); } + + [Command("getinterviewtemplates")] + [Description("Provides a copy of the interview templates which you can edit and then reupload.")] + public async Task GetInterviewTemplates(SlashCommandContext command) + { + MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(Database.GetInterviewTemplates())); + await command.RespondAsync(new DiscordInteractionResponseBuilder().AddFile("interview-templates.json", stream)); + } + + [Command("setinterviewtemplates")] + [Description("Uploads an interview template file.")] + public async Task SetInterviewTemplates(SlashCommandContext command, [Parameter("file")] DiscordAttachment file) + { + if (!file.MediaType?.Contains("application/json") ?? false) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + + Color = DiscordColor.Red, + Description = "The uploaded file is not a JSON file according to Discord." + }); + return; + } + + Stream stream = await new HttpClient().GetStreamAsync(file.Url); + string json = await new StreamReader(stream).ReadToEndAsync(); + + try + { + List errors = []; + + // Convert it to an interview object to validate the template + Dictionary interview = JsonConvert.DeserializeObject>(json, new JsonSerializerSettings() + { + //NullValueHandling = NullValueHandling.Include, + MissingMemberHandling = MissingMemberHandling.Error, + Error = delegate (object sender, ErrorEventArgs args) + { + // I noticed the main exception mainly has information for developers, not administrators, + // so I switched to using the inner message if available. + if (string.IsNullOrEmpty(args.ErrorContext.Error.InnerException?.Message)) + { + errors.Add(args.ErrorContext.Error.Message); + } + else + { + errors.Add(args.ErrorContext.Error.InnerException.Message); + } + + Logger.Debug("Exception occured when trying to upload interview template:\n" + args.ErrorContext.Error); + args.ErrorContext.Handled = true; + } + }); + + if (errors.Count != 0) + { + string errorString = string.Join("\n\n", errors); + if (errorString.Length > 1500) + { + errorString = errorString.Substring(0, 1500); + } + + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "The uploaded JSON structure could not be converted to an interview template.\n\nErrors:\n```\n" + errorString + "\n```", + Footer = new DiscordEmbedBuilder.EmbedFooter() + { + Text = "More detailed information may be available as debug messages in the bot logs." + } + }); + return; + } + + Database.SetInterviewTemplates(JsonConvert.SerializeObject(interview, Formatting.Indented)); + } + catch (Exception e) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + + Color = DiscordColor.Red, + Description = "The uploaded JSON structure could not be converted to an interview template.\n\nError message:\n```\n" + e.Message + "\n```" + }); + return; + } + + await command.RespondAsync(new DiscordEmbedBuilder + { + + Color = DiscordColor.Green, + Description = "Uploaded interview template." + }); + } } \ No newline at end of file diff --git a/Config.cs b/Config.cs index 1993673..351ecdc 100644 --- a/Config.cs +++ b/Config.cs @@ -32,8 +32,6 @@ internal static class Config internal static string username = "supportchild"; internal static string password = ""; - internal static JToken interviews; - private static string configPath = "./config.yml"; public static void LoadConfig() @@ -102,8 +100,5 @@ internal static class Config database = json.SelectToken("database.name")?.Value() ?? "supportchild"; username = json.SelectToken("database.user")?.Value() ?? "supportchild"; password = json.SelectToken("database.password")?.Value() ?? ""; - - // Set up interviewer - interviews = json.SelectToken("interviews") ?? new JObject(); } } \ No newline at end of file diff --git a/Database.cs b/Database.cs index d1d1bda..0cd3cb3 100644 --- a/Database.cs +++ b/Database.cs @@ -731,6 +731,52 @@ public static class Database } } + public static string GetInterviewTemplates() + { + using MySqlConnection c = GetConnection(); + c.Open(); + using MySqlCommand selection = new MySqlCommand("SELECT * FROM interviews WHERE channel_id=0", c); + selection.Prepare(); + MySqlDataReader results = selection.ExecuteReader(); + + // Check if messages exist in the database + if (!results.Read()) + { + return "{}"; + } + + string templates = results.GetString("interview"); + results.Close(); + return templates; + } + + public static bool SetInterviewTemplates(string templates) + { + try + { + string query; + if (TryGetInterview(0, out _)) + { + query = "UPDATE interviews SET interview = @interview WHERE channel_id = 0"; + } + else + { + query = "INSERT INTO interviews (channel_id,interview) VALUES (0, @interview)"; + } + + using MySqlConnection c = GetConnection(); + c.Open(); + using MySqlCommand cmd = new MySqlCommand(query, c); + cmd.Parameters.AddWithValue("@interview", templates); + cmd.Prepare(); + return cmd.ExecuteNonQuery() > 0; + } + catch (MySqlException) + { + return false; + } + } + public static Dictionary GetAllInterviews() { using MySqlConnection c = GetConnection(); @@ -745,14 +791,16 @@ public static class Database return new Dictionary(); } - Dictionary questions = new Dictionary + Dictionary questions = new(); + do { - { results.GetUInt64("channel_id"), JsonConvert.DeserializeObject(results.GetString("interview")) } - }; - while (results.Read()) - { - questions.Add(results.GetUInt64("channel_id"), JsonConvert.DeserializeObject(results.GetString("interview"))); + // Channel ID 0 is the interview template + if (results.GetUInt64("channel_id") != 0) + { + questions.Add(results.GetUInt64("channel_id"), JsonConvert.DeserializeObject(results.GetString("interview"))); + } } + while (results.Read()); results.Close(); return questions; @@ -762,26 +810,23 @@ public static class Database { try { + string query; if (TryGetInterview(channelID, out _)) { - using MySqlConnection c = GetConnection(); - c.Open(); - using MySqlCommand cmd = new MySqlCommand(@"UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id;", c); - cmd.Parameters.AddWithValue("@channel_id", channelID); - cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview)); - cmd.Prepare(); - return cmd.ExecuteNonQuery() > 0; + query = "UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id"; } else { - using MySqlConnection c = GetConnection(); - c.Open(); - using MySqlCommand cmd = new MySqlCommand(@"INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview);", c); - cmd.Parameters.AddWithValue("@channel_id", channelID); - cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview)); - cmd.Prepare(); - return cmd.ExecuteNonQuery() > 0; + query = "INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview)"; } + + using MySqlConnection c = GetConnection(); + c.Open(); + using MySqlCommand cmd = new MySqlCommand(query, c); + cmd.Parameters.AddWithValue("@channel_id", channelID); + cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview)); + cmd.Prepare(); + return cmd.ExecuteNonQuery() > 0; } catch (MySqlException) { diff --git a/Interviewer.cs b/Interviewer.cs index 506b28b..304e789 100644 --- a/Interviewer.cs +++ b/Interviewer.cs @@ -1,9 +1,12 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using DSharpPlus; using DSharpPlus.Entities; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; namespace SupportChild; @@ -11,8 +14,8 @@ public static class Interviewer { public enum QuestionType { - DONE, FAIL, + DONE, BUTTONS, SELECTOR, TEXT_INPUT @@ -25,37 +28,95 @@ public static class Interviewer public class InterviewQuestion { // 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; - // The ID of this message, populated after it has been sent. + // The ID of this message where the bot asked this question, + // populated after it has been sent. + [JsonProperty("message-id")] public ulong messageID; // Used as label for this question in the post-interview summary. + [JsonProperty("summary-field")] public string summaryField; // The user's response to the question. + [JsonProperty("answer")] public string answer; // The ID of the user's answer message, populated after it has been received. + [JsonProperty("answer-id")] public ulong answerID; // Possible questions to ask next, or DONE/FAIL type in order to finish interview. + [JsonProperty("paths")] public Dictionary paths; } + // 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 + { + // Message contents sent to the user. + [JsonProperty("message", Required = Required.Always)] + public string message; + + // The type of question. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("type", Required = Required.Always)] + public QuestionType type; + + // Colour of the message embed. + [JsonProperty("color", Required = Required.Always)] + public string color; + + // The ID of this message where the bot asked this question, + // populated after it has been sent. + [JsonProperty("message-id", Required = Required.Default)] + public ulong messageID; + + // Used as label for this question in the post-interview summary. + [JsonProperty("summary-field", Required = Required.Default)] + public string summaryField; + + // The user's response to the question. + [JsonProperty("answer", Required = Required.Default)] + public string answer; + + // The ID of the user's answer message, populated after it has been received. + [JsonProperty("answer-id", Required = Required.Default)] + public ulong answerID; + + // Possible questions to ask next, or DONE/FAIL type in order to finish interview. + [JsonProperty("paths", Required = Required.Always)] + public Dictionary paths; + } + private static Dictionary categoryInterviews = []; private static Dictionary activeInterviews = []; - public static void ParseConfig(JToken interviewConfig) + public static void ParseTemplates(JToken interviewConfig) { - categoryInterviews = JsonConvert.DeserializeObject>(interviewConfig.ToString()); + categoryInterviews = JsonConvert.DeserializeObject>(interviewConfig.ToString(), new JsonSerializerSettings + { + Error = delegate (object sender, ErrorEventArgs args) + { + Logger.Error("Exception occured when trying to read interview from database:\n" + args.ErrorContext.Error.Message); + Logger.Debug("Detailed exception:", args.ErrorContext.Error); + args.ErrorContext.Handled = true; + } + }); } public static void LoadActiveInterviews() @@ -107,7 +168,7 @@ public static class Interviewer List buttonRow = []; for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++) { - buttonRow.Add(new DiscordButtonComponent(ButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key)); + buttonRow.Add(new DiscordButtonComponent(DiscordButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key)); } msgBuilder.AddComponents(buttonRow); } diff --git a/SupportChild.cs b/SupportChild.cs index c78452f..940f4a6 100644 --- a/SupportChild.cs +++ b/SupportChild.cs @@ -93,7 +93,11 @@ internal static class SupportChild Logger.Log("Starting " + Assembly.GetEntryAssembly()?.GetName().Name + " version " + GetVersion() + "..."); try { - Reload(); + if (!await Reload()) + { + Logger.Fatal("Aborting startup due to a fatal error..."); + return; + } // Block this task until the program is closed. await Task.Delay(-1); @@ -115,7 +119,7 @@ internal static class SupportChild + " (" + ThisAssembly.Git.Commit + ")"; } - public static async void Reload() + public static async Task Reload() { if (client != null) { @@ -141,20 +145,20 @@ internal static class SupportChild } catch (Exception e) { - Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: " + e); - throw; + Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: ", e); + return false; } try { - Logger.Log("Connecting to database... (" + Config.hostName + ":" + Config.port + ")"); - Interviewer.ParseConfig(Config.interviews); + Logger.Log("Loading interviews from database..."); + Interviewer.ParseTemplates(Database.GetInterviewTemplates()); Interviewer.LoadActiveInterviews(); } catch (Exception e) { - Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: " + e); - throw; + Logger.Fatal("Could not load interviews from database. Error: ", e); + return false; } Logger.Log("Setting up Discord client..."); @@ -233,6 +237,7 @@ internal static class SupportChild Logger.Log("Connecting to Discord..."); await client.ConnectAsync(); + return true; } } diff --git a/SupportChild.csproj b/SupportChild.csproj index c5a40ab..4339399 100644 --- a/SupportChild.csproj +++ b/SupportChild.csproj @@ -42,11 +42,12 @@ + - + diff --git a/Utilities.cs b/Utilities.cs index f960589..8d368b8 100644 --- a/Utilities.cs +++ b/Utilities.cs @@ -66,7 +66,6 @@ public static class Utilities return verifiedCategories; } - public static string ReadManifestData(string embeddedFileName) { Assembly assembly = Assembly.GetExecutingAssembly(); diff --git a/default_config.yml b/default_config.yml index e4c83f3..70a012d 100644 --- a/default_config.yml +++ b/default_config.yml @@ -19,7 +19,7 @@ # Whether staff members should be randomly assigned tickets when they are made. Individual staff members can opt out using the toggleactive command. random-assignment: true - # If set to true the rasssign command will include staff members set as inactive if a specific role is specified in the command. + # If set to true the rassign command will include staff members set as inactive if a specific role is specified in the command. # This can be useful if you have admins set as inactive to not automatically receive tickets and then have moderators elevate tickets when needed. random-assign-role-override: true @@ -63,23 +63,4 @@ database: # Username and password for authentication. user: "" - password: "" - -# TODO: May want to move interview entries to subkey to make room for additional interview settings -interviews: - 000000000000000000: - message: "Are you appealing your own ban or on behalf of another user?" - type: "BUTTONS" - color: "CYAN" - paths: - - "My own ban": # TODO: Can I add button color support somehow? - text: "What is your user name?" - type: "TEXT_INPUT" - summary-field: "Username" - paths: - - ".*": - text: "Please write your appeal below, motivate why you think you should be unbanned." - - "Another user's ban": - text: "You can only appeal your own ban. Please close this ticket." - type: "FAIL" - color: "CYAN" \ No newline at end of file + password: "" \ No newline at end of file