Added optional properties to interview questions
This commit is contained in:
parent
a8d7440a7a
commit
1ced350789
1 changed files with 94 additions and 22 deletions
116
Interviewer.cs
116
Interviewer.cs
|
@ -25,13 +25,26 @@ public static class Interviewer
|
||||||
TEXT_INPUT
|
TEXT_INPUT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ButtonType
|
||||||
|
{
|
||||||
|
// Secondary first to make it the default
|
||||||
|
SECONDARY,
|
||||||
|
PRIMARY,
|
||||||
|
SUCCESS,
|
||||||
|
DANGER
|
||||||
|
}
|
||||||
|
|
||||||
// A tree of questions representing an interview.
|
// 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.
|
// 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 in order to record responses as they are made.
|
// The entire interview tree is serialized and stored in the database in order to record responses as they are made.
|
||||||
public class InterviewQuestion
|
public class InterviewQuestion
|
||||||
{
|
{
|
||||||
// TODO: Optional properties: embed title, button style
|
// TODO: Other selector types.
|
||||||
|
|
||||||
|
// Title of the message embed.
|
||||||
|
[JsonProperty("title")]
|
||||||
|
public string title;
|
||||||
|
|
||||||
// Message contents sent to the user.
|
// Message contents sent to the user.
|
||||||
[JsonProperty("message")]
|
[JsonProperty("message")]
|
||||||
|
@ -50,7 +63,18 @@ public static class Interviewer
|
||||||
[JsonProperty("summary-field")]
|
[JsonProperty("summary-field")]
|
||||||
public string summaryField;
|
public string summaryField;
|
||||||
|
|
||||||
// Possible questions to ask next, or DONE/FAIL type in order to finish interview.
|
// If this question is on a button, give it this style.
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
[JsonProperty("button-style")]
|
||||||
|
public ButtonType buttonStyle;
|
||||||
|
|
||||||
|
[JsonProperty("max-length")]
|
||||||
|
public int maxLength;
|
||||||
|
|
||||||
|
[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")]
|
[JsonProperty("paths")]
|
||||||
public Dictionary<string, InterviewQuestion> paths;
|
public Dictionary<string, InterviewQuestion> paths;
|
||||||
|
|
||||||
|
@ -147,6 +171,18 @@ public static class Interviewer
|
||||||
relatedMessageIDs.AddRange(messageIDs);
|
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.Primary
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This class is identical to the one above and just exists as a hack to get JSON validation when
|
// This class is identical to the one above and just exists as a hack to get JSON validation when
|
||||||
|
@ -154,24 +190,32 @@ public static class Interviewer
|
||||||
// I might do this in a more proper way at some point.
|
// I might do this in a more proper way at some point.
|
||||||
public class ValidatedInterviewQuestion
|
public class ValidatedInterviewQuestion
|
||||||
{
|
{
|
||||||
// Message contents sent to the user.
|
[JsonProperty("title", Required = Required.Default)]
|
||||||
|
public string title;
|
||||||
|
|
||||||
[JsonProperty("message", Required = Required.Always)]
|
[JsonProperty("message", Required = Required.Always)]
|
||||||
public string message;
|
public string message;
|
||||||
|
|
||||||
// The type of question.
|
|
||||||
[JsonConverter(typeof(StringEnumConverter))]
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
[JsonProperty("type", Required = Required.Always)]
|
[JsonProperty("type", Required = Required.Always)]
|
||||||
public QuestionType type;
|
public QuestionType type;
|
||||||
|
|
||||||
// Colour of the message embed.
|
|
||||||
[JsonProperty("color", Required = Required.Always)]
|
[JsonProperty("color", Required = Required.Always)]
|
||||||
public string color;
|
public string color;
|
||||||
|
|
||||||
// Used as label for this question in the post-interview summary.
|
[JsonProperty("summary-field", Required = Required.Default)]
|
||||||
[JsonProperty("summary-field", Required = Required.Always)]
|
|
||||||
public string summaryField;
|
public string summaryField;
|
||||||
|
|
||||||
// Possible questions to ask next, or DONE/FAIL type in order to finish interview.
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
[JsonProperty("button-style", Required = Required.Default)]
|
||||||
|
public ButtonType buttonStyle;
|
||||||
|
|
||||||
|
[JsonProperty("max-length", Required = Required.Default)]
|
||||||
|
public int maxLength;
|
||||||
|
|
||||||
|
[JsonProperty("min-length", Required = Required.Default)]
|
||||||
|
public int minLength;
|
||||||
|
|
||||||
[JsonProperty("paths", Required = Required.Always)]
|
[JsonProperty("paths", Required = Required.Always)]
|
||||||
public Dictionary<string, ValidatedInterviewQuestion> paths;
|
public Dictionary<string, ValidatedInterviewQuestion> paths;
|
||||||
}
|
}
|
||||||
|
@ -250,6 +294,8 @@ public static class Interviewer
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage);
|
||||||
|
|
||||||
// Parse the response index from the button/selector.
|
// Parse the response index from the button/selector.
|
||||||
string componentID = "";
|
string componentID = "";
|
||||||
|
|
||||||
|
@ -265,38 +311,33 @@ public static class Interviewer
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!int.TryParse(componentID, out int pathIndex))
|
if (!int.TryParse(componentID, out int pathIndex))
|
||||||
{
|
{
|
||||||
Logger.Error("Invalid interview button/selector index: " + componentID);
|
Logger.Error("Invalid interview button/selector index: " + componentID);
|
||||||
await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0)
|
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0)
|
||||||
{
|
{
|
||||||
Logger.Error("Invalid interview button/selector index: " + pathIndex);
|
Logger.Error("Invalid interview button/selector index: " + pathIndex);
|
||||||
await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await interaction.CreateResponseAsync(DiscordInteractionResponseType.UpdateMessage);
|
|
||||||
|
|
||||||
(string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex);
|
(string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex);
|
||||||
|
|
||||||
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel);
|
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task ProcessResponseMessage(DiscordMessage message)
|
public static async Task ProcessResponseMessage(DiscordMessage answerMessage)
|
||||||
{
|
{
|
||||||
// Either the message or the referenced message is null.
|
// Either the message or the referenced message is null.
|
||||||
if (message.Channel == null || message.ReferencedMessage?.Channel == null)
|
if (answerMessage.Channel == null || answerMessage.ReferencedMessage?.Channel == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The channel does not have an active interview.
|
// The channel does not have an active interview.
|
||||||
if (!activeInterviews.TryGetValue(message.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot))
|
if (!activeInterviews.TryGetValue(answerMessage.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -307,7 +348,7 @@ public static class Interviewer
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user responded to something other than the latest interview question.
|
// The user responded to something other than the latest interview question.
|
||||||
if (message.ReferencedMessage.Id != currentQuestion.messageID)
|
if (answerMessage.ReferencedMessage.Id != currentQuestion.messageID)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -318,21 +359,47 @@ public static class Interviewer
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The length requirement is less than 1024 characters, and must be less than the configurable limit if it is set.
|
||||||
|
int maxLength = currentQuestion.maxLength == 0 ? 1024 : Math.Min(currentQuestion.maxLength, 1024);
|
||||||
|
|
||||||
|
if (answerMessage.Content.Length > maxLength)
|
||||||
|
{
|
||||||
|
DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
|
||||||
|
{
|
||||||
|
Description = "Error: Your answer cannot be more than " + maxLength + " characters (" + answerMessage.Content.Length + "/" + maxLength + ").",
|
||||||
|
Color = DiscordColor.Red
|
||||||
|
});
|
||||||
|
currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerMessage.Content.Length < currentQuestion.minLength)
|
||||||
|
{
|
||||||
|
DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
|
||||||
|
{
|
||||||
|
Description = "Error: Your answer must be at least " + currentQuestion.minLength + " characters (" + answerMessage.Content.Length + "/" + currentQuestion.minLength + ").",
|
||||||
|
Color = DiscordColor.Red
|
||||||
|
});
|
||||||
|
currentQuestion.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths)
|
foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths)
|
||||||
{
|
{
|
||||||
// Skip to the first matching path.
|
// Skip to the first matching path.
|
||||||
if (!Regex.IsMatch(message.Content, questionString)) continue;
|
if (!Regex.IsMatch(answerMessage.Content, questionString)) continue;
|
||||||
|
|
||||||
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, message.Channel, message);
|
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, answerMessage.Channel, answerMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Make message configurable.
|
// TODO: Make message configurable.
|
||||||
await message.RespondAsync(new DiscordEmbedBuilder
|
DiscordMessage errorMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
|
||||||
{
|
{
|
||||||
Description = "Error: Could not determine the next question based on your answer.",
|
Description = "Error: Could not determine the next question based on your answer.",
|
||||||
Color = DiscordColor.Red
|
Color = DiscordColor.Red
|
||||||
});
|
});
|
||||||
|
currentQuestion.AddRelatedMessageIDs(errorMessage.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task HandleAnswer(string questionString,
|
private static async Task HandleAnswer(string questionString,
|
||||||
|
@ -379,7 +446,7 @@ public static class Interviewer
|
||||||
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
||||||
{
|
{
|
||||||
Color = Utilities.StringToColor(nextQuestion.color),
|
Color = Utilities.StringToColor(nextQuestion.color),
|
||||||
Title = "Summary:", // TODO: Set title
|
Title = nextQuestion.title,
|
||||||
Description = nextQuestion.message,
|
Description = nextQuestion.message,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -402,6 +469,7 @@ public static class Interviewer
|
||||||
await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
||||||
{
|
{
|
||||||
Color = Utilities.StringToColor(nextQuestion.color),
|
Color = Utilities.StringToColor(nextQuestion.color),
|
||||||
|
Title = nextQuestion.title,
|
||||||
Description = nextQuestion.message
|
Description = nextQuestion.message
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -419,6 +487,7 @@ public static class Interviewer
|
||||||
DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
||||||
{
|
{
|
||||||
Color = Utilities.StringToColor(nextQuestion.color),
|
Color = Utilities.StringToColor(nextQuestion.color),
|
||||||
|
Title = nextQuestion.title,
|
||||||
Description = nextQuestion.message
|
Description = nextQuestion.message
|
||||||
});
|
});
|
||||||
previousQuestion.AddRelatedMessageIDs(errorMessage.Id);
|
previousQuestion.AddRelatedMessageIDs(errorMessage.Id);
|
||||||
|
@ -429,6 +498,7 @@ public static class Interviewer
|
||||||
.AddEmbed(new DiscordEmbedBuilder()
|
.AddEmbed(new DiscordEmbedBuilder()
|
||||||
{
|
{
|
||||||
Color = Utilities.StringToColor(nextQuestion.color),
|
Color = Utilities.StringToColor(nextQuestion.color),
|
||||||
|
Title = nextQuestion.title,
|
||||||
Description = nextQuestion.message
|
Description = nextQuestion.message
|
||||||
}).WithReply(answerMessage.Id);
|
}).WithReply(answerMessage.Id);
|
||||||
DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
|
DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
|
||||||
|
@ -463,6 +533,7 @@ public static class Interviewer
|
||||||
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
||||||
{
|
{
|
||||||
Color = Utilities.StringToColor(question.color),
|
Color = Utilities.StringToColor(question.color),
|
||||||
|
Title = question.title,
|
||||||
Description = question.message
|
Description = question.message
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -475,7 +546,8 @@ public static class Interviewer
|
||||||
List<DiscordButtonComponent> buttonRow = [];
|
List<DiscordButtonComponent> buttonRow = [];
|
||||||
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++)
|
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++)
|
||||||
{
|
{
|
||||||
buttonRow.Add(new DiscordButtonComponent(DiscordButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key));
|
(string questionString, InterviewQuestion nextQuestion) = question.paths.ToArray()[nrOfButtons];
|
||||||
|
buttonRow.Add(new DiscordButtonComponent(nextQuestion.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, questionString));
|
||||||
}
|
}
|
||||||
msgBuilder.AddComponents(buttonRow);
|
msgBuilder.AddComponents(buttonRow);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue