using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Reflection; using DSharpPlus.Entities; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; namespace SupportChild.Interviews; public enum MessageType { // TODO: Support multiselector as separate type ERROR, END_WITH_SUMMARY, END_WITHOUT_SUMMARY, BUTTONS, TEXT_SELECTOR, USER_SELECTOR, ROLE_SELECTOR, MENTIONABLE_SELECTOR, // User or role CHANNEL_SELECTOR, TEXT_INPUT } public enum ButtonType { PRIMARY, SECONDARY, SUCCESS, DANGER } // 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("message-type")] public MessageType messageType; // Colour of the message embed. [JsonProperty("color")] public string color; // 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 on a selector, give it this placeholder. [JsonProperty("selector-placeholder")] public string selectorPlaceholder; // If this step is on 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; // Possible questions to ask next, an error message, or the end of the interview. [JsonProperty("steps")] public Dictionary 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 relatedMessageIDs; public bool TryGetCurrentStep(out InterviewStep step) { // This object has not been initialized, we have checked too deep. if (messageID == 0) { step = null; return false; } // Check children. foreach (KeyValuePair 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)) { return true; } } // This object is the deepest object with a message ID set, meaning it is the latest asked question. step = this; return true; } public void GetSummary(ref OrderedDictionary summary) { if (messageID == 0) { return; } if (!string.IsNullOrWhiteSpace(summaryField)) { summary.Add(summaryField, answer); } // This will always contain exactly one or zero children. foreach (KeyValuePair step in steps) { step.Value.GetSummary(ref summary); } } public void GetMessageIDs(ref List 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 step in steps) { step.Value.GetMessageIDs(ref messageIDs); } } public void AddRelatedMessageIDs(params ulong[] messageIDs) { if (relatedMessageIDs == null) { relatedMessageIDs = messageIDs.ToList(); } else { 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.Secondary }; } public void Validate(ref List errors, ref List warnings, string stepID, int summaryFieldCount = 0, int summaryMaxLength = 0, InterviewStep parent = null) { if (!string.IsNullOrWhiteSpace(summaryField)) { ++summaryFieldCount; summaryMaxLength += summaryField.Length; switch (messageType) { case MessageType.BUTTONS: case MessageType.TEXT_SELECTOR: // Get the longest button/selector text if (steps.Count > 0) { summaryMaxLength += steps.Max(kv => kv.Key.Length); } break; case MessageType.USER_SELECTOR: case MessageType.ROLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR: case MessageType.CHANNEL_SELECTOR: // Approximate length of a mention summaryMaxLength += 23; break; case MessageType.TEXT_INPUT: summaryMaxLength += Math.Min(maxLength ?? 1024, 1024); break; case MessageType.END_WITH_SUMMARY: case MessageType.END_WITHOUT_SUMMARY: case MessageType.ERROR: default: break; } } if (messageType is MessageType.ERROR or MessageType.END_WITH_SUMMARY or MessageType.END_WITHOUT_SUMMARY) { if (steps.Count > 0) { warnings.Add("Steps of the type '" + messageType + "' cannot have child steps.\n\n" + stepID + ".message-type"); } if (!string.IsNullOrWhiteSpace(summaryField)) { warnings.Add("Steps of the type '" + messageType + "' cannot have summary field names.\n\n" + stepID + ".summary-field"); } } else if (steps.Count == 0) { errors.Add("Steps of the type '" + messageType + "' must have at least one child step.\n\n" + stepID + ".message-type"); } if (messageType is MessageType.END_WITH_SUMMARY) { summaryMaxLength += message?.Length ?? 0; summaryMaxLength += heading?.Length ?? 0; if (summaryFieldCount > 25) { errors.Add("A summary cannot contain more than 25 fields, but you have " + summaryFieldCount + " fields in this branch.\n\n" + stepID); } else if (summaryMaxLength >= 6000) { warnings.Add("A summary cannot contain more than 6000 characters, but this branch may reach " + summaryMaxLength + " characters.\n" + "Use the \"max-length\" parameter to limit text input field lengths, or shorten other parts of the summary message.\n\n" + stepID); } } if (parent?.messageType is not MessageType.BUTTONS && buttonStyle != null) { warnings.Add("Button styles have no effect on child steps of a '" + parent?.messageType + "' step.\n\n" + stepID + ".button-style"); } if (parent?.messageType is not MessageType.TEXT_SELECTOR && selectorDescription != null) { warnings.Add("Selector descriptions have no effect on child steps of a '" + parent?.messageType + "' step.\n\n" + stepID + ".selector-description"); } if (messageType is not MessageType.TEXT_SELECTOR && selectorPlaceholder != null) { warnings.Add("Selector placeholders have no effect on steps of the type '" + messageType + "'.\n\n" + stepID + ".selector-placeholder"); } if (messageType is not MessageType.TEXT_INPUT && maxLength != null) { warnings.Add("Max length has no effect on steps of the type '" + messageType + "'.\n\n" + stepID + ".max-length"); } if (messageType is not MessageType.TEXT_INPUT && minLength != null) { warnings.Add("Min length has no effect on steps of the type '" + messageType + "'.\n\n" + stepID + ".min-length"); } foreach (KeyValuePair step in steps) { // The JSON schema error messages use this format for the JSON path, so we use it here too. string nextStepID = stepID; nextStepID += step.Key.ContainsAny('.', ' ', '[', ']', '(', ')', '/', '\\') ? ".steps['" + step.Key + "']" : ".steps." + step.Key; step.Value.Validate(ref errors, ref warnings, nextStepID, summaryFieldCount, summaryMaxLength, this); } } public class StripInternalPropertiesResolver : DefaultContractResolver { private static readonly HashSet 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 Template(ulong categoryID, InterviewStep interview) { [JsonProperty("category-id", Required = Required.Always)] public ulong categoryID = categoryID; [JsonProperty("interview", Required = Required.Always)] public InterviewStep interview = interview; }