From 32776826bad1939a75ce41dd5148a089e8c8d466 Mon Sep 17 00:00:00 2001 From: Toastie Date: Thu, 26 Dec 2024 20:12:50 +1300 Subject: [PATCH] Created functioning interview system PoC --- Commands/AdminCommands.cs | 4 +- Commands/CloseCommand.cs | 1 + Database.cs | 48 ++++- EventHandler.cs | 40 +++-- Interviewer.cs | 359 +++++++++++++++++++++++++++++++------- SupportChild.cs | 2 - 6 files changed, 377 insertions(+), 77 deletions(-) diff --git a/Commands/AdminCommands.cs b/Commands/AdminCommands.cs index d561a96..442407d 100644 --- a/Commands/AdminCommands.cs +++ b/Commands/AdminCommands.cs @@ -221,14 +221,14 @@ public class AdminCommands Description = "Reloading bot application..." }); Logger.Log("Reloading bot..."); - SupportChild.Reload(); + await 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())); + MemoryStream stream = new(Encoding.UTF8.GetBytes(Database.GetInterviewTemplatesJSON())); await command.RespondAsync(new DiscordInteractionResponseBuilder().AddFile("interview-templates.json", stream)); } diff --git a/Commands/CloseCommand.cs b/Commands/CloseCommand.cs index da68298..07a12dd 100644 --- a/Commands/CloseCommand.cs +++ b/Commands/CloseCommand.cs @@ -133,6 +133,7 @@ public class CloseCommand } Database.ArchiveTicket(ticket); + Database.TryDeleteInterview(channelID); await interaction.EditOriginalResponseAsync(new DiscordWebhookBuilder().AddEmbed(new DiscordEmbedBuilder { diff --git a/Database.cs b/Database.cs index 0cd3cb3..1f6a9ed 100644 --- a/Database.cs +++ b/Database.cs @@ -5,6 +5,7 @@ using System.Security.Cryptography; using DSharpPlus; using MySqlConnector; using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace SupportChild; @@ -731,7 +732,7 @@ public static class Database } } - public static string GetInterviewTemplates() + public static string GetInterviewTemplatesJSON() { using MySqlConnection c = GetConnection(); c.Open(); @@ -750,6 +751,34 @@ public static class Database return templates; } + public static Dictionary 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 new Dictionary(); + } + + string templates = results.GetString("interview"); + results.Close(); + + return JsonConvert.DeserializeObject>(templates, 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 bool SetInterviewTemplates(string templates) { try @@ -854,6 +883,23 @@ public static class Database return true; } + public static bool TryDeleteInterview(ulong channelID) + { + try + { + using MySqlConnection c = GetConnection(); + c.Open(); + using MySqlCommand deletion = new MySqlCommand(@"DELETE FROM interviews WHERE channel_id=@channel_id", c); + deletion.Parameters.AddWithValue("@channel_id", channelID); + deletion.Prepare(); + return deletion.ExecuteNonQuery() > 0; + } + catch (MySqlException) + { + return false; + } + } + public class Ticket { public uint id; diff --git a/EventHandler.cs b/EventHandler.cs index 5d0c882..a49744d 100644 --- a/EventHandler.cs +++ b/EventHandler.cs @@ -58,12 +58,27 @@ public static class EventHandler return; } - // Check if ticket exists in the database and ticket notifications are enabled - if (!Database.TryGetOpenTicket(e.Channel.Id, out Database.Ticket ticket) || !Config.ticketUpdatedNotifications) + // Ignore messages outside of tickets. + if (!Database.TryGetOpenTicket(e.Channel.Id, out Database.Ticket ticket)) { return; } + // Send staff notification if applicable. + if (Config.ticketUpdatedNotifications) + { + await SendTicketUpdatedMessage(e, ticket); + } + + // Try to process the message as an interview response if the ticket owner replied to this bot. + if (ticket.creatorID == e.Author.Id && e.Message.ReferencedMessage?.Author == client.CurrentUser) + { + await Interviewer.ProcessResponseMessage(e.Message); + } + } + + private static async Task SendTicketUpdatedMessage(MessageCreatedEventArgs e, Database.Ticket ticket) + { // Ignore staff messages if (Database.IsStaff(e.Author.Id)) { @@ -114,7 +129,7 @@ public static class EventHandler catch (DiscordException ex) { Logger.Error("Exception occurred trying to add channel permissions: " + ex); - Logger.Error("JsomMessage: " + ex.JsonMessage); + Logger.Error("JsonMessage: " + ex.JsonMessage); } } @@ -183,19 +198,18 @@ public static class EventHandler await CloseCommand.OnConfirmed(e.Interaction); return; case not null when e.Id.StartsWith("supportchild_newcommandbutton"): - await OnCategorySelection(e.Interaction); + await OnNewTicketSelectorUsed(e.Interaction); return; case not null when e.Id.StartsWith("supportchild_newticketbutton"): - await OnButtonUsed(e.Interaction); + await OnNewTicketButtonUsed(e.Interaction); + return; + case not null when e.Id.StartsWith("supportchild_interviewbutton"): + await Interviewer.ProcessButtonResponse(e.Interaction); return; case "right": - return; case "left": - return; case "rightskip": - return; case "leftskip": - return; case "stop": return; default: @@ -206,7 +220,7 @@ public static class EventHandler switch (e.Id) { case not null when e.Id.StartsWith("supportchild_newcommandselector"): - await OnCategorySelection(e.Interaction); + await OnNewTicketSelectorUsed(e.Interaction); return; case not null when e.Id.StartsWith("supportchild_newticketselector"): await CreateSelectionBoxPanelCommand.OnSelectionMenuUsed(e.Interaction); @@ -241,13 +255,13 @@ public static class EventHandler } } - private static async Task OnButtonUsed(DiscordInteraction interaction) + private static async Task OnNewTicketButtonUsed(DiscordInteraction interaction) { await interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredChannelMessageWithSource, new DiscordInteractionResponseBuilder().AsEphemeral()); if (!ulong.TryParse(interaction.Data.CustomId.Replace("supportchild_newticketbutton ", ""), out ulong categoryID) || categoryID == 0) { - Logger.Warn("Invalid ID: " + interaction.Data.CustomId.Replace("supportchild_newticketbutton ", "")); + Logger.Warn("Invalid ticket button ID: " + interaction.Data.CustomId.Replace("supportchild_newticketbutton ", "")); return; } @@ -271,7 +285,7 @@ public static class EventHandler } } - private static async Task OnCategorySelection(DiscordInteraction interaction) + private static async Task OnNewTicketSelectorUsed(DiscordInteraction interaction) { string stringID; switch (interaction.Data.ComponentType) diff --git a/Interviewer.cs b/Interviewer.cs index 304e789..bec5889 100644 --- a/Interviewer.cs +++ b/Interviewer.cs @@ -1,12 +1,13 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; -using DSharpPlus; +using System.Text.RegularExpressions; +using System.Threading.Tasks; using DSharpPlus.Entities; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; namespace SupportChild; @@ -14,7 +15,8 @@ public static class Interviewer { public enum QuestionType { - FAIL, + ERROR, + CANCEL, DONE, BUTTONS, SELECTOR, @@ -27,6 +29,8 @@ public static class Interviewer // The entire interview tree is serialized and stored in the database in order to record responses as they are made. public class InterviewQuestion { + // TODO: Optional properties: embed title, button style + // Message contents sent to the user. [JsonProperty("message")] public string message; @@ -40,26 +44,84 @@ public static class Interviewer [JsonProperty("color")] public string color; - // 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; + + // 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; + + 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 (!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); + } + + // This will always contain exactly one or zero children. + foreach (KeyValuePair path in paths) + { + path.Value.GetMessageIDs(ref messageIDs); + } + } } // This class is identical to the one above and just exists as a hack to get JSON validation when @@ -80,77 +142,256 @@ public static class Interviewer [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)] + [JsonProperty("summary-field", Required = Required.Always)] 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 interviewTemplates = []; private static Dictionary activeInterviews = []; - public static void ParseTemplates(JToken interviewConfig) - { - 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() + // TODO: Maybe split into two functions? + public static void Reload() { + interviewTemplates = Database.GetInterviewTemplates(); activeInterviews = Database.GetAllInterviews(); } - public static void StartInterview(DiscordChannel channel) + public static async void StartInterview(DiscordChannel channel) { if (channel.Parent == null) { return; } - if (categoryInterviews.TryGetValue(channel.Parent.Id, out InterviewQuestion interview)) + if (interviewTemplates.TryGetValue(channel.Parent.Id, out InterviewQuestion interview)) { - CreateQuestion(channel, interview); + await CreateQuestion(channel, interview); Database.SaveInterview(channel.Id, interview); + Reload(); } } - public static void ProcessResponse(DiscordMessage message) + public static bool IsInterviewActive(ulong channelID) + { + return activeInterviews.ContainsKey(channelID); + } + + // TODO: Add selection box handling. + + public static async Task ProcessButtonResponse(DiscordInteraction interaction) + { + // TODO: Add error responses. + + if (interaction?.Channel == null || interaction?.Message == null) + { + return; + } + + await interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate); + + // Could not find active interview. + if (!activeInterviews.TryGetValue(interaction.Channel.Id, out InterviewQuestion interviewRoot)) + { + return; + } + + // Could not find message id in interview. + if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion)) + { + return; + } + + // This button is for an older question. + if (interaction.Message.Id != currentQuestion.messageID) + { + return; + } + + // Parse the response index from the button. + if (!int.TryParse(interaction.Data.CustomId.Replace("supportchild_interviewbutton ", ""), out int pathIndex)) + { + Logger.Error("Invalid interview button index: " + interaction.Data.CustomId.Replace("supportchild_interviewbutton ", "")); + return; + } + + if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0) + { + Logger.Error("Invalid interview button index: " + pathIndex); + return; + } + + KeyValuePair questionPath = currentQuestion.paths.ElementAt(pathIndex); + + + currentQuestion.answer = interaction.Data.Name; + currentQuestion.answerID = 0; + + // Create next question, or finish the interview. + InterviewQuestion nextQuestion = questionPath.Value; + switch (questionPath.Value.type) + { + case QuestionType.TEXT_INPUT: + await CreateQuestion(interaction.Channel, nextQuestion); + break; + case QuestionType.BUTTONS: + await CreateQuestion(interaction.Channel, nextQuestion); + // TODO: Remove buttons + break; + case QuestionType.SELECTOR: + await CreateQuestion(interaction.Channel, nextQuestion); + // TODO: Remove selector + break; + case QuestionType.DONE: + // TODO: Create summary. + // TODO: Remove previous interview messages. + // TODO: Remove active interview. + Logger.Error("INTERVIEW DONE"); + break; + case QuestionType.CANCEL: + default: + // TODO: Post fail message. + // TODO: Remove active interview. + Logger.Error("INTERVIEW FAILED"); + break; + } + + // Remove other paths. + currentQuestion.paths = new Dictionary + { + { questionPath.Key, nextQuestion } + }; + + await interaction.EditOriginalResponseAsync(new DiscordWebhookBuilder().AddEmbed(interaction.Message.Embeds[0])); + + Database.SaveInterview(interaction.Channel.Id, interviewRoot); + } + + public static async Task ProcessResponseMessage(DiscordMessage message) { - // TODO: Find if channel has open interview. - // TODO: Find if message is replying to interview message. // TODO: Handle other interactions like button presses. - // TODO: Find out where in the interview tree we are. // TODO: Handle FAIL event, cancelling the interview. // TODO: Handle DONE event, creating a summary. - //Database.SaveInterview(channel.Id, interview); + // Either the message or the referenced message is null. + if (message.Channel == null || message.ReferencedMessage?.Channel == null) + { + return; + } + + // The channel does not have an active interview. + if (!activeInterviews.TryGetValue(message.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot)) + { + return; + } + + if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion)) + { + return; + } + + // The user responded to something other than the latest interview question. + if (message.ReferencedMessage.Id != currentQuestion.messageID) + { + return; + } + + // The user responded to a question which does not take a text response. + if (currentQuestion.type != QuestionType.TEXT_INPUT) + { + return; + } + + foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths) + { + // Skip to the matching path. + if (!Regex.IsMatch(message.Content, questionString)) continue; + + // TODO: Refactor this into separate function to reduce duplication + + currentQuestion.answer = message.Content; + currentQuestion.answerID = message.Id; + + // Create next question, or finish the interview. + switch (nextQuestion.type) + { + case QuestionType.ERROR: + break; + case QuestionType.TEXT_INPUT: + case QuestionType.BUTTONS: + case QuestionType.SELECTOR: + await CreateQuestion(message.Channel, nextQuestion); + + // Remove other paths. + currentQuestion.paths = new Dictionary + { + { questionString, nextQuestion } + }; + + Database.SaveInterview(message.Channel.Id, interviewRoot); + break; + case QuestionType.DONE: + // TODO: Remove previous interview messages. + OrderedDictionary summaryFields = new OrderedDictionary(); + interviewRoot.GetSummary(ref summaryFields); + + DiscordEmbedBuilder embed = new DiscordEmbedBuilder() + { + Color = Utilities.StringToColor(nextQuestion.color), + Title = "Summary:", + Description = nextQuestion.message, + }; + + foreach (DictionaryEntry entry in summaryFields) + { + embed.AddField((string)entry.Key, (string)entry.Value); + } + + await message.Channel.SendMessageAsync(embed); + + List previousMessages = new List { }; + interviewRoot.GetMessageIDs(ref previousMessages); + + foreach (ulong previousMessageID in previousMessages) + { + try + { + Logger.Debug("Deleting message: " + previousMessageID); + DiscordMessage previousMessage = await message.Channel.GetMessageAsync(previousMessageID); + await message.Channel.DeleteMessageAsync(previousMessage, "Deleting old interview message."); + } + catch (Exception e) + { + Logger.Error("Failed to delete old interview message: " + e.Message); + } + + } + + if (!Database.TryDeleteInterview(message.Channel.Id)) + { + Logger.Error("Could not delete interview from database. Channel ID: " + message.Channel.Id); + } + Reload(); + return; + case QuestionType.CANCEL: + default: + // TODO: Post fail message. + // TODO: Remove active interview. + break; + } + return; + } + + // TODO: No matching path found. + } - private static async void CreateQuestion(DiscordChannel channel, InterviewQuestion question) + private static async Task CreateQuestion(DiscordChannel channel, InterviewQuestion question) { DiscordMessageBuilder msgBuilder = new DiscordMessageBuilder(); DiscordEmbedBuilder embed = new DiscordEmbedBuilder() @@ -190,10 +431,10 @@ public static class Interviewer msgBuilder.AddComponents(selectionComponents); break; case QuestionType.TEXT_INPUT: - embed.WithFooter("Reply to this message with your answer."); + embed.WithFooter("Reply to this message with your answer. You cannot include images or files."); break; case QuestionType.DONE: - case QuestionType.FAIL: + case QuestionType.CANCEL: default: break; } diff --git a/SupportChild.cs b/SupportChild.cs index 940f4a6..9c6930f 100644 --- a/SupportChild.cs +++ b/SupportChild.cs @@ -152,8 +152,6 @@ internal static class SupportChild try { Logger.Log("Loading interviews from database..."); - Interviewer.ParseTemplates(Database.GetInterviewTemplates()); - Interviewer.LoadActiveInterviews(); } catch (Exception e) {