Normalised interview terminology for "question", "path", "step" to use "step" everywhere

This commit is contained in:
Toastie 2024-12-27 17:23:03 +13:00
parent 23d6209ffc
commit 8820549927
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
9 changed files with 222 additions and 184 deletions

View file

@ -106,13 +106,13 @@ public class CloseCommand
// If the zip transcript doesn't exist, use the html file. // If the zip transcript doesn't exist, use the html file.
try try
{ {
FileInfo fi = new FileInfo(filePath); FileInfo fileInfo = new FileInfo(filePath);
if (!fi.Exists || fi.Length >= 26214400) if (!fileInfo.Exists || fileInfo.Length >= 26214400)
{ {
fileName = Transcriber.GetHTMLFilename(ticket.id); fileName = Transcriber.GetHTMLFilename(ticket.id);
filePath = Transcriber.GetHtmlPath(ticket.id); filePath = Transcriber.GetHtmlPath(ticket.id);
} }
zipSize = fi.Length; zipSize = fileInfo.Length;
} }
catch (Exception e) catch (Exception e)
{ {

View file

@ -78,7 +78,7 @@ public class InterviewCommands
return; return;
} }
if (!Database.TryGetInterview(command.Channel.Id, out InterviewQuestion interviewRoot)) if (!Database.TryGetInterview(command.Channel.Id, out InterviewStep interviewRoot))
{ {
await command.RespondAsync(new DiscordEmbedBuilder await command.RespondAsync(new DiscordEmbedBuilder
{ {

View file

@ -49,7 +49,7 @@ public class InterviewTemplateCommands
" \"message\": \"\",\n" + " \"message\": \"\",\n" +
" \"message-type\": \"\",\n" + " \"message-type\": \"\",\n" +
" \"color\": \"\",\n" + " \"color\": \"\",\n" +
" \"paths\":\n" + " \"steps\":\n" +
" {\n" + " {\n" +
" \n" + " \n" +
" }\n" + " }\n" +
@ -214,7 +214,7 @@ public class InterviewTemplateCommands
return; return;
} }
if (!Database.TryGetInterviewTemplate(category.Id, out InterviewQuestion _)) if (!Database.TryGetInterviewTemplate(category.Id, out InterviewStep _))
{ {
await command.RespondAsync(new DiscordEmbedBuilder await command.RespondAsync(new DiscordEmbedBuilder
{ {

View file

@ -88,13 +88,13 @@ public class TranscriptCommand
// If the zip transcript doesn't exist, use the html file. // If the zip transcript doesn't exist, use the html file.
try try
{ {
FileInfo fi = new FileInfo(filePath); FileInfo fileInfo = new FileInfo(filePath);
if (!fi.Exists || fi.Length >= 26214400) if (!fileInfo.Exists || fileInfo.Length >= 26214400)
{ {
fileName = Transcriber.GetHTMLFilename(ticket.id); fileName = Transcriber.GetHTMLFilename(ticket.id);
filePath = Transcriber.GetHtmlPath(ticket.id); filePath = Transcriber.GetHtmlPath(ticket.id);
} }
zipSize = fi.Length; zipSize = fileInfo.Length;
} }
catch (Exception e) catch (Exception e)
{ {

View file

@ -7,6 +7,8 @@ using MySqlConnector;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using SupportChild.Interviews; using SupportChild.Interviews;
using SupportChild.Interviews;
using SupportChild;
namespace SupportChild; namespace SupportChild;
@ -759,7 +761,7 @@ public static class Database
return templates; return templates;
} }
public static bool TryGetInterviewTemplate(ulong categoryID, out Interviews.InterviewQuestion template) public static bool TryGetInterviewTemplate(ulong categoryID, out Interviews.InterviewStep template)
{ {
using MySqlConnection c = GetConnection(); using MySqlConnection c = GetConnection();
c.Open(); c.Open();
@ -807,7 +809,7 @@ public static class Database
NullValueHandling = NullValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore,
MissingMemberHandling = MissingMemberHandling.Error, MissingMemberHandling = MissingMemberHandling.Error,
Formatting = Formatting.Indented, Formatting = Formatting.Indented,
ContractResolver = new InterviewQuestion.StripInternalPropertiesResolver() ContractResolver = new InterviewStep.StripInternalPropertiesResolver()
}); });
string query; string query;
@ -851,7 +853,7 @@ public static class Database
} }
} }
public static bool SaveInterview(ulong channelID, Interviews.InterviewQuestion interview) public static bool SaveInterview(ulong channelID, Interviews.InterviewStep interview)
{ {
try try
{ {
@ -879,7 +881,7 @@ public static class Database
} }
} }
public static bool TryGetInterview(ulong channelID, out Interviews.InterviewQuestion interview) public static bool TryGetInterview(ulong channelID, out Interviews.InterviewStep interview)
{ {
using MySqlConnection c = GetConnection(); using MySqlConnection c = GetConnection();
c.Open(); c.Open();
@ -894,7 +896,7 @@ public static class Database
interview = null; interview = null;
return false; return false;
} }
interview = JsonConvert.DeserializeObject<Interviews.InterviewQuestion>(results.GetString("interview")); interview = JsonConvert.DeserializeObject<Interviews.InterviewStep>(results.GetString("interview"));
results.Close(); results.Close();
return true; return true;
} }

View file

@ -10,9 +10,9 @@ using Newtonsoft.Json.Serialization;
namespace SupportChild.Interviews; namespace SupportChild.Interviews;
public enum QuestionType public enum MessageType
{ {
// TODO: Support multiselector as separate type, with only one subpath supported // TODO: Support multiselector as separate type
ERROR, ERROR,
END_WITH_SUMMARY, END_WITH_SUMMARY,
END_WITHOUT_SUMMARY, END_WITHOUT_SUMMARY,
@ -33,43 +33,43 @@ public enum ButtonType
DANGER DANGER
} }
// A tree of questions representing an interview. // 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. // 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. // 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. // The entire interview tree is serialized and stored in the database to record responses as they are made.
public class InterviewQuestion public class InterviewStep
{ {
// Title of the message embed. // Title of the message embed.
[JsonProperty("title")] [JsonProperty("heading")]
public string title; public string heading;
// Message contents sent to the user. // Message contents sent to the user.
[JsonProperty("message")] [JsonProperty("message")]
public string message; public string message;
// The type of question. // The type of message.
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
[JsonProperty("message-type")] [JsonProperty("message-type")]
public QuestionType type; public MessageType messageType;
// Colour of the message embed. // Colour of the message embed.
[JsonProperty("color")] [JsonProperty("color")]
public string color; public string color;
// Used as label for this question in the post-interview summary. // Used as label for this answer in the post-interview summary.
[JsonProperty("summary-field")] [JsonProperty("summary-field")]
public string summaryField; public string summaryField;
// If this question is on a button, give it this style. // If this step is on a button, give it this style.
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
[JsonProperty("button-style")] [JsonProperty("button-style")]
public ButtonType? buttonStyle; public ButtonType? buttonStyle;
// If this question is on a selector, give it this placeholder. // If this step is on a selector, give it this placeholder.
[JsonProperty("selector-placeholder")] [JsonProperty("selector-placeholder")]
public string selectorPlaceholder; public string selectorPlaceholder;
// If this question is on a selector, give it this description. // If this step is on a selector, give it this description.
[JsonProperty("selector-description")] [JsonProperty("selector-description")]
public string selectorDescription; public string selectorDescription;
@ -78,12 +78,12 @@ public class InterviewQuestion
public int? maxLength; public int? maxLength;
// The minimum length of a text input. // The minimum length of a text input.
[JsonProperty("min-length", Required = Required.Default)] [JsonProperty("min-length")]
public int? minLength; public int? minLength;
// Possible questions to ask next, an error message, or the end of the interview. // Possible questions to ask next, an error message, or the end of the interview.
[JsonProperty("paths")] [JsonProperty("steps")]
public Dictionary<string, InterviewQuestion> paths = new(); public Dictionary<string, InterviewStep> steps = new();
// //////////////////////////////////////////////////////////////////////////// // ////////////////////////////////////////////////////////////////////////////
// The following parameters are populated by the bot, not the json template. // // The following parameters are populated by the bot, not the json template. //
@ -105,27 +105,27 @@ public class InterviewQuestion
[JsonProperty("related-message-ids")] [JsonProperty("related-message-ids")]
public List<ulong> relatedMessageIDs; public List<ulong> relatedMessageIDs;
public bool TryGetCurrentQuestion(out InterviewQuestion question) public bool TryGetCurrentStep(out InterviewStep step)
{ {
// This object has not been initialized, we have checked too deep. // This object has not been initialized, we have checked too deep.
if (messageID == 0) if (messageID == 0)
{ {
question = null; step = null;
return false; return false;
} }
// Check children. // Check children.
foreach (KeyValuePair<string,InterviewQuestion> path in paths) foreach (KeyValuePair<string, InterviewStep> childStep in steps)
{ {
// This child either is the one we are looking for or contains the one we are looking for. // This child either is the one we are looking for or contains the one we are looking for.
if (path.Value.TryGetCurrentQuestion(out question)) if (childStep.Value.TryGetCurrentStep(out step))
{ {
return true; return true;
} }
} }
// This object is the deepest object with a message ID set, meaning it is the latest asked question. // This object is the deepest object with a message ID set, meaning it is the latest asked question.
question = this; step = this;
return true; return true;
} }
@ -142,9 +142,9 @@ public class InterviewQuestion
} }
// This will always contain exactly one or zero children. // This will always contain exactly one or zero children.
foreach (KeyValuePair<string, InterviewQuestion> path in paths) foreach (KeyValuePair<string, InterviewStep> step in steps)
{ {
path.Value.GetSummary(ref summary); step.Value.GetSummary(ref summary);
} }
} }
@ -166,9 +166,9 @@ public class InterviewQuestion
} }
// This will always contain exactly one or zero children. // This will always contain exactly one or zero children.
foreach (KeyValuePair<string, InterviewQuestion> path in paths) foreach (KeyValuePair<string, InterviewStep> step in steps)
{ {
path.Value.GetMessageIDs(ref messageIDs); step.Value.GetMessageIDs(ref messageIDs);
} }
} }
@ -188,69 +188,74 @@ public class InterviewQuestion
{ {
return buttonStyle switch return buttonStyle switch
{ {
ButtonType.PRIMARY => DiscordButtonStyle.Primary, ButtonType.PRIMARY => DiscordButtonStyle.Primary,
ButtonType.SECONDARY => DiscordButtonStyle.Secondary, ButtonType.SECONDARY => DiscordButtonStyle.Secondary,
ButtonType.SUCCESS => DiscordButtonStyle.Success, ButtonType.SUCCESS => DiscordButtonStyle.Success,
ButtonType.DANGER => DiscordButtonStyle.Danger, ButtonType.DANGER => DiscordButtonStyle.Danger,
_ => DiscordButtonStyle.Secondary _ => DiscordButtonStyle.Secondary
}; };
} }
public void Validate(ref List<string> errors, ref List<string> warnings, string stepID, int summaryFieldCount, int summaryMaxLength) public void Validate(ref List<string> errors,
ref List<string> warnings,
string stepID,
int summaryFieldCount = 0,
int summaryMaxLength = 0,
InterviewStep parent = null)
{ {
if (!string.IsNullOrWhiteSpace(summaryField)) if (!string.IsNullOrWhiteSpace(summaryField))
{ {
++summaryFieldCount; ++summaryFieldCount;
summaryMaxLength += summaryField.Length; summaryMaxLength += summaryField.Length;
switch (type) switch (messageType)
{ {
case QuestionType.BUTTONS: case MessageType.BUTTONS:
case QuestionType.TEXT_SELECTOR: case MessageType.TEXT_SELECTOR:
// Get the longest button/selector text // Get the longest button/selector text
if (paths.Count > 0) if (steps.Count > 0)
{ {
summaryMaxLength += paths.Max(kv => kv.Key.Length); summaryMaxLength += steps.Max(kv => kv.Key.Length);
} }
break; break;
case QuestionType.USER_SELECTOR: case MessageType.USER_SELECTOR:
case QuestionType.ROLE_SELECTOR: case MessageType.ROLE_SELECTOR:
case QuestionType.MENTIONABLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR:
case QuestionType.CHANNEL_SELECTOR: case MessageType.CHANNEL_SELECTOR:
// Approximate length of a mention // Approximate length of a mention
summaryMaxLength += 23; summaryMaxLength += 23;
break; break;
case QuestionType.TEXT_INPUT: case MessageType.TEXT_INPUT:
summaryMaxLength += Math.Min(maxLength ?? 1024, 1024); summaryMaxLength += Math.Min(maxLength ?? 1024, 1024);
break; break;
case QuestionType.END_WITH_SUMMARY: case MessageType.END_WITH_SUMMARY:
case QuestionType.END_WITHOUT_SUMMARY: case MessageType.END_WITHOUT_SUMMARY:
case QuestionType.ERROR: case MessageType.ERROR:
default: default:
break; break;
} }
} }
if (type is QuestionType.ERROR or QuestionType.END_WITH_SUMMARY or QuestionType.END_WITHOUT_SUMMARY) if (messageType is MessageType.ERROR or MessageType.END_WITH_SUMMARY or MessageType.END_WITHOUT_SUMMARY)
{ {
if (paths.Count > 0) if (steps.Count > 0)
{ {
warnings.Add("'" + type + "' paths cannot have child paths.\n\n" + stepID + ".message-type"); warnings.Add("Steps of the type '" + messageType + "' cannot have child steps.\n\n" + stepID + ".message-type");
} }
if (!string.IsNullOrWhiteSpace(summaryField)) if (!string.IsNullOrWhiteSpace(summaryField))
{ {
warnings.Add("'" + type + "' paths cannot have summary field names.\n\n" + stepID + ".summary-field"); warnings.Add("Steps of the type '" + messageType + "' cannot have summary field names.\n\n" + stepID + ".summary-field");
} }
} }
else if (paths.Count == 0) else if (steps.Count == 0)
{ {
errors.Add("'" + type + "' paths must have at least one child path.\n\n" + stepID + ".message-type"); errors.Add("Steps of the type '" + messageType + "' must have at least one child step.\n\n" + stepID + ".message-type");
} }
if (type is QuestionType.END_WITH_SUMMARY) if (messageType is MessageType.END_WITH_SUMMARY)
{ {
summaryMaxLength += message?.Length ?? 0; summaryMaxLength += message?.Length ?? 0;
summaryMaxLength += title?.Length ?? 0; summaryMaxLength += heading?.Length ?? 0;
if (summaryFieldCount > 25) if (summaryFieldCount > 25)
{ {
errors.Add("A summary cannot contain more than 25 fields, but you have " + summaryFieldCount + " fields in this branch.\n\n" + stepID); errors.Add("A summary cannot contain more than 25 fields, but you have " + summaryFieldCount + " fields in this branch.\n\n" + stepID);
@ -262,15 +267,40 @@ public class InterviewQuestion
} }
} }
foreach (KeyValuePair<string,InterviewQuestion> path in paths) 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<string, InterviewStep> step in steps)
{ {
// The JSON schema error messages use this format for the JSON path, so we use it here too. // The JSON schema error messages use this format for the JSON path, so we use it here too.
string nextStepID = stepID; string nextStepID = stepID;
nextStepID += path.Key.ContainsAny('.', ' ', '[', ']', '(', ')', '/', '\\') nextStepID += step.Key.ContainsAny('.', ' ', '[', ']', '(', ')', '/', '\\')
? ".paths['" + path.Key + "']" ? ".steps['" + step.Key + "']"
: ".paths." + path.Key; : ".steps." + step.Key;
path.Value.Validate(ref errors, ref warnings, nextStepID, summaryFieldCount, summaryMaxLength); step.Value.Validate(ref errors, ref warnings, nextStepID, summaryFieldCount, summaryMaxLength, this);
} }
} }
@ -296,11 +326,11 @@ public class InterviewQuestion
} }
} }
public class Template(ulong categoryID, InterviewQuestion interview) public class Template(ulong categoryID, InterviewStep interview)
{ {
[JsonProperty("category-id", Required = Required.Always)] [JsonProperty("category-id", Required = Required.Always)]
public ulong categoryID = categoryID; public ulong categoryID = categoryID;
[JsonProperty("interview", Required = Required.Always)] [JsonProperty("interview", Required = Required.Always)]
public InterviewQuestion interview = interview; public InterviewStep interview = interview;
} }

View file

@ -14,18 +14,18 @@ public static class Interviewer
{ {
public static async Task<bool> StartInterview(DiscordChannel channel) public static async Task<bool> StartInterview(DiscordChannel channel)
{ {
if (!Database.TryGetInterviewTemplate(channel.Parent.Id, out InterviewQuestion template)) if (!Database.TryGetInterviewTemplate(channel.Parent.Id, out InterviewStep template))
{ {
return false; return false;
} }
await CreateQuestion(channel, template); await SendNextMessage(channel, template);
return Database.SaveInterview(channel.Id, template); return Database.SaveInterview(channel.Id, template);
} }
public static async Task<bool> RestartInterview(DiscordChannel channel) public static async Task<bool> RestartInterview(DiscordChannel channel)
{ {
if (Database.TryGetInterview(channel.Id, out InterviewQuestion interviewRoot)) if (Database.TryGetInterview(channel.Id, out InterviewStep interviewRoot))
{ {
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
{ {
@ -43,7 +43,7 @@ public static class Interviewer
public static async Task<bool> StopInterview(DiscordChannel channel) public static async Task<bool> StopInterview(DiscordChannel channel)
{ {
if (Database.TryGetInterview(channel.Id, out InterviewQuestion interviewRoot)) if (Database.TryGetInterview(channel.Id, out InterviewStep interviewRoot))
{ {
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
{ {
@ -74,7 +74,7 @@ public static class Interviewer
} }
// Return if there is no active interview in this channel // Return if there is no active interview in this channel
if (!Database.TryGetInterview(interaction.Channel.Id, out InterviewQuestion interviewRoot)) if (!Database.TryGetInterview(interaction.Channel.Id, out InterviewStep interviewRoot))
{ {
await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder()
.AddEmbed(new DiscordEmbedBuilder() .AddEmbed(new DiscordEmbedBuilder()
@ -85,19 +85,19 @@ public static class Interviewer
} }
// Return if the current question cannot be found in the interview. // Return if the current question cannot be found in the interview.
if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion)) if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep))
{ {
await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder()
.AddEmbed(new DiscordEmbedBuilder() .AddEmbed(new DiscordEmbedBuilder()
.WithColor(DiscordColor.Red) .WithColor(DiscordColor.Red)
.WithDescription("Error: Something seems to have broken in this interview, you may want to restart it.")) .WithDescription("Error: Something seems to have broken in this interview, you may want to restart it."))
.AsEphemeral()); .AsEphemeral());
Logger.Error("The interview for channel " + interaction.Channel.Id + " exists but does not have a message ID set for it's root question"); Logger.Error("The interview for channel " + interaction.Channel.Id + " exists but does not have a message ID set for it's root interview step");
return; return;
} }
// Check if this button/selector is for an older question. // Check if this button/selector is for an older question.
if (interaction.Message.Id != currentQuestion.messageID) if (interaction.Message.Id != currentStep.messageID)
{ {
await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder()
.AddEmbed(new DiscordEmbedBuilder() .AddEmbed(new DiscordEmbedBuilder()
@ -158,41 +158,41 @@ public static class Interviewer
// The different mentionable selectors provide the actual answer, while the others just return the ID. // The different mentionable selectors provide the actual answer, while the others just return the ID.
if (componentID == "") if (componentID == "")
{ {
foreach (KeyValuePair<string, InterviewQuestion> path in currentQuestion.paths) foreach (KeyValuePair<string, InterviewStep> step in currentStep.steps)
{ {
// Skip to the first matching path. // Skip to the first matching step.
if (Regex.IsMatch(answer, path.Key)) if (Regex.IsMatch(answer, step.Key))
{ {
await HandleAnswer(answer, path.Value, interviewRoot, currentQuestion, interaction.Channel); await HandleAnswer(answer, step.Value, interviewRoot, currentStep, interaction.Channel);
return; return;
} }
} }
Logger.Error("The interview for channel " + interaction.Channel.Id + " reached a question of type " + currentQuestion.type + " which has no valid next question. Their selection was:\n" + answer); Logger.Error("The interview for channel " + interaction.Channel.Id + " reached a step of type " + currentStep.messageType + " which has no valid next step. Their selection was:\n" + answer);
DiscordMessage followupMessage = await interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().AddEmbed(new DiscordEmbedBuilder DiscordMessage followupMessage = await interaction.CreateFollowupMessageAsync(new DiscordFollowupMessageBuilder().AddEmbed(new DiscordEmbedBuilder
{ {
Color = DiscordColor.Red, Color = DiscordColor.Red,
Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect." Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect."
}).AsEphemeral()); }).AsEphemeral());
currentQuestion.AddRelatedMessageIDs(followupMessage.Id); currentStep.AddRelatedMessageIDs(followupMessage.Id);
Database.SaveInterview(interaction.Channel.Id, interviewRoot); Database.SaveInterview(interaction.Channel.Id, interviewRoot);
} }
else else
{ {
if (!int.TryParse(componentID, out int pathIndex)) if (!int.TryParse(componentID, out int stepIndex))
{ {
Logger.Error("Invalid interview button/selector index: " + componentID); Logger.Error("Invalid interview button/selector index: " + componentID);
return; return;
} }
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0) if (stepIndex >= currentStep.steps.Count || stepIndex < 0)
{ {
Logger.Error("Invalid interview button/selector index: " + pathIndex); Logger.Error("Invalid interview button/selector index: " + stepIndex);
return; return;
} }
(string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex); (string stepString, InterviewStep nextStep) = currentStep.steps.ElementAt(stepIndex);
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel); await HandleAnswer(stepString, nextStep, interviewRoot, currentStep, interaction.Channel);
} }
} }
@ -205,30 +205,30 @@ public static class Interviewer
} }
// The channel does not have an active interview. // The channel does not have an active interview.
if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot)) if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id, out InterviewStep interviewRoot))
{ {
return; return;
} }
if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion)) if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep))
{ {
return; return;
} }
// The user responded to something other than the latest interview question. // The user responded to something other than the latest interview question.
if (answerMessage.ReferencedMessage.Id != currentQuestion.messageID) if (answerMessage.ReferencedMessage.Id != currentStep.messageID)
{ {
return; return;
} }
// The user responded to a question which does not take a text response. // The user responded to a question which does not take a text response.
if (currentQuestion.type != QuestionType.TEXT_INPUT) if (currentStep.messageType != MessageType.TEXT_INPUT)
{ {
return; return;
} }
// The length requirement is less than 1024 characters, and must be less than the configurable limit if it is set. // The length requirement is less than 1024 characters, and must be less than the configurable limit if it is set.
int maxLength = Math.Min(currentQuestion.maxLength ?? 1024, 1024); int maxLength = Math.Min(currentStep.maxLength ?? 1024, 1024);
if (answerMessage.Content.Length > maxLength) if (answerMessage.Content.Length > maxLength)
{ {
@ -237,83 +237,83 @@ public static class Interviewer
Description = "Error: Your answer cannot be more than " + maxLength + " characters (" + answerMessage.Content.Length + "/" + maxLength + ").", Description = "Error: Your answer cannot be more than " + maxLength + " characters (" + answerMessage.Content.Length + "/" + maxLength + ").",
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
return; return;
} }
if (answerMessage.Content.Length < (currentQuestion.minLength ?? 0)) if (answerMessage.Content.Length < (currentStep.minLength ?? 0))
{ {
DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
{ {
Description = "Error: Your answer must be at least " + currentQuestion.minLength + " characters (" + answerMessage.Content.Length + "/" + currentQuestion.minLength + ").", Description = "Error: Your answer must be at least " + currentStep.minLength + " characters (" + answerMessage.Content.Length + "/" + currentStep.minLength + ").",
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
return; return;
} }
foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths) foreach ((string stepPattern, InterviewStep nextStep) in currentStep.steps)
{ {
// Skip to the first matching path. // Skip to the first matching step.
if (!Regex.IsMatch(answerMessage.Content, questionString)) if (!Regex.IsMatch(answerMessage.Content, stepPattern))
{ {
continue; continue;
} }
await HandleAnswer(answerMessage.Content, nextQuestion, interviewRoot, currentQuestion, answerMessage.Channel, answerMessage); await HandleAnswer(answerMessage.Content, nextStep, interviewRoot, currentStep, answerMessage.Channel, answerMessage);
return; return;
} }
Logger.Error("The interview for channel " + answerMessage.Channel.Id + " reached a question of type " + currentQuestion.type + " which has no valid next question. Their message was:\n" + answerMessage.Content); Logger.Error("The interview for channel " + answerMessage.Channel.Id + " reached a step of type " + currentStep.messageType + " which has no valid next step. Their message was:\n" + answerMessage.Content);
DiscordMessage errorMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder DiscordMessage errorMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
{ {
Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect.", Description = "Error: Could not determine the next question based on your answer. Check your response and ask an admin to check the bot logs if this seems incorrect.",
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentQuestion.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
} }
private static async Task HandleAnswer(string answer, private static async Task HandleAnswer(string answer,
InterviewQuestion nextQuestion, InterviewStep nextStep,
InterviewQuestion interviewRoot, InterviewStep interviewRoot,
InterviewQuestion previousQuestion, InterviewStep previousStep,
DiscordChannel channel, DiscordChannel channel,
DiscordMessage answerMessage = null) DiscordMessage answerMessage = null)
{ {
// The error message type should not alter anything about the interview. // The error message type should not alter anything about the interview.
if (nextQuestion.type != QuestionType.ERROR) if (nextStep.messageType != MessageType.ERROR)
{ {
previousQuestion.answer = answer; previousStep.answer = answer;
// There is no message ID if the question is not a text input. // There is no message ID if the step is not a text input.
previousQuestion.answerID = answerMessage == null ? 0 : answerMessage.Id; previousStep.answerID = answerMessage == null ? 0 : answerMessage.Id;
} }
// Create next question, or finish the interview. // Create next step, or finish the interview.
switch (nextQuestion.type) switch (nextStep.messageType)
{ {
case QuestionType.TEXT_INPUT: case MessageType.TEXT_INPUT:
case QuestionType.BUTTONS: case MessageType.BUTTONS:
case QuestionType.TEXT_SELECTOR: case MessageType.TEXT_SELECTOR:
case QuestionType.ROLE_SELECTOR: case MessageType.ROLE_SELECTOR:
case QuestionType.USER_SELECTOR: case MessageType.USER_SELECTOR:
case QuestionType.CHANNEL_SELECTOR: case MessageType.CHANNEL_SELECTOR:
case QuestionType.MENTIONABLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR:
await CreateQuestion(channel, nextQuestion); await SendNextMessage(channel, nextStep);
Database.SaveInterview(channel.Id, interviewRoot); Database.SaveInterview(channel.Id, interviewRoot);
break; break;
case QuestionType.END_WITH_SUMMARY: case MessageType.END_WITH_SUMMARY:
OrderedDictionary summaryFields = new OrderedDictionary(); OrderedDictionary summaryFields = new OrderedDictionary();
interviewRoot.GetSummary(ref summaryFields); interviewRoot.GetSummary(ref summaryFields);
DiscordEmbedBuilder embed = new DiscordEmbedBuilder() DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
{ {
Color = Utilities.StringToColor(nextQuestion.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextQuestion.title, Title = nextStep.heading,
Description = nextQuestion.message, Description = nextStep.message,
}; };
foreach (DictionaryEntry entry in summaryFields) foreach (DictionaryEntry entry in summaryFields)
@ -333,12 +333,12 @@ public static class Interviewer
Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id); Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id);
} }
return; return;
case QuestionType.END_WITHOUT_SUMMARY: case MessageType.END_WITHOUT_SUMMARY:
await channel.SendMessageAsync(new DiscordEmbedBuilder() await channel.SendMessageAsync(new DiscordEmbedBuilder()
{ {
Color = Utilities.StringToColor(nextQuestion.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextQuestion.title, Title = nextStep.heading,
Description = nextQuestion.message Description = nextStep.message
}); });
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
@ -351,29 +351,29 @@ public static class Interviewer
Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id); Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id);
} }
break; break;
case QuestionType.ERROR: case MessageType.ERROR:
default: default:
if (answerMessage == null) if (answerMessage == null)
{ {
DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder() DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder()
{ {
Color = Utilities.StringToColor(nextQuestion.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextQuestion.title, Title = nextStep.heading,
Description = nextQuestion.message Description = nextStep.message
}); });
previousQuestion.AddRelatedMessageIDs(errorMessage.Id); previousStep.AddRelatedMessageIDs(errorMessage.Id);
} }
else else
{ {
DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder() DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder()
.AddEmbed(new DiscordEmbedBuilder() .AddEmbed(new DiscordEmbedBuilder()
{ {
Color = Utilities.StringToColor(nextQuestion.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextQuestion.title, Title = nextStep.heading,
Description = nextQuestion.message Description = nextStep.message
}).WithReply(answerMessage.Id); }).WithReply(answerMessage.Id);
DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder); DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
previousQuestion.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id); previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id);
} }
Database.SaveInterview(channel.Id, interviewRoot); Database.SaveInterview(channel.Id, interviewRoot);
@ -381,7 +381,7 @@ public static class Interviewer
} }
} }
private static async Task DeletePreviousMessages(InterviewQuestion interviewRoot, DiscordChannel channel) private static async Task DeletePreviousMessages(InterviewStep interviewRoot, DiscordChannel channel)
{ {
List<ulong> previousMessages = []; List<ulong> previousMessages = [];
interviewRoot.GetMessageIDs(ref previousMessages); interviewRoot.GetMessageIDs(ref previousMessages);
@ -400,78 +400,78 @@ public static class Interviewer
} }
} }
private static async Task CreateQuestion(DiscordChannel channel, InterviewQuestion question) private static async Task SendNextMessage(DiscordChannel channel, InterviewStep step)
{ {
DiscordMessageBuilder msgBuilder = new(); DiscordMessageBuilder msgBuilder = new();
DiscordEmbedBuilder embed = new() DiscordEmbedBuilder embed = new()
{ {
Color = Utilities.StringToColor(question.color), Color = Utilities.StringToColor(step.color),
Title = question.title, Title = step.heading,
Description = question.message Description = step.message
}; };
switch (question.type) switch (step.messageType)
{ {
case QuestionType.BUTTONS: case MessageType.BUTTONS:
int nrOfButtons = 0; int nrOfButtons = 0;
for (int nrOfButtonRows = 0; nrOfButtonRows < 5 && nrOfButtons < question.paths.Count; nrOfButtonRows++) for (int nrOfButtonRows = 0; nrOfButtonRows < 5 && nrOfButtons < step.steps.Count; nrOfButtonRows++)
{ {
List<DiscordButtonComponent> buttonRow = []; List<DiscordButtonComponent> buttonRow = [];
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++) for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < step.steps.Count; nrOfButtons++)
{ {
(string questionString, InterviewQuestion nextQuestion) = question.paths.ToArray()[nrOfButtons]; (string stepPattern, InterviewStep nextStep) = step.steps.ToArray()[nrOfButtons];
buttonRow.Add(new DiscordButtonComponent(nextQuestion.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, questionString)); buttonRow.Add(new DiscordButtonComponent(nextStep.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, stepPattern));
} }
msgBuilder.AddComponents(buttonRow); msgBuilder.AddComponents(buttonRow);
} }
break; break;
case QuestionType.TEXT_SELECTOR: case MessageType.TEXT_SELECTOR:
List<DiscordSelectComponent> selectionComponents = []; List<DiscordSelectComponent> selectionComponents = [];
int selectionOptions = 0; int selectionOptions = 0;
for (int selectionBoxes = 0; selectionBoxes < 5 && selectionOptions < question.paths.Count; selectionBoxes++) for (int selectionBoxes = 0; selectionBoxes < 5 && selectionOptions < step.steps.Count; selectionBoxes++)
{ {
List<DiscordSelectComponentOption> categoryOptions = []; List<DiscordSelectComponentOption> categoryOptions = [];
for (; selectionOptions < 25 * (selectionBoxes + 1) && selectionOptions < question.paths.Count; selectionOptions++) for (; selectionOptions < 25 * (selectionBoxes + 1) && selectionOptions < step.steps.Count; selectionOptions++)
{ {
(string questionString, InterviewQuestion nextQuestion) = question.paths.ToArray()[selectionOptions]; (string stepPattern, InterviewStep nextStep) = step.steps.ToArray()[selectionOptions];
categoryOptions.Add(new DiscordSelectComponentOption(questionString, selectionOptions.ToString(), nextQuestion.selectorDescription)); categoryOptions.Add(new DiscordSelectComponentOption(stepPattern, selectionOptions.ToString(), nextStep.selectorDescription));
} }
selectionComponents.Add(new DiscordSelectComponent("supportchild_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(question.selectorPlaceholder) selectionComponents.Add(new DiscordSelectComponent("supportchild_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select an option..." : question.selectorPlaceholder, categoryOptions)); ? "Select an option..." : step.selectorPlaceholder, categoryOptions));
} }
msgBuilder.AddComponents(selectionComponents); msgBuilder.AddComponents(selectionComponents);
break; break;
case QuestionType.ROLE_SELECTOR: case MessageType.ROLE_SELECTOR:
msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportchild_interviewroleselector", string.IsNullOrWhiteSpace(question.selectorPlaceholder) msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportchild_interviewroleselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a role..." : question.selectorPlaceholder)); ? "Select a role..." : step.selectorPlaceholder));
break; break;
case QuestionType.USER_SELECTOR: case MessageType.USER_SELECTOR:
msgBuilder.AddComponents(new DiscordUserSelectComponent("supportchild_interviewuserselector", string.IsNullOrWhiteSpace(question.selectorPlaceholder) msgBuilder.AddComponents(new DiscordUserSelectComponent("supportchild_interviewuserselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a user..." : question.selectorPlaceholder)); ? "Select a user..." : step.selectorPlaceholder));
break; break;
case QuestionType.CHANNEL_SELECTOR: case MessageType.CHANNEL_SELECTOR:
msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportchild_interviewchannelselector", string.IsNullOrWhiteSpace(question.selectorPlaceholder) msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportchild_interviewchannelselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a channel..." : question.selectorPlaceholder)); ? "Select a channel..." : step.selectorPlaceholder));
break; break;
case QuestionType.MENTIONABLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR:
msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportchild_interviewmentionableselector", string.IsNullOrWhiteSpace(question.selectorPlaceholder) msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportchild_interviewmentionableselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a user or role..." : question.selectorPlaceholder)); ? "Select a user or role..." : step.selectorPlaceholder));
break; break;
case QuestionType.TEXT_INPUT: case MessageType.TEXT_INPUT:
embed.WithFooter("Reply to this message with your answer. You cannot include images or files."); embed.WithFooter("Reply to this message with your answer. You cannot include images or files.");
break; break;
case QuestionType.END_WITH_SUMMARY: case MessageType.END_WITH_SUMMARY:
case QuestionType.END_WITHOUT_SUMMARY: case MessageType.END_WITHOUT_SUMMARY:
case QuestionType.ERROR: case MessageType.ERROR:
default: default:
break; break;
} }
msgBuilder.AddEmbed(embed); msgBuilder.AddEmbed(embed);
DiscordMessage message = await channel.SendMessageAsync(msgBuilder); DiscordMessage message = await channel.SendMessageAsync(msgBuilder);
question.messageID = message.Id; step.messageID = message.Id;
} }
} }

View file

@ -88,10 +88,10 @@
], ],
"minLength": 1 "minLength": 1
}, },
"paths": { "steps": {
"type": "object", "type": "object",
"title": "Paths", "title": "Steps",
"description": "The possible next steps, the name of the path is matched against the user input, use \".*\" to match anything.", "description": "The possible next steps, the name of the step is matched against the user input using regex, use \".*\" to match anything.",
"patternProperties": { "patternProperties": {
".*": { ".*": {
"$ref": "#/definitions/step" "$ref": "#/definitions/step"
@ -121,6 +121,12 @@
"description": "The description of the selector.", "description": "The description of the selector.",
"minLength": 1 "minLength": 1
}, },
"selector-placeholder": {
"type": "string",
"title": "Selector Placeholder",
"description": "The placeholder shown before an option is selected.",
"minLength": 1
},
"max-length": { "max-length": {
"type": "number", "type": "number",
"title": "Max Length", "title": "Max Length",

View file

@ -87,7 +87,7 @@ Here is a simple example of an interview asking a user for their favourite colou
"message-type": "BUTTONS", "message-type": "BUTTONS",
"color": "BLUE", "color": "BLUE",
"summary-field": "Favourite colour", "summary-field": "Favourite colour",
"paths": "steps":
{ {
"Blue": "Blue":
{ {
@ -95,7 +95,7 @@ Here is a simple example of an interview asking a user for their favourite colou
"message-type": "END_WITH_SUMMARY", "message-type": "END_WITH_SUMMARY",
"color": "BLUE", "color": "BLUE",
"button-style": "PRIMARY", "button-style": "PRIMARY",
"paths": {} "steps": {}
}, },
"Gray": "Gray":
{ {
@ -103,7 +103,7 @@ Here is a simple example of an interview asking a user for their favourite colou
"message-type": "END_WITH_SUMMARY", "message-type": "END_WITH_SUMMARY",
"color": "GRAY", "color": "GRAY",
"button-style": "SECONDARY", "button-style": "SECONDARY",
"paths": {} "steps": {}
}, },
"Green": "Green":
{ {
@ -111,7 +111,7 @@ Here is a simple example of an interview asking a user for their favourite colou
"message-type": "END_WITH_SUMMARY", "message-type": "END_WITH_SUMMARY",
"color": "GREEN", "color": "GREEN",
"button-style": "SUCCESS", "button-style": "SUCCESS",
"paths": {} "steps": {}
}, },
"Red": "Red":
{ {
@ -119,7 +119,7 @@ Here is a simple example of an interview asking a user for their favourite colou
"message-type": "END_WITH_SUMMARY", "message-type": "END_WITH_SUMMARY",
"color": "RED", "color": "RED",
"button-style": "DANGER", "button-style": "DANGER",
"paths": {} "steps": {}
} }
} }
} }
@ -131,7 +131,7 @@ Here is a simple example of an interview asking a user for their favourite colou
| Property&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; | Required | Description | | Property&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; | Required | Description |
|----------------------------------------------------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------| |----------------------------------------------------------------------------|----------|--------------------------------------------------------------------------------------------------------------------------------------|
| `category-id` | Yes | The id of the category this template applies to. You can change this and re-upload the template to apply it to a different category. | | `category-id` | Yes | The id of the category this template applies to. You can change this and re-upload the template to apply it to a different category. |
| `interview` | Yes | Contains the interview conversation tree, starting with one path which branches into many. | | `interview` | Yes | Contains the interview conversation tree, starting with one interview step which branches into many. |
### Interview Paths ### Interview Paths
@ -183,7 +183,7 @@ Colour of the message embed. You can either enter a colour name or a hexadecimal
</tr> </tr>
<tr> <tr>
<td> <td>
`paths` `steps`
</td> </td>
<td>No</td> <td>No</td>
<td>Steps</td> <td>Steps</td>