using System; 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; using Newtonsoft.Json.Serialization; namespace SupportChild.Interviews; public enum StepType { // TODO: Support multiselector as separate type ERROR, INTERVIEW_END = 1, [Obsolete("Use INTERVIEW_END instead")] END_WITH_SUMMARY = 1, [Obsolete("Use INTERVIEW_END instead")] END_WITHOUT_SUMMARY = 1, BUTTONS, TEXT_SELECTOR, USER_SELECTOR, ROLE_SELECTOR, MENTIONABLE_SELECTOR, // User or role CHANNEL_SELECTOR, TEXT_INPUT, REFERENCE_END } public enum ButtonType { PRIMARY, SECONDARY, SUCCESS, 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 bool TryGetReferencedStep(Dictionary<string, InterviewStep> definitions, out InterviewStep step, bool ignoreReferenceParameters = false) { if (!definitions.TryGetValue(id, out step)) { Logger.Error("Could not find referenced step '" + id + "' in interview."); return false; } if (!ignoreReferenceParameters) { 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. // The entire interview tree is serialized and stored in the database to record responses as they are made. public class InterviewStep { // Title of the message embed. [JsonProperty("heading")] public string heading; // Message contents sent to the user. [JsonProperty("message")] public string message; // The type of message. [JsonConverter(typeof(StringEnumConverter))] [JsonProperty("step-type")] public StepType stepType; // Colour of the message embed. [JsonProperty("color")] public string color = "CYAN"; // Used as label for this answer in the post-interview summary. [JsonProperty("summary-field")] public string summaryField; // If this step is on a button, give it this style. [JsonConverter(typeof(StringEnumConverter))] [JsonProperty("button-style")] public ButtonType? buttonStyle; // If this step is a selector, give it this placeholder. [JsonProperty("selector-placeholder")] public string selectorPlaceholder; // If this step is in 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")] public int? minLength; // Adds a summary to the message. [JsonProperty("add-summary")] public bool? addSummary; // References to steps defined elsewhere in the template [JsonProperty("step-references")] public Dictionary<string, ReferencedInterviewStep> references = new(); // If set will merge answers with the delimiter, otherwise will overwrite [JsonProperty("answer-delimiter")] public string answerDelimiter; // Possible questions to ask next, an error message, or the end of the interview. [JsonProperty("steps")] public Dictionary<string, InterviewStep> steps = new(); // //////////////////////////////////////////////////////////////////////////// // 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<ulong> relatedMessageIDs; // 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) { previousSteps = null; return false; } // Check children. 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.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. previousSteps = [this]; return true; } public void GetSummary(ref OrderedDictionary summary) { if (messageID == 0) { return; } if (!string.IsNullOrWhiteSpace(summaryField) && !string.IsNullOrWhiteSpace(answer)) { if (answerDelimiter != null && summary.Contains(summaryField)) { if ((summary[summaryField] + answerDelimiter + answer).Length < 1024) { summary[summaryField] += answerDelimiter + answer; } else { Logger.Error("Tried to add answer '" + answer + "' to summary field '" + summaryField + "' but it was too long."); } } else { summary[summaryField] = answer; } } // This will always contain exactly one or zero children. foreach (KeyValuePair<string, InterviewStep> step in steps) { step.Value.GetSummary(ref summary); } } public void GetMessageIDs(ref List<ulong> 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<string, InterviewStep> step in steps) { step.Value.GetMessageIDs(ref messageIDs); } } public void AddRelatedMessageIDs(params ulong[] messageIDs) { if (relatedMessageIDs == null) { relatedMessageIDs = messageIDs.ToList(); } else { relatedMessageIDs.AddRange(messageIDs); } } // Gets all steps in the interview tree, including after-reference-steps but not referenced steps public void GetAllSteps(ref List<InterviewStep> allSteps) { allSteps.Add(this); foreach (KeyValuePair<string, InterviewStep> step in steps) { step.Value.GetAllSteps(ref allSteps); } foreach (KeyValuePair<string, ReferencedInterviewStep> reference in references) { reference.Value.afterReferenceStep?.GetAllSteps(ref allSteps); } } public void Validate(ref List<string> errors, ref List<string> warnings, string stepID, Dictionary<string, InterviewStep> definitions, InterviewStep parent = null) { if (answerDelimiter != null && string.IsNullOrWhiteSpace(summaryField)) { warnings.Add("An answer-delimiter has no effect without a summary-field.\n\n> " + stepID + ".answer-delimiter"); } // TODO: Add url button here when implemented if (stepType is StepType.REFERENCE_END) { if (!string.IsNullOrWhiteSpace(message)) { warnings.Add("The message parameter on '" + stepType + "' steps have no effect.\n\n> " + stepID + ".message"); } } else { if (string.IsNullOrWhiteSpace(message)) { errors.Add("'" + stepType + "' steps must have a message parameter.\n\n> " + stepID + ".message"); } } if (stepType is StepType.ERROR or StepType.INTERVIEW_END or StepType.REFERENCE_END) { if (steps.Count > 0 || references.Count > 0) { warnings.Add("Steps of the type '" + stepType + "' cannot have child steps.\n\n> " + stepID + ".step-type"); } if (!string.IsNullOrWhiteSpace(summaryField)) { warnings.Add("Steps of the type '" + stepType + "' cannot have summary field names.\n\n> " + stepID + ".summary-field"); } } else if (steps.Count == 0 && references.Count == 0) { errors.Add("Steps of the type '" + stepType + "' must have at least one child step.\n\n> " + stepID + ".step-type"); } foreach (KeyValuePair<string, ReferencedInterviewStep> reference in references) { if (!reference.Value.TryGetReferencedStep(definitions, out InterviewStep referencedStep, true)) { errors.Add("'" + reference.Value.id + "' does not exist in the step definitions.\n\n> " + FormatJSONKey(stepID + ".step-references", reference.Key)); } else if (reference.Value.afterReferenceStep == null) { List<InterviewStep> allChildSteps = []; referencedStep.GetAllSteps(ref allChildSteps); if (allChildSteps.Any(s => s.stepType == StepType.REFERENCE_END)) { errors.Add("The '" + FormatJSONKey(stepID + ".step-references", reference.Key) + "' reference needs an after-reference-step as the '" + reference.Value.id + "' definition contains a REFERENCE_END step."); } } } if (parent?.stepType is not StepType.BUTTONS && buttonStyle != null) { warnings.Add("Button styles have no effect on child steps of a '" + parent?.stepType + "' step.\n\n> " + stepID + ".button-style"); } if (parent?.stepType is not StepType.TEXT_SELECTOR && selectorDescription != null) { warnings.Add("Selector descriptions have no effect on child steps of a '" + parent?.stepType + "' step.\n\n> " + stepID + ".selector-description"); } if (stepType is not StepType.TEXT_SELECTOR && selectorPlaceholder != null) { warnings.Add("Selector placeholders have no effect on steps of the type '" + stepType + "'.\n\n> " + stepID + ".selector-placeholder"); } if (stepType is not StepType.TEXT_INPUT && maxLength != null) { warnings.Add("Max length has no effect on steps of the type '" + stepType + "'.\n\n> " + stepID + ".max-length"); } if (stepType is not StepType.TEXT_INPUT && minLength != null) { warnings.Add("Min length has no effect on steps of the type '" + stepType + "'.\n\n> " + stepID + ".min-length"); } foreach (KeyValuePair<string, InterviewStep> step in steps) { step.Value.Validate(ref errors, ref warnings, FormatJSONKey(stepID + ".steps", step.Key), definitions, this); } } private string FormatJSONKey(string parentPath, string key) { // The JSON schema error messages use this format for the JSON path, so we use it in the validation too. return parentPath + (key.ContainsAny('.', ' ', '[', ']', '(', ')', '/', '\\') ? "['" + key + "']" : "." + key); } 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 = [ "message-id", "answer", "answer-id", "related-message-ids" ]; protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { JsonProperty property = base.CreateProperty(member, memberSerialization); if (ignoreProps.Contains(property.PropertyName)) { property.ShouldSerialize = _ => false; } return property; } } } 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; }