Add JSON schema for interview templates, switched so using it internally for validation too

This commit is contained in:
Toastie 2024-12-27 02:47:04 +13:00
parent a254bc5697
commit 76462b97fb
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
6 changed files with 288 additions and 178 deletions

View file

@ -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;
}

View file

@ -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;

View file

@ -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)

View file

@ -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;
}

View 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
}

View file

@ -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" />
@ -49,15 +50,16 @@
<PackageReference Include="YamlDotNet" Version="16.1.3" />
<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>