From 61b57d0afcaf59616a2d629ebcfe960b0c820a85 Mon Sep 17 00:00:00 2001 From: Toastie <toastie@toastiet0ast.com> Date: Tue, 4 Feb 2025 16:32:21 +1300 Subject: [PATCH] Added step references, ability to jump to other steps depending on user actions --- Commands/InterviewCommands.cs | 2 +- Commands/InterviewTemplateCommands.cs | 23 ++- Database.cs | 39 ++-- Interviews/Interview.cs | 121 +++++++++--- Interviews/Interviewer.cs | 222 ++++++++++++++++------ Interviews/interview_template.schema.json | 14 +- 6 files changed, 315 insertions(+), 106 deletions(-) diff --git a/Commands/InterviewCommands.cs b/Commands/InterviewCommands.cs index 1629f77..b70491c 100644 --- a/Commands/InterviewCommands.cs +++ b/Commands/InterviewCommands.cs @@ -62,7 +62,7 @@ public class InterviewCommands return; } - if (!Database.TryGetInterview(command.Channel.Id, out InterviewStep interviewRoot)) + if (!Database.TryGetInterview(command.Channel.Id, out Interview _)) { await command.RespondAsync(new DiscordEmbedBuilder { diff --git a/Commands/InterviewTemplateCommands.cs b/Commands/InterviewTemplateCommands.cs index c3af250..7955d26 100644 --- a/Commands/InterviewTemplateCommands.cs +++ b/Commands/InterviewTemplateCommands.cs @@ -102,17 +102,34 @@ public class InterviewTemplateCommands JsonSerializer serializer = new(); Template template = serializer.Deserialize<Template>(validatingReader); - DiscordChannel category = await SupportChild.client.GetChannelAsync(template.categoryID); + DiscordChannel category; + try + { + category = await SupportChild.client.GetChannelAsync(template.categoryID); + } + catch (Exception e) + { + await command.RespondAsync(new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "Could not get the category from the ID in the uploaded JSON structure." + }, true); + Logger.Warn("Failed to get template category from ID: " + template.categoryID, e); + return; + } + if (!category.IsCategory) { await command.RespondAsync(new DiscordEmbedBuilder { Color = DiscordColor.Red, - Description = "The category ID in the uploaded JSON structure is not a valid category." + Description = "The channel ID in the uploaded JSON structure is not a category." }, true); return; } + //TODO: Validate that any references with reference end steps have an after reference step + List<string> errors = []; List<string> warnings = []; template.interview.Validate(ref errors, ref warnings, "interview", 0, 0); @@ -208,7 +225,7 @@ public class InterviewTemplateCommands return; } - if (!Database.TryGetInterviewTemplate(category.Id, out InterviewStep _)) + if (!Database.TryGetInterviewFromTemplate(category.Id, 0, out Interview _)) { await command.RespondAsync(new DiscordEmbedBuilder { diff --git a/Database.cs b/Database.cs index abfd4cd..76b2b36 100644 --- a/Database.cs +++ b/Database.cs @@ -113,7 +113,8 @@ public static class Database using MySqlCommand createInterviews = new MySqlCommand( "CREATE TABLE IF NOT EXISTS interviews(" + "channel_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," + - "interview JSON NOT NULL)", + "interview JSON NOT NULL," + + "definitions JSON NOT NULL)", c); using MySqlCommand createInterviewTemplates = new MySqlCommand( "CREATE TABLE IF NOT EXISTS interview_templates(" + @@ -753,7 +754,7 @@ public static class Database return templates; } - public static bool TryGetInterviewTemplate(ulong categoryID, out Interviews.InterviewStep template) + public static bool TryGetInterviewFromTemplate(ulong categoryID, ulong channelID, out Interviews.Interview interview) { using MySqlConnection c = GetConnection(); c.Open(); @@ -765,7 +766,7 @@ public static class Database // Check if messages exist in the database if (!results.Read()) { - template = null; + interview = null; return false; } @@ -774,7 +775,7 @@ public static class Database try { - template = JsonConvert.DeserializeObject<Interviews.Template>(templateString, new JsonSerializerSettings + Template template = JsonConvert.DeserializeObject<Template>(templateString, new JsonSerializerSettings { Error = delegate (object sender, ErrorEventArgs args) { @@ -782,21 +783,22 @@ public static class Database Logger.Debug("Detailed exception:", args.ErrorContext.Error); args.ErrorContext.Handled = false; } - }).interview; + }); + interview = new Interview(channelID, template.interview, template.definitions); return true; } catch (Exception) { - template = null; + interview = null; return false; } } - public static bool SetInterviewTemplate(Interviews.Template template) + public static bool SetInterviewTemplate(Template template) { try { - string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings() + string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, MissingMemberHandling = MissingMemberHandling.Error, @@ -805,7 +807,7 @@ public static class Database }); string query; - if (TryGetInterviewTemplate(template.categoryID, out _)) + if (TryGetInterviewFromTemplate(template.categoryID, 0, out _)) { query = "UPDATE interview_templates SET template = @template WHERE category_id=@category_id"; } @@ -845,25 +847,26 @@ public static class Database } } - public static bool SaveInterview(ulong channelID, Interviews.InterviewStep interview) + public static bool SaveInterview(Interview interview) { try { string query; - if (TryGetInterview(channelID, out _)) + if (TryGetInterview(interview.channelID, out _)) { - query = "UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id"; + query = "UPDATE interviews SET interview = @interview, definitions = @definitions WHERE channel_id = @channel_id"; } else { - query = "INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview)"; + query = "INSERT INTO interviews (channel_id,interview, definitions) VALUES (@channel_id, @interview, @definitions)"; } 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, Formatting.Indented)); + cmd.Parameters.AddWithValue("@channel_id", interview.channelID); + cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview.interviewRoot, Formatting.Indented)); + cmd.Parameters.AddWithValue("@definitions", JsonConvert.SerializeObject(interview.definitions, Formatting.Indented)); cmd.Prepare(); return cmd.ExecuteNonQuery() > 0; } @@ -873,7 +876,7 @@ public static class Database } } - public static bool TryGetInterview(ulong channelID, out Interviews.InterviewStep interview) + public static bool TryGetInterview(ulong channelID, out Interview interview) { using MySqlConnection c = GetConnection(); c.Open(); @@ -888,7 +891,9 @@ public static class Database interview = null; return false; } - interview = JsonConvert.DeserializeObject<Interviews.InterviewStep>(results.GetString("interview")); + interview = new Interview(channelID, + JsonConvert.DeserializeObject<InterviewStep>(results.GetString("interview")), + JsonConvert.DeserializeObject<Dictionary<string, InterviewStep>>(results.GetString("definitions"))); results.Close(); return true; } diff --git a/Interviews/Interview.cs b/Interviews/Interview.cs index 505e7c1..3b7d544 100644 --- a/Interviews/Interview.cs +++ b/Interviews/Interview.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; using DSharpPlus.Entities; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -22,7 +23,8 @@ public enum MessageType ROLE_SELECTOR, MENTIONABLE_SELECTOR, // User or role CHANNEL_SELECTOR, - TEXT_INPUT + TEXT_INPUT, + REFERENCE_END } public enum ButtonType @@ -33,6 +35,45 @@ public enum ButtonType DANGER } +public class ReferencedInterviewStep +{ + [JsonProperty("id")] + public string id; + + // If this step is on a button, give it this style. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("button-style")] + public ButtonType? buttonStyle; + + // If this step is in a selector, give it this description. + [JsonProperty("selector-description")] + public string selectorDescription; + + // Runs at the end of the reference + [JsonProperty("after-reference-step")] + public InterviewStep afterReferenceStep; + + public DiscordButtonStyle GetButtonStyle() + { + return InterviewStep.GetButtonStyle(buttonStyle); + } + + public bool TryGetReferencedStep(Interview interview, out InterviewStep step) + { + if (!interview.definitions.TryGetValue(id, out step)) + { + Logger.Error("Could not find referenced step '" + id + "' in interview for channel '" + interview.channelID + "'"); + return false; + } + + step.buttonStyle = buttonStyle; + step.selectorDescription = selectorDescription; + step.afterReferenceStep = afterReferenceStep; + + return true; + } +} + // A tree of steps 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. @@ -54,7 +95,7 @@ public class InterviewStep // Colour of the message embed. [JsonProperty("color")] - public string color; + public string color = "CYAN"; // Used as label for this answer in the post-interview summary. [JsonProperty("summary-field")] @@ -65,11 +106,11 @@ public class InterviewStep [JsonProperty("button-style")] public ButtonType? buttonStyle; - // If this step is on a selector, give it this placeholder. + // If this step is a selector, give it this placeholder. [JsonProperty("selector-placeholder")] public string selectorPlaceholder; - // If this step is on a selector, give it this description. + // If this step is in a selector, give it this description. [JsonProperty("selector-description")] public string selectorDescription; @@ -81,6 +122,10 @@ public class InterviewStep [JsonProperty("min-length")] public int? minLength; + // References to steps defined elsewhere in the template + [JsonProperty("step-references")] + public Dictionary<string, ReferencedInterviewStep> references = new(); + // Possible questions to ask next, an error message, or the end of the interview. [JsonProperty("steps")] public Dictionary<string, InterviewStep> steps = new(); @@ -105,12 +150,23 @@ public class InterviewStep [JsonProperty("related-message-ids")] public List<ulong> relatedMessageIDs; - public bool TryGetCurrentStep(out InterviewStep step) + // This is only set when the user gets to a referenced step + [JsonProperty("after-reference-step")] + public InterviewStep afterReferenceStep = null; + + public bool TryGetCurrentStep(out InterviewStep currentStep) + { + bool result = TryGetTakenSteps(out List<InterviewStep> previousSteps); + currentStep = previousSteps.FirstOrDefault(); + return result; + } + + public bool TryGetTakenSteps(out List<InterviewStep> previousSteps) { // This object has not been initialized, we have checked too deep. if (messageID == 0) { - step = null; + previousSteps = null; return false; } @@ -118,14 +174,15 @@ public class InterviewStep foreach (KeyValuePair<string, InterviewStep> childStep in steps) { // This child either is the one we are looking for or contains the one we are looking for. - if (childStep.Value.TryGetCurrentStep(out step)) + if (childStep.Value.TryGetTakenSteps(out previousSteps)) { + previousSteps.Add(this); return true; } } // This object is the deepest object with a message ID set, meaning it is the latest asked question. - step = this; + previousSteps = new List<InterviewStep> { this }; return true; } @@ -138,7 +195,8 @@ public class InterviewStep if (!string.IsNullOrWhiteSpace(summaryField)) { - summary.Add(summaryField, answer); + // TODO: Add option to merge answers + summary[summaryField] = answer; } // This will always contain exactly one or zero children. @@ -184,18 +242,6 @@ public class InterviewStep } } - 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 void Validate(ref List<string> errors, ref List<string> warnings, string stepID, @@ -237,7 +283,7 @@ public class InterviewStep if (messageType is MessageType.ERROR or MessageType.END_WITH_SUMMARY or MessageType.END_WITHOUT_SUMMARY) { - if (steps.Count > 0) + if (steps.Count > 0 || references.Count > 0) { warnings.Add("Steps of the type '" + messageType + "' cannot have child steps.\n\n" + stepID + ".message-type"); } @@ -247,7 +293,7 @@ public class InterviewStep warnings.Add("Steps of the type '" + messageType + "' cannot have summary field names.\n\n" + stepID + ".summary-field"); } } - else if (steps.Count == 0) + else if (steps.Count == 0 && references.Count == 0) { errors.Add("Steps of the type '" + messageType + "' must have at least one child step.\n\n" + stepID + ".message-type"); } @@ -304,6 +350,23 @@ public class InterviewStep } } + public DiscordButtonStyle GetButtonStyle() + { + return GetButtonStyle(buttonStyle); + } + + public static DiscordButtonStyle GetButtonStyle(ButtonType? buttonStyle) + { + return buttonStyle switch + { + ButtonType.PRIMARY => DiscordButtonStyle.Primary, + ButtonType.SECONDARY => DiscordButtonStyle.Secondary, + ButtonType.SUCCESS => DiscordButtonStyle.Success, + ButtonType.DANGER => DiscordButtonStyle.Danger, + _ => DiscordButtonStyle.Secondary + }; + } + public class StripInternalPropertiesResolver : DefaultContractResolver { private static readonly HashSet<string> ignoreProps = @@ -326,11 +389,21 @@ public class InterviewStep } } -public class Template(ulong categoryID, InterviewStep interview) +public class Interview(ulong channelID, InterviewStep interviewRoot, Dictionary<string, InterviewStep> definitions) +{ + public ulong channelID = channelID; + public InterviewStep interviewRoot = interviewRoot; + public Dictionary<string, InterviewStep> definitions = definitions; +} + +public class Template(ulong categoryID, InterviewStep interview, Dictionary<string, InterviewStep> definitions) { [JsonProperty("category-id", Required = Required.Always)] public ulong categoryID = categoryID; [JsonProperty("interview", Required = Required.Always)] public InterviewStep interview = interview; + + [JsonProperty("definitions", Required = Required.Default)] + public Dictionary<string, InterviewStep> definitions = definitions; } \ No newline at end of file diff --git a/Interviews/Interviewer.cs b/Interviews/Interviewer.cs index fb5ed0b..3b32630 100644 --- a/Interviews/Interviewer.cs +++ b/Interviews/Interviewer.cs @@ -14,22 +14,22 @@ public static class Interviewer { public static async Task<bool> StartInterview(DiscordChannel channel) { - if (!Database.TryGetInterviewTemplate(channel.Parent.Id, out InterviewStep template)) + if (!Database.TryGetInterviewFromTemplate(channel.Parent.Id, channel.Id, out Interview interview)) { return false; } - await SendNextMessage(channel, template); - return Database.SaveInterview(channel.Id, template); + await SendNextMessage(channel, interview.interviewRoot); + return Database.SaveInterview(interview); } public static async Task<bool> RestartInterview(DiscordChannel channel) { - if (Database.TryGetInterview(channel.Id, out InterviewStep interviewRoot)) + if (Database.TryGetInterview(channel.Id, out Interview interview)) { if (Config.deleteMessagesAfterNoSummary) { - await DeletePreviousMessages(interviewRoot, channel); + await DeletePreviousMessages(interview, channel); } if (!Database.TryDeleteInterview(channel.Id)) @@ -43,11 +43,11 @@ public static class Interviewer public static async Task<bool> StopInterview(DiscordChannel channel) { - if (Database.TryGetInterview(channel.Id, out InterviewStep interviewRoot)) + if (Database.TryGetInterview(channel.Id, out Interview interview)) { if (Config.deleteMessagesAfterNoSummary) { - await DeletePreviousMessages(interviewRoot, channel); + await DeletePreviousMessages(interview, channel); } if (!Database.TryDeleteInterview(channel.Id)) @@ -61,7 +61,7 @@ public static class Interviewer public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction) { - if (interaction?.Channel == null || interaction?.Message == null) + if (interaction?.Channel == null || interaction.Message == null) { return; } @@ -74,7 +74,7 @@ public static class Interviewer } // Return if there is no active interview in this channel - if (!Database.TryGetInterview(interaction.Channel.Id, out InterviewStep interviewRoot)) + if (!Database.TryGetInterview(interaction.Channel.Id, out Interview interview)) { await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() .AddEmbed(new DiscordEmbedBuilder() @@ -85,7 +85,7 @@ public static class Interviewer } // Return if the current question cannot be found in the interview. - if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep)) + if (!interview.interviewRoot.TryGetCurrentStep(out InterviewStep currentStep)) { await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() .AddEmbed(new DiscordEmbedBuilder() @@ -126,19 +126,19 @@ public static class Interviewer case DiscordComponentType.RoleSelect: case DiscordComponentType.ChannelSelect: case DiscordComponentType.MentionableSelect: - if (interaction.Data.Resolved?.Roles?.Any() ?? false) + if (interaction.Data.Resolved.Roles.Any()) { answer = interaction.Data.Resolved.Roles.First().Value.Mention; } - else if (interaction.Data.Resolved?.Users?.Any() ?? false) + else if (interaction.Data.Resolved.Users.Any()) { answer = interaction.Data.Resolved.Users.First().Value.Mention; } - else if (interaction.Data.Resolved?.Channels?.Any() ?? false) + else if (interaction.Data.Resolved.Channels.Any()) { answer = interaction.Data.Resolved.Channels.First().Value.Mention; } - else if (interaction.Data.Resolved?.Messages?.Any() ?? false) + else if (interaction.Data.Resolved.Messages.Any()) { answer = interaction.Data.Resolved.Messages.First().Value.Id.ToString(); } @@ -147,7 +147,7 @@ public static class Interviewer componentID = interaction.Data.Values[0]; break; case DiscordComponentType.Button: - componentID = interaction.Data.CustomId.Replace("supportchild_interviewbutton ", ""); + componentID = interaction.Data.CustomId.Replace("supportboi_interviewbutton ", ""); break; case DiscordComponentType.ActionRow: case DiscordComponentType.FormInput: @@ -158,12 +158,27 @@ public static class Interviewer // The different mentionable selectors provide the actual answer, while the others just return the ID. if (componentID == "") { + foreach (KeyValuePair<string, ReferencedInterviewStep> reference in currentStep.references) + { + // Skip to the first matching step. + if (Regex.IsMatch(answer, reference.Key)) + { + if (TryGetStepFromReference(interview, reference.Value, out InterviewStep referencedStep)) + { + currentStep.steps.Add(reference.Key, referencedStep); + await HandleAnswer(answer, referencedStep, interview, currentStep, interaction.Channel); + } + currentStep.references.Remove(reference.Key); + return; + } + } + foreach (KeyValuePair<string, InterviewStep> step in currentStep.steps) { // Skip to the first matching step. if (Regex.IsMatch(answer, step.Key)) { - await HandleAnswer(answer, step.Value, interviewRoot, currentStep, interaction.Channel); + await HandleAnswer(answer, step.Value, interview, currentStep, interaction.Channel); return; } } @@ -175,7 +190,7 @@ 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." }).AsEphemeral()); currentStep.AddRelatedMessageIDs(followupMessage.Id); - Database.SaveInterview(interaction.Channel.Id, interviewRoot); + Database.SaveInterview(interview); } else { @@ -192,10 +207,31 @@ public static class Interviewer } (string stepString, InterviewStep nextStep) = currentStep.steps.ElementAt(stepIndex); - await HandleAnswer(stepString, nextStep, interviewRoot, currentStep, interaction.Channel); + await HandleAnswer(stepString, nextStep, interview, currentStep, interaction.Channel); } } + public static bool TryGetStepFromReference(Interview interview, ReferencedInterviewStep reference, out InterviewStep step) + { + foreach (KeyValuePair<string, InterviewStep> definition in interview.definitions) + { + if (reference.id == definition.Key) + { + step = definition.Value; + step.buttonStyle = reference.buttonStyle; + step.selectorDescription = reference.selectorDescription; + if (step.messageType != MessageType.ERROR) + { + step.afterReferenceStep = reference.afterReferenceStep; + } + return true; + } + } + + step = null; + return false; + } + public static async Task ProcessResponseMessage(DiscordMessage answerMessage) { // Either the message or the referenced message is null. @@ -205,12 +241,13 @@ public static class Interviewer } // The channel does not have an active interview. - if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id, out InterviewStep interviewRoot)) + if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id, + out Interview interview)) { return; } - if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep)) + if (!interview.interviewRoot.TryGetCurrentStep(out InterviewStep currentStep)) { return; } @@ -238,7 +275,7 @@ public static class Interviewer Color = DiscordColor.Red }); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); - Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); + Database.SaveInterview(interview); return; } @@ -250,7 +287,7 @@ public static class Interviewer Color = DiscordColor.Red }); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); - Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); + Database.SaveInterview(interview); return; } @@ -262,7 +299,7 @@ public static class Interviewer continue; } - await HandleAnswer(answerMessage.Content, nextStep, interviewRoot, currentStep, answerMessage.Channel, answerMessage); + await HandleAnswer(answerMessage.Content, nextStep, interview, currentStep, answerMessage.Channel, answerMessage); return; } @@ -273,12 +310,12 @@ public static class Interviewer Color = DiscordColor.Red }); currentStep.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id); - Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); + Database.SaveInterview(interview); } private static async Task HandleAnswer(string answer, InterviewStep nextStep, - InterviewStep interviewRoot, + Interview interview, InterviewStep previousStep, DiscordChannel channel, DiscordMessage answerMessage = null) @@ -302,14 +339,37 @@ public static class Interviewer case MessageType.USER_SELECTOR: case MessageType.CHANNEL_SELECTOR: case MessageType.MENTIONABLE_SELECTOR: + foreach ((string stepPattern, ReferencedInterviewStep reference) in nextStep.references) + { + if (!reference.TryGetReferencedStep(interview, out InterviewStep step)) + { + if (answerMessage != null) + { + DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder + { + Description = "Error: The referenced step id '" + reference.id + "' does not exist in the step definitions.", + Color = DiscordColor.Red + }); + nextStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); + previousStep.answer = null; + previousStep.answerID = 0; + Database.SaveInterview(interview); + } + return; + } + + nextStep.steps.Add(stepPattern, step); + } + nextStep.references.Clear(); + await SendNextMessage(channel, nextStep); - Database.SaveInterview(channel.Id, interviewRoot); + Database.SaveInterview(interview); break; case MessageType.END_WITH_SUMMARY: OrderedDictionary summaryFields = new OrderedDictionary(); - interviewRoot.GetSummary(ref summaryFields); + interview.interviewRoot.GetSummary(ref summaryFields); - DiscordEmbedBuilder embed = new DiscordEmbedBuilder() + DiscordEmbedBuilder embed = new DiscordEmbedBuilder { Color = Utilities.StringToColor(nextStep.color), Title = nextStep.heading, @@ -318,14 +378,14 @@ public static class Interviewer foreach (DictionaryEntry entry in summaryFields) { - embed.AddField((string)entry.Key, (string)entry.Value); + embed.AddField((string)entry.Key, (string)entry.Value ?? string.Empty); } await channel.SendMessageAsync(embed); if (Config.deleteMessagesAfterSummary) { - await DeletePreviousMessages(interviewRoot, channel); + await DeletePreviousMessages(interview, channel); } if (!Database.TryDeleteInterview(channel.Id)) @@ -334,7 +394,7 @@ public static class Interviewer } return; case MessageType.END_WITHOUT_SUMMARY: - await channel.SendMessageAsync(new DiscordEmbedBuilder() + await channel.SendMessageAsync(new DiscordEmbedBuilder { Color = Utilities.StringToColor(nextStep.color), Title = nextStep.heading, @@ -343,7 +403,7 @@ public static class Interviewer if (Config.deleteMessagesAfterNoSummary) { - await DeletePreviousMessages(interviewRoot, channel); + await DeletePreviousMessages(interview, channel); } if (!Database.TryDeleteInterview(channel.Id)) @@ -351,40 +411,88 @@ public static class Interviewer Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id); } break; - case MessageType.ERROR: - default: + case MessageType.REFERENCE_END: + // TODO: What is happening with the summaries? + if (interview.interviewRoot.TryGetTakenSteps(out List<InterviewStep> previousSteps)) + { + foreach (InterviewStep step in previousSteps) + { + if (step.afterReferenceStep != null) + { + // If the referenced step is also a reference end, skip it and try to find another. + if (step.afterReferenceStep.messageType == MessageType.REFERENCE_END) + { + step.afterReferenceStep = null; + } + else + { + nextStep = step.afterReferenceStep; + step.afterReferenceStep = null; + + previousStep.steps.Clear(); + previousStep.steps.Add(answer, nextStep); + await HandleAnswer(answer, + nextStep, + interview, + previousStep, + channel, + answerMessage); + return; + } + } + } + } + + DiscordEmbedBuilder error = new DiscordEmbedBuilder + { + Color = DiscordColor.Red, + Description = "An error occured while trying to find the next interview step." + }; + if (answerMessage == null) { - DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder() - { - Color = Utilities.StringToColor(nextStep.color), - Title = nextStep.heading, - Description = nextStep.message - }); + DiscordMessage errorMessage = await channel.SendMessageAsync(error); previousStep.AddRelatedMessageIDs(errorMessage.Id); } else { - DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder() - .AddEmbed(new DiscordEmbedBuilder() - { - Color = Utilities.StringToColor(nextStep.color), - Title = nextStep.heading, - Description = nextStep.message - }).WithReply(answerMessage.Id); - DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder); + DiscordMessage errorMessage = await answerMessage.RespondAsync(error); previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id); } - Database.SaveInterview(channel.Id, interviewRoot); + Database.SaveInterview(interview); + + Logger.Error("Could not find a step to return to after a reference step in channel " + channel.Id); + return; + case MessageType.ERROR: + default: + DiscordEmbedBuilder err = new DiscordEmbedBuilder + { + Color = Utilities.StringToColor(nextStep.color), + Title = nextStep.heading, + Description = nextStep.message + }; + + if (answerMessage == null) + { + DiscordMessage errorMessage = await channel.SendMessageAsync(err); + previousStep.AddRelatedMessageIDs(errorMessage.Id); + } + else + { + DiscordMessage errorMessage = await answerMessage.RespondAsync(err); + previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id); + } + + Database.SaveInterview(interview); break; } } - private static async Task DeletePreviousMessages(InterviewStep interviewRoot, DiscordChannel channel) + private static async Task DeletePreviousMessages(Interview interview, DiscordChannel channel) { List<ulong> previousMessages = []; - interviewRoot.GetMessageIDs(ref previousMessages); + interview.interviewRoot.GetMessageIDs(ref previousMessages); foreach (ulong previousMessageID in previousMessages) { @@ -420,7 +528,7 @@ public static class Interviewer for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < step.steps.Count; nrOfButtons++) { (string stepPattern, InterviewStep nextStep) = step.steps.ToArray()[nrOfButtons]; - buttonRow.Add(new DiscordButtonComponent(nextStep.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, stepPattern)); + buttonRow.Add(new DiscordButtonComponent(nextStep.GetButtonStyle(), "supportboi_interviewbutton " + nrOfButtons, stepPattern)); } msgBuilder.AddComponents(buttonRow); } @@ -438,26 +546,26 @@ public static class Interviewer categoryOptions.Add(new DiscordSelectComponentOption(stepPattern, selectionOptions.ToString(), nextStep.selectorDescription)); } - selectionComponents.Add(new DiscordSelectComponent("supportchild_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(step.selectorPlaceholder) + selectionComponents.Add(new DiscordSelectComponent("supportboi_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(step.selectorPlaceholder) ? "Select an option..." : step.selectorPlaceholder, categoryOptions)); } msgBuilder.AddComponents(selectionComponents); break; case MessageType.ROLE_SELECTOR: - msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportchild_interviewroleselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) + msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportboi_interviewroleselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) ? "Select a role..." : step.selectorPlaceholder)); break; case MessageType.USER_SELECTOR: - msgBuilder.AddComponents(new DiscordUserSelectComponent("supportchild_interviewuserselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) + msgBuilder.AddComponents(new DiscordUserSelectComponent("supportboi_interviewuserselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) ? "Select a user..." : step.selectorPlaceholder)); break; case MessageType.CHANNEL_SELECTOR: - msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportchild_interviewchannelselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) + msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportboi_interviewchannelselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) ? "Select a channel..." : step.selectorPlaceholder)); break; case MessageType.MENTIONABLE_SELECTOR: - msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportchild_interviewmentionableselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) + msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportboi_interviewmentionableselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) ? "Select a user or role..." : step.selectorPlaceholder)); break; case MessageType.TEXT_INPUT: diff --git a/Interviews/interview_template.schema.json b/Interviews/interview_template.schema.json index baad265..343ce82 100644 --- a/Interviews/interview_template.schema.json +++ b/Interviews/interview_template.schema.json @@ -6,7 +6,7 @@ "definitions": { "reference": { "type": "object", - "title": "Interview Step", + "title": "Interview Step Reference", "description": "Contains a reference to a step defined in the 'definitions' property. You can also specify a button-style and selector-description here to override the one from the referenced step.", "properties": { "id": { @@ -31,6 +31,12 @@ "title": "Selector Description", "description": "Description for this option in the parent step's selection box. Requires that the parent step is a 'TEXT_SELECTOR'.", "minLength": 1 + }, + "after-reference-step": { + "type": "object", + "title": "Steps", + "description": "Any REFERENCE_END steps in the reference will continue to this step.", + "$ref": "#/definitions/step" } }, "required": [ @@ -69,7 +75,8 @@ "ROLE_SELECTOR", "MENTIONABLE_SELECTOR", "CHANNEL_SELECTOR", - "TEXT_INPUT" + "TEXT_INPUT", + "REFERENCE_END" ] }, "color": { @@ -186,8 +193,7 @@ }, "required": [ "message", - "message-type", - "color" + "message-type" ], "unevaluatedProperties": false }