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 QuestionType { // TODO: Support multiselector as separate type, with only one subpath supported 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 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 { // Title of the message embed. [JsonProperty("title")] public string title; // Message contents sent to the user. [JsonProperty("message")] public string message; // The type of question. [JsonConverter(typeof(StringEnumConverter))] [JsonProperty("message-type")] public QuestionType type; // Colour of the message embed. [JsonProperty("color")] public string color; // Used as label for this question in the post-interview summary. [JsonProperty("summary-field")] public string summaryField; // If this question is on a button, give it this style. [JsonConverter(typeof(StringEnumConverter))] [JsonProperty("button-style")] public ButtonType? buttonStyle; // If this question is on a selector, give it this placeholder. [JsonProperty("selector-placeholder")] public string selectorPlaceholder; // If this question 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", 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; // //////////////////////////////////////////////////////////////////////////// // 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 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 (messageID == 0) { return; } 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); } if (relatedMessageIDs != null) { messageIDs.AddRange(relatedMessageIDs); } // This will always contain exactly one or zero children. foreach (KeyValuePair path in paths) { path.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, int summaryMaxLength) { if (!string.IsNullOrWhiteSpace(summaryField)) { ++summaryFieldCount; summaryMaxLength += summaryField.Length; switch (type) { case QuestionType.BUTTONS: case QuestionType.TEXT_SELECTOR: // Get the longest button/selector text if (paths.Count > 0) { summaryMaxLength += paths.Max(kv => kv.Key.Length); } break; case QuestionType.USER_SELECTOR: case QuestionType.ROLE_SELECTOR: case QuestionType.MENTIONABLE_SELECTOR: case QuestionType.CHANNEL_SELECTOR: // Approximate length of a mention summaryMaxLength += 23; break; case QuestionType.TEXT_INPUT: summaryMaxLength += Math.Min(maxLength ?? 1024, 1024); break; case QuestionType.END_WITH_SUMMARY: case QuestionType.END_WITHOUT_SUMMARY: case QuestionType.ERROR: default: break; } } if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY) { if (paths.Count > 0) { warnings.Add("'" + type + "' paths cannot have child paths.\n\n" + stepID + ".message-type"); } if (!string.IsNullOrWhiteSpace(summaryField)) { warnings.Add("'" + type + "' paths cannot have summary field names.\n\n" + stepID + ".summary-field"); } } else if (paths.Count == 0) { errors.Add("'" + type + "' paths must have at least one child path.\n\n" + stepID + ".message-type"); } if (type is QuestionType.END_WITH_SUMMARY) { summaryMaxLength += message?.Length ?? 0; summaryMaxLength += title?.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); } } foreach (KeyValuePair path in paths) { // The JSON schema error messages use this format for the JSON path, so we use it here too. string nextStepID = stepID; nextStepID += path.Key.ContainsAny('.', ' ', '[', ']', '(', ')', '/', '\\') ? ".paths['" + path.Key + "']" : ".paths." + path.Key; path.Value.Validate(ref errors, ref warnings, nextStepID, summaryFieldCount, summaryMaxLength); } } 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, InterviewQuestion interview) { [JsonProperty("category-id", Required = Required.Always)] public ulong categoryID = categoryID; [JsonProperty("interview", Required = Required.Always)] public InterviewQuestion interview = interview; }