diff --git a/Interviewer.cs b/Interviewer.cs index b1c0646..875f7a9 100644 --- a/Interviewer.cs +++ b/Interviewer.cs @@ -25,13 +25,26 @@ public static class Interviewer 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 { - // TODO: Optional properties: embed title, button style + // TODO: Other selector types. + + // Title of the message embed. + [JsonProperty("title")] + public string title; // Message contents sent to the user. [JsonProperty("message")] @@ -50,7 +63,18 @@ public static class Interviewer [JsonProperty("summary-field")] public string summaryField; - // Possible questions to ask next, or DONE/FAIL type in order to finish interview. + // If this question is on a button, give it this style. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("button-style")] + public ButtonType buttonStyle; + + [JsonProperty("max-length")] + public int maxLength; + + [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; @@ -147,6 +171,18 @@ public static class Interviewer 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 @@ -154,24 +190,32 @@ public static class Interviewer // I might do this in a more proper way at some point. public class ValidatedInterviewQuestion { - // Message contents sent to the user. + [JsonProperty("title", Required = Required.Default)] + public string title; + [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; - // Used as label for this question in the post-interview summary. - [JsonProperty("summary-field", Required = Required.Always)] + [JsonProperty("summary-field", Required = Required.Default)] public string summaryField; - // Possible questions to ask next, or DONE/FAIL type in order to finish interview. + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("button-style", Required = Required.Default)] + public ButtonType buttonStyle; + + [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; } @@ -250,6 +294,8 @@ public static class Interviewer return; } + await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage); + // Parse the response index from the button/selector. string componentID = ""; @@ -265,38 +311,33 @@ public static class Interviewer throw new ArgumentOutOfRangeException(); } - if (!int.TryParse(componentID, out int pathIndex)) { Logger.Error("Invalid interview button/selector index: " + componentID); - await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage); return; } if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0) { Logger.Error("Invalid interview button/selector index: " + pathIndex); - await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage); return; } - await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage); - (string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex); await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel); } - public static async Task ProcessResponseMessage(DiscordMessage message) + public static async Task ProcessResponseMessage(DiscordMessage answerMessage) { // Either the message or the referenced message is null. - if (message.Channel == null || message.ReferencedMessage?.Channel == null) + if (answerMessage.Channel == null || answerMessage.ReferencedMessage?.Channel == null) { return; } // The channel does not have an active interview. - if (!activeInterviews.TryGetValue(message.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot)) + if (!activeInterviews.TryGetValue(answerMessage.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot)) { return; } @@ -307,7 +348,7 @@ public static class Interviewer } // The user responded to something other than the latest interview question. - if (message.ReferencedMessage.Id != currentQuestion.messageID) + if (answerMessage.ReferencedMessage.Id != currentQuestion.messageID) { return; } @@ -318,21 +359,47 @@ public static class Interviewer return; } + // 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); + + if (answerMessage.Content.Length > maxLength) + { + DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder + { + Description = "Error: Your answer cannot be more than " + maxLength + " characters (" + answerMessage.Content.Length + "/" + maxLength + ").", + Color = DiscordColor.Red + }); + currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); + return; + } + + if (answerMessage.Content.Length < currentQuestion.minLength) + { + DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder + { + Description = "Error: Your answer must be at least " + currentQuestion.minLength + " characters (" + answerMessage.Content.Length + "/" + currentQuestion.minLength + ").", + Color = DiscordColor.Red + }); + currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); + return; + } + foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths) { // Skip to the first matching path. - if (!Regex.IsMatch(message.Content, questionString)) continue; + if (!Regex.IsMatch(answerMessage.Content, questionString)) continue; - await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, message.Channel, message); + await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, answerMessage.Channel, answerMessage); return; } // TODO: Make message configurable. - await message.RespondAsync(new DiscordEmbedBuilder + DiscordMessage errorMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder { Description = "Error: Could not determine the next question based on your answer.", Color = DiscordColor.Red }); + currentQuestion.AddRelatedMessageIDs(errorMessage.Id); } private static async Task HandleAnswer(string questionString, @@ -379,7 +446,7 @@ public static class Interviewer DiscordEmbedBuilder embed = new DiscordEmbedBuilder() { Color = Utilities.StringToColor(nextQuestion.color), - Title = "Summary:", // TODO: Set title + Title = nextQuestion.title, Description = nextQuestion.message, }; @@ -402,6 +469,7 @@ public static class Interviewer await channel.SendMessageAsync(new DiscordEmbedBuilder() { Color = Utilities.StringToColor(nextQuestion.color), + Title = nextQuestion.title, Description = nextQuestion.message }); @@ -419,6 +487,7 @@ public static class Interviewer DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder() { Color = Utilities.StringToColor(nextQuestion.color), + Title = nextQuestion.title, Description = nextQuestion.message }); previousQuestion.AddRelatedMessageIDs(errorMessage.Id); @@ -429,6 +498,7 @@ public static class Interviewer .AddEmbed(new DiscordEmbedBuilder() { Color = Utilities.StringToColor(nextQuestion.color), + Title = nextQuestion.title, Description = nextQuestion.message }).WithReply(answerMessage.Id); DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder); @@ -463,6 +533,7 @@ public static class Interviewer DiscordEmbedBuilder embed = new DiscordEmbedBuilder() { Color = Utilities.StringToColor(question.color), + Title = question.title, Description = question.message }; @@ -475,7 +546,8 @@ public static class Interviewer List buttonRow = []; for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++) { - buttonRow.Add(new DiscordButtonComponent(DiscordButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key)); + (string questionString, InterviewQuestion nextQuestion) = question.paths.ToArray()[nrOfButtons]; + buttonRow.Add(new DiscordButtonComponent(nextQuestion.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, questionString)); } msgBuilder.AddComponents(buttonRow); }