Add JSON schema for interview templates, switched so using it internally for validation too
This commit is contained in:
parent
a254bc5697
commit
76462b97fb
6 changed files with 288 additions and 178 deletions
|
@ -11,6 +11,7 @@ using DSharpPlus.Commands.Processors.SlashCommands;
|
|||
using DSharpPlus.Entities;
|
||||
using DSharpPlus.Exceptions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Schema;
|
||||
using SupportChild.Interviews;
|
||||
|
||||
namespace SupportChild.Commands;
|
||||
|
@ -19,6 +20,8 @@ namespace SupportChild.Commands;
|
|||
[Description("Administrative commands.")]
|
||||
public class InterviewTemplateCommands
|
||||
{
|
||||
private static string jsonSchema = Utilities.ReadManifestData("Interviews.interview_template.schema.json");
|
||||
|
||||
[RequireGuild]
|
||||
[Command("get")]
|
||||
[Description("Provides a copy of the interview template for a category which you can edit and then reupload.")]
|
||||
|
@ -96,35 +99,20 @@ public class InterviewTemplateCommands
|
|||
return;
|
||||
}
|
||||
|
||||
Stream stream = await new HttpClient().GetStreamAsync(file.Url);
|
||||
string json = await new StreamReader(stream).ReadToEndAsync();
|
||||
|
||||
try
|
||||
{
|
||||
List<string> errors = [];
|
||||
|
||||
// Convert it to an interview object to validate the template
|
||||
Interviews.ValidatedTemplate template = JsonConvert.DeserializeObject<Interviews.ValidatedTemplate>(json, new JsonSerializerSettings()
|
||||
{
|
||||
//NullValueHandling = NullValueHandling.Include,
|
||||
MissingMemberHandling = MissingMemberHandling.Error,
|
||||
Error = delegate (object sender, Newtonsoft.Json.Serialization.ErrorEventArgs args)
|
||||
{
|
||||
// I noticed the main exception mainly has information for developers, not administrators,
|
||||
// so I switched to using the inner message if available.
|
||||
if (string.IsNullOrEmpty(args.ErrorContext.Error.InnerException?.Message))
|
||||
{
|
||||
errors.Add(args.ErrorContext.Error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add(args.ErrorContext.Error.InnerException.Message);
|
||||
}
|
||||
Stream stream = await new HttpClient().GetStreamAsync(file.Url);
|
||||
JSchemaValidatingReader validatingReader = new(new JsonTextReader(new StreamReader(stream)));
|
||||
validatingReader.Schema = JSchema.Parse(jsonSchema);
|
||||
|
||||
Logger.Debug("Exception occured when trying to upload interview template:\n" + args.ErrorContext.Error);
|
||||
args.ErrorContext.Handled = false;
|
||||
}
|
||||
});
|
||||
// The schema seems to throw an additional error with incorrect information if an invalid parameter is included
|
||||
// in the template. Throw here in order to only show the first correct error to the user, also skips unnecessary validation further down.
|
||||
validatingReader.ValidationEventHandler += (o, a) => throw new JsonException(a.Message);
|
||||
|
||||
JsonSerializer serializer = new();
|
||||
Template template = serializer.Deserialize<Template>(validatingReader);
|
||||
|
||||
DiscordChannel category = await SupportChild.client.GetChannelAsync(template.categoryID);
|
||||
if (!category.IsCategory)
|
||||
|
@ -170,11 +158,7 @@ public class InterviewTemplateCommands
|
|||
await command.RespondAsync(new DiscordEmbedBuilder
|
||||
{
|
||||
Color = DiscordColor.Red,
|
||||
Description = "The uploaded JSON structure could not be converted to an interview template.\n\nErrors:\n```\n" + errorString + "\n```",
|
||||
Footer = new DiscordEmbedBuilder.EmbedFooter()
|
||||
{
|
||||
Text = "More detailed information may be available as debug messages in the bot logs."
|
||||
}
|
||||
Description = "The uploaded JSON structure could not be parsed as an interview template.\n\nErrors:\n```\n" + errorString + "\n```"
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
@ -208,11 +192,15 @@ public class InterviewTemplateCommands
|
|||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Debug("Exception occured when trying to upload interview template:\n", e);
|
||||
await command.RespondAsync(new DiscordEmbedBuilder
|
||||
{
|
||||
|
||||
Color = DiscordColor.Red,
|
||||
Description = "The uploaded JSON structure could not be converted to an interview template.\n\nError message:\n```\n" + e.Message + "\n```"
|
||||
Description = "The uploaded JSON structure could not be parsed as an interview template.\n\nError message:\n```\n" + e.Message + "\n```",
|
||||
Footer = new DiscordEmbedBuilder.EmbedFooter
|
||||
{
|
||||
Text = "More detailed information may be available as debug messages in the bot logs."
|
||||
}
|
||||
}, true);
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ using DSharpPlus;
|
|||
using MySqlConnector;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using SupportChild.Interviews;
|
||||
|
||||
namespace SupportChild;
|
||||
|
||||
|
@ -797,7 +798,7 @@ public static class Database
|
|||
}
|
||||
}
|
||||
|
||||
public static bool SetInterviewTemplate(Interviews.ValidatedTemplate template)
|
||||
public static bool SetInterviewTemplate(Interviews.Template template)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -805,7 +806,8 @@ public static class Database
|
|||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
MissingMemberHandling = MissingMemberHandling.Error,
|
||||
Formatting = Formatting.Indented
|
||||
Formatting = Formatting.Indented,
|
||||
ContractResolver = new InterviewQuestion.StripInternalPropertiesResolver()
|
||||
});
|
||||
|
||||
string query;
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
using System.Collections.Generic;
|
||||
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
|
||||
{
|
||||
// Support multiselector as separate type, with only one subpath supported
|
||||
// TODO: Support multiselector as separate type, with only one subpath supported
|
||||
ERROR,
|
||||
END_WITH_SUMMARY,
|
||||
END_WITHOUT_SUMMARY,
|
||||
|
@ -46,7 +49,7 @@ public class InterviewQuestion
|
|||
|
||||
// The type of question.
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
[JsonProperty("type")]
|
||||
[JsonProperty("message-type")]
|
||||
public QuestionType type;
|
||||
|
||||
// Colour of the message embed.
|
||||
|
@ -192,6 +195,106 @@ public class InterviewQuestion
|
|||
_ => DiscordButtonStyle.Secondary
|
||||
};
|
||||
}
|
||||
|
||||
public void Validate(ref List<string> errors, out int summaryCount, out int summaryMaxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
errors.Add("Message cannot be empty.");
|
||||
}
|
||||
|
||||
if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY)
|
||||
{
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
errors.Add("'" + type + "' questions cannot have child paths.");
|
||||
}
|
||||
}
|
||||
else if (paths.Count == 0)
|
||||
{
|
||||
errors.Add("'" + type + "' questions must have at least one child path.");
|
||||
}
|
||||
|
||||
List<int> summaryCounts = [];
|
||||
Dictionary<string, int> childMaxLengths = new Dictionary<string, int>();
|
||||
foreach (KeyValuePair<string, InterviewQuestion> path in paths)
|
||||
{
|
||||
path.Value.Validate(ref errors, out int summaries, out int maxLen);
|
||||
summaryCounts.Add(summaries);
|
||||
childMaxLengths.Add(path.Key, maxLen);
|
||||
}
|
||||
|
||||
summaryCount = summaryCounts.Count == 0 ? 0 : summaryCounts.Max();
|
||||
|
||||
string childPathString = "";
|
||||
int childMaxLength = 0;
|
||||
if (childMaxLengths.Count != 0)
|
||||
{
|
||||
(childPathString, childMaxLength) = childMaxLengths.ToArray().MaxBy(x => x.Key.Length + x.Value);
|
||||
}
|
||||
|
||||
summaryMaxLength = childMaxLength;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(summaryField))
|
||||
{
|
||||
++summaryCount;
|
||||
}
|
||||
|
||||
// Only count summaries that end in a summary question.
|
||||
if (type == QuestionType.END_WITH_SUMMARY)
|
||||
{
|
||||
summaryMaxLength = message?.Length ?? 0;
|
||||
summaryMaxLength += title?.Length ?? 0;
|
||||
}
|
||||
// Only add to the total max length if the summary field is not empty. That way we know this branch ends in a summary.
|
||||
else if (summaryMaxLength > 0 && !string.IsNullOrEmpty(summaryField))
|
||||
{
|
||||
summaryMaxLength += summaryField.Length;
|
||||
switch (type)
|
||||
{
|
||||
case QuestionType.BUTTONS:
|
||||
case QuestionType.TEXT_SELECTOR:
|
||||
summaryMaxLength += childPathString.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
@ -1,137 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using SupportChild.Interviews;
|
||||
|
||||
namespace SupportChild.Interviews;
|
||||
|
||||
// This class is identical to the normal interview question and just exists as a hack to get JSON validation when
|
||||
// new entries are entered but not when read from database in order to be more lenient with old interviews.
|
||||
// I might do this in a more proper way at some point.
|
||||
public class ValidatedInterviewQuestion
|
||||
{
|
||||
[JsonProperty("title", Required = Required.Default)]
|
||||
public string title;
|
||||
|
||||
[JsonProperty("message", Required = Required.Always)]
|
||||
public string message;
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
[JsonProperty("type", Required = Required.Always)]
|
||||
public QuestionType type;
|
||||
|
||||
[JsonProperty("color", Required = Required.Always)]
|
||||
public string color;
|
||||
|
||||
[JsonProperty("summary-field", Required = Required.Default)]
|
||||
public string summaryField;
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
[JsonProperty("button-style", Required = Required.Default)]
|
||||
public ButtonType? buttonStyle;
|
||||
|
||||
[JsonProperty("selector-placeholder", Required = Required.Default)]
|
||||
public string selectorPlaceholder;
|
||||
|
||||
[JsonProperty("selector-description", Required = Required.Default)]
|
||||
public string selectorDescription;
|
||||
|
||||
[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<string, ValidatedInterviewQuestion> paths;
|
||||
|
||||
public void Validate(ref List<string> errors, out int summaryCount, out int summaryMaxLength)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
errors.Add("Message cannot be empty.");
|
||||
}
|
||||
|
||||
if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY)
|
||||
{
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
errors.Add("'" + type + "' questions cannot have child paths.");
|
||||
}
|
||||
}
|
||||
else if (paths.Count == 0)
|
||||
{
|
||||
errors.Add("'" + type + "' questions must have at least one child path.");
|
||||
}
|
||||
|
||||
List<int> summaryCounts = [];
|
||||
Dictionary<string, int> childMaxLengths = new Dictionary<string, int>();
|
||||
foreach (KeyValuePair<string, ValidatedInterviewQuestion> path in paths)
|
||||
{
|
||||
path.Value.Validate(ref errors, out int summaries, out int maxLen);
|
||||
summaryCounts.Add(summaries);
|
||||
childMaxLengths.Add(path.Key, maxLen);
|
||||
}
|
||||
|
||||
summaryCount = summaryCounts.Count == 0 ? 0 : summaryCounts.Max();
|
||||
|
||||
string childPathString = "";
|
||||
int childMaxLength = 0;
|
||||
if (childMaxLengths.Count != 0)
|
||||
{
|
||||
(childPathString, childMaxLength) = childMaxLengths.ToArray().MaxBy(x => x.Key.Length + x.Value);
|
||||
}
|
||||
|
||||
summaryMaxLength = childMaxLength;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(summaryField))
|
||||
{
|
||||
++summaryCount;
|
||||
}
|
||||
|
||||
// Only count summaries that end in a summary question.
|
||||
if (type == QuestionType.END_WITH_SUMMARY)
|
||||
{
|
||||
summaryMaxLength = message?.Length ?? 0;
|
||||
summaryMaxLength += title?.Length ?? 0;
|
||||
}
|
||||
// Only add to the total max length if the summary field is not empty. That way we know this branch ends in a summary.
|
||||
else if (summaryMaxLength > 0 && !string.IsNullOrEmpty(summaryField))
|
||||
{
|
||||
summaryMaxLength += summaryField.Length;
|
||||
switch (type)
|
||||
{
|
||||
case QuestionType.BUTTONS:
|
||||
case QuestionType.TEXT_SELECTOR:
|
||||
summaryMaxLength += childPathString.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ValidatedTemplate(ulong categoryID, ValidatedInterviewQuestion interview)
|
||||
{
|
||||
[JsonProperty("category-id", Required = Required.Always)]
|
||||
public ulong categoryID = categoryID;
|
||||
|
||||
[JsonProperty("interview", Required = Required.Always)]
|
||||
public ValidatedInterviewQuestion interview = interview;
|
||||
}
|
152
Interviews/interview_template.schema.json
Normal file
152
Interviews/interview_template.schema.json
Normal file
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"id": "https://toastielab.dev/Emotions-stuff/SupportChild/src/branch/main/Interviews/interview_template.schema.json",
|
||||
"title": "Interview Template",
|
||||
"description": "An interview dialog tree template for SupportChild",
|
||||
"definitions": {
|
||||
"step": {
|
||||
"type": "object",
|
||||
"title": "Interview",
|
||||
"description": "Contains the interview dialog tree.",
|
||||
"properties": {
|
||||
"heading": {
|
||||
"type": "string",
|
||||
"title": "Heading",
|
||||
"description": "The title of the message.",
|
||||
"minLength": 1
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"title": "Message",
|
||||
"description": "The message text.",
|
||||
"minLength": 1
|
||||
},
|
||||
"message-type": {
|
||||
"type": "string",
|
||||
"title": "Message Type",
|
||||
"description": "The type of message, decides what the bot will do when the user gets to this step.",
|
||||
"enum": [
|
||||
"ERROR",
|
||||
"END_WITH_SUMMARY",
|
||||
"END_WITHOUT_SUMMARY",
|
||||
"BUTTONS",
|
||||
"TEXT_SELECTOR",
|
||||
"USER_SELECTOR",
|
||||
"ROLE_SELECTOR",
|
||||
"MENTIONABLE_SELECTOR",
|
||||
"CHANNEL_SELECTOR",
|
||||
"TEXT_INPUT"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"title": "Color",
|
||||
"description": "Colour of the embed message.",
|
||||
"examples": [
|
||||
"BLACK",
|
||||
"WHITE",
|
||||
"GRAY",
|
||||
"DARKGRAY",
|
||||
"LIGHTGRAY",
|
||||
"VERYDARKGRAY",
|
||||
"BLURPLE",
|
||||
"GRAYPLE",
|
||||
"DARKBUTNOTBLACK",
|
||||
"NOTQUITEBLACK",
|
||||
"RED",
|
||||
"DARKRED",
|
||||
"GREEN",
|
||||
"DARKGREEN",
|
||||
"BLUE",
|
||||
"DARKBLUE",
|
||||
"YELLOW",
|
||||
"CYAN",
|
||||
"MAGENTA",
|
||||
"TEAL",
|
||||
"AQUAMARINE",
|
||||
"GOLD",
|
||||
"GOLDENROD",
|
||||
"AZURE",
|
||||
"ROSE",
|
||||
"SPRINGGREEN",
|
||||
"CHARTREUSE",
|
||||
"ORANGE",
|
||||
"PURPLE",
|
||||
"VIOLET",
|
||||
"BROWN",
|
||||
"HOTPINK",
|
||||
"LILAC",
|
||||
"CORNFLOWERBLUE",
|
||||
"MIDNIGHTBLUE",
|
||||
"WHEAT",
|
||||
"INDIANRED",
|
||||
"TURQUOISE",
|
||||
"SAPGREEN",
|
||||
"PHTHALOBLUE",
|
||||
"PHTHALOGREEN",
|
||||
"SIENNA"
|
||||
],
|
||||
"minLength": 1
|
||||
},
|
||||
"paths": {
|
||||
"type": "object",
|
||||
"title": "Paths",
|
||||
"description": "The possible next steps, the name of the path is matched against the user input, use \".*\" to match anything.",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/step"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary-field": {
|
||||
"type": "string",
|
||||
"title": "Summary Field",
|
||||
"description": "If this interview ends with a summary this will be the name of the answer the user used in this field.",
|
||||
"minLength": 1
|
||||
},
|
||||
"button-style": {
|
||||
"type": "string",
|
||||
"title": "Button Style",
|
||||
"description": "The style of the button that leads to this step, the step before this one must be a button step for this to work.",
|
||||
"enum": [
|
||||
"PRIMARY",
|
||||
"SECONDARY",
|
||||
"SUCCESS",
|
||||
"DANGER"
|
||||
]
|
||||
},
|
||||
"selector-description": {
|
||||
"type": "string",
|
||||
"title": "Selector Description",
|
||||
"description": "The description of the selector.",
|
||||
"minLength": 1
|
||||
},
|
||||
"max-length": {
|
||||
"type": "number",
|
||||
"title": "Max Length",
|
||||
"description": "The maximum length of the text input."
|
||||
},
|
||||
"min-length": {
|
||||
"type": "number",
|
||||
"title": "Min Length",
|
||||
"description": "The minimum length of the text input."
|
||||
}
|
||||
},
|
||||
"required": [ "message", "message-type", "color", "paths" ],
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category-id": {
|
||||
"type": "string",
|
||||
"title": "Category ID",
|
||||
"description": "The category the template applies to. Change this to apply the template to a different category."
|
||||
},
|
||||
"interview": {
|
||||
"$ref": "#/definitions/step"
|
||||
}
|
||||
},
|
||||
"required": [ "category-id", "interview" ],
|
||||
"unevaluatedProperties": false
|
||||
}
|
|
@ -42,6 +42,7 @@
|
|||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.3.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageReference Include="Polly" Version="8.4.2" />
|
||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
|
@ -50,14 +51,15 @@
|
|||
<PackageReference Include="YoutubeExplode" Version="6.4.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="lib\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="DiscordChatExporter.Core">
|
||||
<HintPath>lib\DiscordChatExporter.Core.dll</HintPath>
|
||||
<HintPath>lib/DiscordChatExporter.Core.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="default_config.yml" />
|
||||
<EmbeddedResource Include="Interviews/interview_template.schema.json" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
Loading…
Reference in a new issue