SupportChild/Interviews/Interview.cs

306 lines
No EOL
9.9 KiB
C#

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<string, InterviewQuestion> 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<ulong> 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<string,InterviewQuestion> 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<string, InterviewQuestion> path in paths)
{
path.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, InterviewQuestion> 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<string> errors, ref List<string> 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<string,InterviewQuestion> 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<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 Template(ulong categoryID, InterviewQuestion interview)
{
[JsonProperty("category-id", Required = Required.Always)]
public ulong categoryID = categoryID;
[JsonProperty("interview", Required = Required.Always)]
public InterviewQuestion interview = interview;
}