Added step references, ability to jump to other steps depending on user actions

This commit is contained in:
Toastie 2025-02-04 16:32:21 +13:00
parent da5cca1f78
commit 61b57d0afc
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
6 changed files with 315 additions and 106 deletions

View file

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

View file

@ -102,17 +102,34 @@ public class InterviewTemplateCommands
JsonSerializer serializer = new(); JsonSerializer serializer = new();
Template template = serializer.Deserialize<Template>(validatingReader); Template template = serializer.Deserialize<Template>(validatingReader);
DiscordChannel category = await SupportChild.client.GetChannelAsync(template.categoryID); DiscordChannel category;
try
{
category = await SupportChild.client.GetChannelAsync(template.categoryID);
}
catch (Exception e)
{
await command.RespondAsync(new DiscordEmbedBuilder
{
Color = DiscordColor.Red,
Description = "Could not get the category from the ID in the uploaded JSON structure."
}, true);
Logger.Warn("Failed to get template category from ID: " + template.categoryID, e);
return;
}
if (!category.IsCategory) if (!category.IsCategory)
{ {
await command.RespondAsync(new DiscordEmbedBuilder await command.RespondAsync(new DiscordEmbedBuilder
{ {
Color = DiscordColor.Red, Color = DiscordColor.Red,
Description = "The category ID in the uploaded JSON structure is not a valid category." Description = "The channel ID in the uploaded JSON structure is not a category."
}, true); }, true);
return; return;
} }
//TODO: Validate that any references with reference end steps have an after reference step
List<string> errors = []; List<string> errors = [];
List<string> warnings = []; List<string> warnings = [];
template.interview.Validate(ref errors, ref warnings, "interview", 0, 0); template.interview.Validate(ref errors, ref warnings, "interview", 0, 0);
@ -208,7 +225,7 @@ public class InterviewTemplateCommands
return; return;
} }
if (!Database.TryGetInterviewTemplate(category.Id, out InterviewStep _)) if (!Database.TryGetInterviewFromTemplate(category.Id, 0, out Interview _))
{ {
await command.RespondAsync(new DiscordEmbedBuilder await command.RespondAsync(new DiscordEmbedBuilder
{ {

View file

@ -113,7 +113,8 @@ public static class Database
using MySqlCommand createInterviews = new MySqlCommand( using MySqlCommand createInterviews = new MySqlCommand(
"CREATE TABLE IF NOT EXISTS interviews(" + "CREATE TABLE IF NOT EXISTS interviews(" +
"channel_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," + "channel_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," +
"interview JSON NOT NULL)", "interview JSON NOT NULL," +
"definitions JSON NOT NULL)",
c); c);
using MySqlCommand createInterviewTemplates = new MySqlCommand( using MySqlCommand createInterviewTemplates = new MySqlCommand(
"CREATE TABLE IF NOT EXISTS interview_templates(" + "CREATE TABLE IF NOT EXISTS interview_templates(" +
@ -753,7 +754,7 @@ public static class Database
return templates; return templates;
} }
public static bool TryGetInterviewTemplate(ulong categoryID, out Interviews.InterviewStep template) public static bool TryGetInterviewFromTemplate(ulong categoryID, ulong channelID, out Interviews.Interview interview)
{ {
using MySqlConnection c = GetConnection(); using MySqlConnection c = GetConnection();
c.Open(); c.Open();
@ -765,7 +766,7 @@ public static class Database
// Check if messages exist in the database // Check if messages exist in the database
if (!results.Read()) if (!results.Read())
{ {
template = null; interview = null;
return false; return false;
} }
@ -774,7 +775,7 @@ public static class Database
try try
{ {
template = JsonConvert.DeserializeObject<Interviews.Template>(templateString, new JsonSerializerSettings Template template = JsonConvert.DeserializeObject<Template>(templateString, new JsonSerializerSettings
{ {
Error = delegate (object sender, ErrorEventArgs args) Error = delegate (object sender, ErrorEventArgs args)
{ {
@ -782,21 +783,22 @@ public static class Database
Logger.Debug("Detailed exception:", args.ErrorContext.Error); Logger.Debug("Detailed exception:", args.ErrorContext.Error);
args.ErrorContext.Handled = false; args.ErrorContext.Handled = false;
} }
}).interview; });
interview = new Interview(channelID, template.interview, template.definitions);
return true; return true;
} }
catch (Exception) catch (Exception)
{ {
template = null; interview = null;
return false; return false;
} }
} }
public static bool SetInterviewTemplate(Interviews.Template template) public static bool SetInterviewTemplate(Template template)
{ {
try try
{ {
string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings() string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings
{ {
NullValueHandling = NullValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore,
MissingMemberHandling = MissingMemberHandling.Error, MissingMemberHandling = MissingMemberHandling.Error,
@ -805,7 +807,7 @@ public static class Database
}); });
string query; string query;
if (TryGetInterviewTemplate(template.categoryID, out _)) if (TryGetInterviewFromTemplate(template.categoryID, 0, out _))
{ {
query = "UPDATE interview_templates SET template = @template WHERE category_id=@category_id"; query = "UPDATE interview_templates SET template = @template WHERE category_id=@category_id";
} }
@ -845,25 +847,26 @@ public static class Database
} }
} }
public static bool SaveInterview(ulong channelID, Interviews.InterviewStep interview) public static bool SaveInterview(Interview interview)
{ {
try try
{ {
string query; string query;
if (TryGetInterview(channelID, out _)) if (TryGetInterview(interview.channelID, out _))
{ {
query = "UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id"; query = "UPDATE interviews SET interview = @interview, definitions = @definitions WHERE channel_id = @channel_id";
} }
else else
{ {
query = "INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview)"; query = "INSERT INTO interviews (channel_id,interview, definitions) VALUES (@channel_id, @interview, @definitions)";
} }
using MySqlConnection c = GetConnection(); using MySqlConnection c = GetConnection();
c.Open(); c.Open();
using MySqlCommand cmd = new MySqlCommand(query, c); using MySqlCommand cmd = new MySqlCommand(query, c);
cmd.Parameters.AddWithValue("@channel_id", channelID); cmd.Parameters.AddWithValue("@channel_id", interview.channelID);
cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview, Formatting.Indented)); cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview.interviewRoot, Formatting.Indented));
cmd.Parameters.AddWithValue("@definitions", JsonConvert.SerializeObject(interview.definitions, Formatting.Indented));
cmd.Prepare(); cmd.Prepare();
return cmd.ExecuteNonQuery() > 0; return cmd.ExecuteNonQuery() > 0;
} }
@ -873,7 +876,7 @@ public static class Database
} }
} }
public static bool TryGetInterview(ulong channelID, out Interviews.InterviewStep interview) public static bool TryGetInterview(ulong channelID, out Interview interview)
{ {
using MySqlConnection c = GetConnection(); using MySqlConnection c = GetConnection();
c.Open(); c.Open();
@ -888,7 +891,9 @@ public static class Database
interview = null; interview = null;
return false; return false;
} }
interview = JsonConvert.DeserializeObject<Interviews.InterviewStep>(results.GetString("interview")); interview = new Interview(channelID,
JsonConvert.DeserializeObject<InterviewStep>(results.GetString("interview")),
JsonConvert.DeserializeObject<Dictionary<string, InterviewStep>>(results.GetString("definitions")));
results.Close(); results.Close();
return true; return true;
} }

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Text.RegularExpressions;
using DSharpPlus.Entities; using DSharpPlus.Entities;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@ -22,7 +23,8 @@ public enum MessageType
ROLE_SELECTOR, ROLE_SELECTOR,
MENTIONABLE_SELECTOR, // User or role MENTIONABLE_SELECTOR, // User or role
CHANNEL_SELECTOR, CHANNEL_SELECTOR,
TEXT_INPUT TEXT_INPUT,
REFERENCE_END
} }
public enum ButtonType public enum ButtonType
@ -33,6 +35,45 @@ public enum ButtonType
DANGER DANGER
} }
public class ReferencedInterviewStep
{
[JsonProperty("id")]
public string id;
// If this step is on a button, give it this style.
[JsonConverter(typeof(StringEnumConverter))]
[JsonProperty("button-style")]
public ButtonType? buttonStyle;
// If this step is in a selector, give it this description.
[JsonProperty("selector-description")]
public string selectorDescription;
// Runs at the end of the reference
[JsonProperty("after-reference-step")]
public InterviewStep afterReferenceStep;
public DiscordButtonStyle GetButtonStyle()
{
return InterviewStep.GetButtonStyle(buttonStyle);
}
public bool TryGetReferencedStep(Interview interview, out InterviewStep step)
{
if (!interview.definitions.TryGetValue(id, out step))
{
Logger.Error("Could not find referenced step '" + id + "' in interview for channel '" + interview.channelID + "'");
return false;
}
step.buttonStyle = buttonStyle;
step.selectorDescription = selectorDescription;
step.afterReferenceStep = afterReferenceStep;
return true;
}
}
// A tree of steps 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.
@ -54,7 +95,7 @@ public class InterviewStep
// Colour of the message embed. // Colour of the message embed.
[JsonProperty("color")] [JsonProperty("color")]
public string color; public string color = "CYAN";
// Used as label for this answer in the post-interview summary. // Used as label for this answer in the post-interview summary.
[JsonProperty("summary-field")] [JsonProperty("summary-field")]
@ -65,11 +106,11 @@ public class InterviewStep
[JsonProperty("button-style")] [JsonProperty("button-style")]
public ButtonType? buttonStyle; public ButtonType? buttonStyle;
// If this step is on a selector, give it this placeholder. // If this step is a selector, give it this placeholder.
[JsonProperty("selector-placeholder")] [JsonProperty("selector-placeholder")]
public string selectorPlaceholder; public string selectorPlaceholder;
// If this step is on a selector, give it this description. // If this step is in a selector, give it this description.
[JsonProperty("selector-description")] [JsonProperty("selector-description")]
public string selectorDescription; public string selectorDescription;
@ -81,6 +122,10 @@ public class InterviewStep
[JsonProperty("min-length")] [JsonProperty("min-length")]
public int? minLength; public int? minLength;
// References to steps defined elsewhere in the template
[JsonProperty("step-references")]
public Dictionary<string, ReferencedInterviewStep> references = new();
// 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("steps")] [JsonProperty("steps")]
public Dictionary<string, InterviewStep> steps = new(); public Dictionary<string, InterviewStep> steps = new();
@ -105,12 +150,23 @@ public class InterviewStep
[JsonProperty("related-message-ids")] [JsonProperty("related-message-ids")]
public List<ulong> relatedMessageIDs; public List<ulong> relatedMessageIDs;
public bool TryGetCurrentStep(out InterviewStep step) // This is only set when the user gets to a referenced step
[JsonProperty("after-reference-step")]
public InterviewStep afterReferenceStep = null;
public bool TryGetCurrentStep(out InterviewStep currentStep)
{
bool result = TryGetTakenSteps(out List<InterviewStep> previousSteps);
currentStep = previousSteps.FirstOrDefault();
return result;
}
public bool TryGetTakenSteps(out List<InterviewStep> previousSteps)
{ {
// 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)
{ {
step = null; previousSteps = null;
return false; return false;
} }
@ -118,14 +174,15 @@ public class InterviewStep
foreach (KeyValuePair<string, InterviewStep> childStep in steps) 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 (childStep.Value.TryGetCurrentStep(out step)) if (childStep.Value.TryGetTakenSteps(out previousSteps))
{ {
previousSteps.Add(this);
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.
step = this; previousSteps = new List<InterviewStep> { this };
return true; return true;
} }
@ -138,7 +195,8 @@ public class InterviewStep
if (!string.IsNullOrWhiteSpace(summaryField)) if (!string.IsNullOrWhiteSpace(summaryField))
{ {
summary.Add(summaryField, answer); // TODO: Add option to merge answers
summary[summaryField] = answer;
} }
// This will always contain exactly one or zero children. // This will always contain exactly one or zero children.
@ -184,18 +242,6 @@ public class InterviewStep
} }
} }
public DiscordButtonStyle GetButtonStyle()
{
return buttonStyle switch
{
ButtonType.PRIMARY => DiscordButtonStyle.Primary,
ButtonType.SECONDARY => DiscordButtonStyle.Secondary,
ButtonType.SUCCESS => DiscordButtonStyle.Success,
ButtonType.DANGER => DiscordButtonStyle.Danger,
_ => DiscordButtonStyle.Secondary
};
}
public void Validate(ref List<string> errors, public void Validate(ref List<string> errors,
ref List<string> warnings, ref List<string> warnings,
string stepID, string stepID,
@ -237,7 +283,7 @@ public class InterviewStep
if (messageType is MessageType.ERROR or MessageType.END_WITH_SUMMARY or MessageType.END_WITHOUT_SUMMARY) if (messageType is MessageType.ERROR or MessageType.END_WITH_SUMMARY or MessageType.END_WITHOUT_SUMMARY)
{ {
if (steps.Count > 0) if (steps.Count > 0 || references.Count > 0)
{ {
warnings.Add("Steps of the type '" + messageType + "' cannot have child steps.\n\n" + stepID + ".message-type"); warnings.Add("Steps of the type '" + messageType + "' cannot have child steps.\n\n" + stepID + ".message-type");
} }
@ -247,7 +293,7 @@ public class InterviewStep
warnings.Add("Steps of the type '" + messageType + "' 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 (steps.Count == 0) else if (steps.Count == 0 && references.Count == 0)
{ {
errors.Add("Steps of the type '" + messageType + "' must have at least one child step.\n\n" + stepID + ".message-type"); errors.Add("Steps of the type '" + messageType + "' must have at least one child step.\n\n" + stepID + ".message-type");
} }
@ -304,6 +350,23 @@ public class InterviewStep
} }
} }
public DiscordButtonStyle GetButtonStyle()
{
return GetButtonStyle(buttonStyle);
}
public static DiscordButtonStyle GetButtonStyle(ButtonType? buttonStyle)
{
return buttonStyle switch
{
ButtonType.PRIMARY => DiscordButtonStyle.Primary,
ButtonType.SECONDARY => DiscordButtonStyle.Secondary,
ButtonType.SUCCESS => DiscordButtonStyle.Success,
ButtonType.DANGER => DiscordButtonStyle.Danger,
_ => DiscordButtonStyle.Secondary
};
}
public class StripInternalPropertiesResolver : DefaultContractResolver public class StripInternalPropertiesResolver : DefaultContractResolver
{ {
private static readonly HashSet<string> ignoreProps = private static readonly HashSet<string> ignoreProps =
@ -326,11 +389,21 @@ public class InterviewStep
} }
} }
public class Template(ulong categoryID, InterviewStep interview) public class Interview(ulong channelID, InterviewStep interviewRoot, Dictionary<string, InterviewStep> definitions)
{
public ulong channelID = channelID;
public InterviewStep interviewRoot = interviewRoot;
public Dictionary<string, InterviewStep> definitions = definitions;
}
public class Template(ulong categoryID, InterviewStep interview, Dictionary<string, InterviewStep> definitions)
{ {
[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 InterviewStep interview = interview; public InterviewStep interview = interview;
[JsonProperty("definitions", Required = Required.Default)]
public Dictionary<string, InterviewStep> definitions = definitions;
} }

View file

@ -14,22 +14,22 @@ 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 InterviewStep template)) if (!Database.TryGetInterviewFromTemplate(channel.Parent.Id, channel.Id, out Interview interview))
{ {
return false; return false;
} }
await SendNextMessage(channel, template); await SendNextMessage(channel, interview.interviewRoot);
return Database.SaveInterview(channel.Id, template); return Database.SaveInterview(interview);
} }
public static async Task<bool> RestartInterview(DiscordChannel channel) public static async Task<bool> RestartInterview(DiscordChannel channel)
{ {
if (Database.TryGetInterview(channel.Id, out InterviewStep interviewRoot)) if (Database.TryGetInterview(channel.Id, out Interview interview))
{ {
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
{ {
await DeletePreviousMessages(interviewRoot, channel); await DeletePreviousMessages(interview, channel);
} }
if (!Database.TryDeleteInterview(channel.Id)) if (!Database.TryDeleteInterview(channel.Id))
@ -43,11 +43,11 @@ 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 InterviewStep interviewRoot)) if (Database.TryGetInterview(channel.Id, out Interview interview))
{ {
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
{ {
await DeletePreviousMessages(interviewRoot, channel); await DeletePreviousMessages(interview, channel);
} }
if (!Database.TryDeleteInterview(channel.Id)) if (!Database.TryDeleteInterview(channel.Id))
@ -61,7 +61,7 @@ public static class Interviewer
public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction) public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction)
{ {
if (interaction?.Channel == null || interaction?.Message == null) if (interaction?.Channel == null || interaction.Message == null)
{ {
return; return;
} }
@ -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 InterviewStep interviewRoot)) if (!Database.TryGetInterview(interaction.Channel.Id, out Interview interview))
{ {
await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder() await interaction.CreateResponseAsync(DiscordInteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder()
.AddEmbed(new DiscordEmbedBuilder() .AddEmbed(new DiscordEmbedBuilder()
@ -85,7 +85,7 @@ 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.TryGetCurrentStep(out InterviewStep currentStep)) if (!interview.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()
@ -126,19 +126,19 @@ public static class Interviewer
case DiscordComponentType.RoleSelect: case DiscordComponentType.RoleSelect:
case DiscordComponentType.ChannelSelect: case DiscordComponentType.ChannelSelect:
case DiscordComponentType.MentionableSelect: case DiscordComponentType.MentionableSelect:
if (interaction.Data.Resolved?.Roles?.Any() ?? false) if (interaction.Data.Resolved.Roles.Any())
{ {
answer = interaction.Data.Resolved.Roles.First().Value.Mention; answer = interaction.Data.Resolved.Roles.First().Value.Mention;
} }
else if (interaction.Data.Resolved?.Users?.Any() ?? false) else if (interaction.Data.Resolved.Users.Any())
{ {
answer = interaction.Data.Resolved.Users.First().Value.Mention; answer = interaction.Data.Resolved.Users.First().Value.Mention;
} }
else if (interaction.Data.Resolved?.Channels?.Any() ?? false) else if (interaction.Data.Resolved.Channels.Any())
{ {
answer = interaction.Data.Resolved.Channels.First().Value.Mention; answer = interaction.Data.Resolved.Channels.First().Value.Mention;
} }
else if (interaction.Data.Resolved?.Messages?.Any() ?? false) else if (interaction.Data.Resolved.Messages.Any())
{ {
answer = interaction.Data.Resolved.Messages.First().Value.Id.ToString(); answer = interaction.Data.Resolved.Messages.First().Value.Id.ToString();
} }
@ -147,7 +147,7 @@ public static class Interviewer
componentID = interaction.Data.Values[0]; componentID = interaction.Data.Values[0];
break; break;
case DiscordComponentType.Button: case DiscordComponentType.Button:
componentID = interaction.Data.CustomId.Replace("supportchild_interviewbutton ", ""); componentID = interaction.Data.CustomId.Replace("supportboi_interviewbutton ", "");
break; break;
case DiscordComponentType.ActionRow: case DiscordComponentType.ActionRow:
case DiscordComponentType.FormInput: case DiscordComponentType.FormInput:
@ -158,12 +158,27 @@ 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, ReferencedInterviewStep> reference in currentStep.references)
{
// Skip to the first matching step.
if (Regex.IsMatch(answer, reference.Key))
{
if (TryGetStepFromReference(interview, reference.Value, out InterviewStep referencedStep))
{
currentStep.steps.Add(reference.Key, referencedStep);
await HandleAnswer(answer, referencedStep, interview, currentStep, interaction.Channel);
}
currentStep.references.Remove(reference.Key);
return;
}
}
foreach (KeyValuePair<string, InterviewStep> step in currentStep.steps) foreach (KeyValuePair<string, InterviewStep> step in currentStep.steps)
{ {
// Skip to the first matching step. // Skip to the first matching step.
if (Regex.IsMatch(answer, step.Key)) if (Regex.IsMatch(answer, step.Key))
{ {
await HandleAnswer(answer, step.Value, interviewRoot, currentStep, interaction.Channel); await HandleAnswer(answer, step.Value, interview, currentStep, interaction.Channel);
return; return;
} }
} }
@ -175,7 +190,7 @@ public static class Interviewer
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());
currentStep.AddRelatedMessageIDs(followupMessage.Id); currentStep.AddRelatedMessageIDs(followupMessage.Id);
Database.SaveInterview(interaction.Channel.Id, interviewRoot); Database.SaveInterview(interview);
} }
else else
{ {
@ -192,10 +207,31 @@ public static class Interviewer
} }
(string stepString, InterviewStep nextStep) = currentStep.steps.ElementAt(stepIndex); (string stepString, InterviewStep nextStep) = currentStep.steps.ElementAt(stepIndex);
await HandleAnswer(stepString, nextStep, interviewRoot, currentStep, interaction.Channel); await HandleAnswer(stepString, nextStep, interview, currentStep, interaction.Channel);
} }
} }
public static bool TryGetStepFromReference(Interview interview, ReferencedInterviewStep reference, out InterviewStep step)
{
foreach (KeyValuePair<string, InterviewStep> definition in interview.definitions)
{
if (reference.id == definition.Key)
{
step = definition.Value;
step.buttonStyle = reference.buttonStyle;
step.selectorDescription = reference.selectorDescription;
if (step.messageType != MessageType.ERROR)
{
step.afterReferenceStep = reference.afterReferenceStep;
}
return true;
}
}
step = null;
return false;
}
public static async Task ProcessResponseMessage(DiscordMessage answerMessage) 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.
@ -205,12 +241,13 @@ 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 InterviewStep interviewRoot)) if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id,
out Interview interview))
{ {
return; return;
} }
if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep)) if (!interview.interviewRoot.TryGetCurrentStep(out InterviewStep currentStep))
{ {
return; return;
} }
@ -238,7 +275,7 @@ public static class Interviewer
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(interview);
return; return;
} }
@ -250,7 +287,7 @@ public static class Interviewer
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(interview);
return; return;
} }
@ -262,7 +299,7 @@ public static class Interviewer
continue; continue;
} }
await HandleAnswer(answerMessage.Content, nextStep, interviewRoot, currentStep, answerMessage.Channel, answerMessage); await HandleAnswer(answerMessage.Content, nextStep, interview, currentStep, answerMessage.Channel, answerMessage);
return; return;
} }
@ -273,12 +310,12 @@ public static class Interviewer
Color = DiscordColor.Red Color = DiscordColor.Red
}); });
currentStep.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id); currentStep.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id);
Database.SaveInterview(answerMessage.Channel.Id, interviewRoot); Database.SaveInterview(interview);
} }
private static async Task HandleAnswer(string answer, private static async Task HandleAnswer(string answer,
InterviewStep nextStep, InterviewStep nextStep,
InterviewStep interviewRoot, Interview interview,
InterviewStep previousStep, InterviewStep previousStep,
DiscordChannel channel, DiscordChannel channel,
DiscordMessage answerMessage = null) DiscordMessage answerMessage = null)
@ -302,14 +339,37 @@ public static class Interviewer
case MessageType.USER_SELECTOR: case MessageType.USER_SELECTOR:
case MessageType.CHANNEL_SELECTOR: case MessageType.CHANNEL_SELECTOR:
case MessageType.MENTIONABLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR:
foreach ((string stepPattern, ReferencedInterviewStep reference) in nextStep.references)
{
if (!reference.TryGetReferencedStep(interview, out InterviewStep step))
{
if (answerMessage != null)
{
DiscordMessage lengthMessage = await answerMessage.RespondAsync(new DiscordEmbedBuilder
{
Description = "Error: The referenced step id '" + reference.id + "' does not exist in the step definitions.",
Color = DiscordColor.Red
});
nextStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
previousStep.answer = null;
previousStep.answerID = 0;
Database.SaveInterview(interview);
}
return;
}
nextStep.steps.Add(stepPattern, step);
}
nextStep.references.Clear();
await SendNextMessage(channel, nextStep); await SendNextMessage(channel, nextStep);
Database.SaveInterview(channel.Id, interviewRoot); Database.SaveInterview(interview);
break; break;
case MessageType.END_WITH_SUMMARY: case MessageType.END_WITH_SUMMARY:
OrderedDictionary summaryFields = new OrderedDictionary(); OrderedDictionary summaryFields = new OrderedDictionary();
interviewRoot.GetSummary(ref summaryFields); interview.interviewRoot.GetSummary(ref summaryFields);
DiscordEmbedBuilder embed = new DiscordEmbedBuilder() DiscordEmbedBuilder embed = new DiscordEmbedBuilder
{ {
Color = Utilities.StringToColor(nextStep.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextStep.heading, Title = nextStep.heading,
@ -318,14 +378,14 @@ public static class Interviewer
foreach (DictionaryEntry entry in summaryFields) foreach (DictionaryEntry entry in summaryFields)
{ {
embed.AddField((string)entry.Key, (string)entry.Value); embed.AddField((string)entry.Key, (string)entry.Value ?? string.Empty);
} }
await channel.SendMessageAsync(embed); await channel.SendMessageAsync(embed);
if (Config.deleteMessagesAfterSummary) if (Config.deleteMessagesAfterSummary)
{ {
await DeletePreviousMessages(interviewRoot, channel); await DeletePreviousMessages(interview, channel);
} }
if (!Database.TryDeleteInterview(channel.Id)) if (!Database.TryDeleteInterview(channel.Id))
@ -334,7 +394,7 @@ public static class Interviewer
} }
return; return;
case MessageType.END_WITHOUT_SUMMARY: case MessageType.END_WITHOUT_SUMMARY:
await channel.SendMessageAsync(new DiscordEmbedBuilder() await channel.SendMessageAsync(new DiscordEmbedBuilder
{ {
Color = Utilities.StringToColor(nextStep.color), Color = Utilities.StringToColor(nextStep.color),
Title = nextStep.heading, Title = nextStep.heading,
@ -343,7 +403,7 @@ public static class Interviewer
if (Config.deleteMessagesAfterNoSummary) if (Config.deleteMessagesAfterNoSummary)
{ {
await DeletePreviousMessages(interviewRoot, channel); await DeletePreviousMessages(interview, channel);
} }
if (!Database.TryDeleteInterview(channel.Id)) if (!Database.TryDeleteInterview(channel.Id))
@ -351,40 +411,88 @@ 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 MessageType.ERROR: case MessageType.REFERENCE_END:
default: // TODO: What is happening with the summaries?
if (interview.interviewRoot.TryGetTakenSteps(out List<InterviewStep> previousSteps))
{
foreach (InterviewStep step in previousSteps)
{
if (step.afterReferenceStep != null)
{
// If the referenced step is also a reference end, skip it and try to find another.
if (step.afterReferenceStep.messageType == MessageType.REFERENCE_END)
{
step.afterReferenceStep = null;
}
else
{
nextStep = step.afterReferenceStep;
step.afterReferenceStep = null;
previousStep.steps.Clear();
previousStep.steps.Add(answer, nextStep);
await HandleAnswer(answer,
nextStep,
interview,
previousStep,
channel,
answerMessage);
return;
}
}
}
}
DiscordEmbedBuilder error = new DiscordEmbedBuilder
{
Color = DiscordColor.Red,
Description = "An error occured while trying to find the next interview step."
};
if (answerMessage == null) if (answerMessage == null)
{ {
DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder() DiscordMessage errorMessage = await channel.SendMessageAsync(error);
{
Color = Utilities.StringToColor(nextStep.color),
Title = nextStep.heading,
Description = nextStep.message
});
previousStep.AddRelatedMessageIDs(errorMessage.Id); previousStep.AddRelatedMessageIDs(errorMessage.Id);
} }
else else
{ {
DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder() DiscordMessage errorMessage = await answerMessage.RespondAsync(error);
.AddEmbed(new DiscordEmbedBuilder()
{
Color = Utilities.StringToColor(nextStep.color),
Title = nextStep.heading,
Description = nextStep.message
}).WithReply(answerMessage.Id);
DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id); previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id);
} }
Database.SaveInterview(channel.Id, interviewRoot); Database.SaveInterview(interview);
Logger.Error("Could not find a step to return to after a reference step in channel " + channel.Id);
return;
case MessageType.ERROR:
default:
DiscordEmbedBuilder err = new DiscordEmbedBuilder
{
Color = Utilities.StringToColor(nextStep.color),
Title = nextStep.heading,
Description = nextStep.message
};
if (answerMessage == null)
{
DiscordMessage errorMessage = await channel.SendMessageAsync(err);
previousStep.AddRelatedMessageIDs(errorMessage.Id);
}
else
{
DiscordMessage errorMessage = await answerMessage.RespondAsync(err);
previousStep.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id);
}
Database.SaveInterview(interview);
break; break;
} }
} }
private static async Task DeletePreviousMessages(InterviewStep interviewRoot, DiscordChannel channel) private static async Task DeletePreviousMessages(Interview interview, DiscordChannel channel)
{ {
List<ulong> previousMessages = []; List<ulong> previousMessages = [];
interviewRoot.GetMessageIDs(ref previousMessages); interview.interviewRoot.GetMessageIDs(ref previousMessages);
foreach (ulong previousMessageID in previousMessages) foreach (ulong previousMessageID in previousMessages)
{ {
@ -420,7 +528,7 @@ public static class Interviewer
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < step.steps.Count; nrOfButtons++) for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < step.steps.Count; nrOfButtons++)
{ {
(string stepPattern, InterviewStep nextStep) = step.steps.ToArray()[nrOfButtons]; (string stepPattern, InterviewStep nextStep) = step.steps.ToArray()[nrOfButtons];
buttonRow.Add(new DiscordButtonComponent(nextStep.GetButtonStyle(), "supportchild_interviewbutton " + nrOfButtons, stepPattern)); buttonRow.Add(new DiscordButtonComponent(nextStep.GetButtonStyle(), "supportboi_interviewbutton " + nrOfButtons, stepPattern));
} }
msgBuilder.AddComponents(buttonRow); msgBuilder.AddComponents(buttonRow);
} }
@ -438,26 +546,26 @@ public static class Interviewer
categoryOptions.Add(new DiscordSelectComponentOption(stepPattern, selectionOptions.ToString(), nextStep.selectorDescription)); categoryOptions.Add(new DiscordSelectComponentOption(stepPattern, selectionOptions.ToString(), nextStep.selectorDescription));
} }
selectionComponents.Add(new DiscordSelectComponent("supportchild_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(step.selectorPlaceholder) selectionComponents.Add(new DiscordSelectComponent("supportboi_interviewselector " + selectionBoxes, string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select an option..." : step.selectorPlaceholder, categoryOptions)); ? "Select an option..." : step.selectorPlaceholder, categoryOptions));
} }
msgBuilder.AddComponents(selectionComponents); msgBuilder.AddComponents(selectionComponents);
break; break;
case MessageType.ROLE_SELECTOR: case MessageType.ROLE_SELECTOR:
msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportchild_interviewroleselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) msgBuilder.AddComponents(new DiscordRoleSelectComponent("supportboi_interviewroleselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a role..." : step.selectorPlaceholder)); ? "Select a role..." : step.selectorPlaceholder));
break; break;
case MessageType.USER_SELECTOR: case MessageType.USER_SELECTOR:
msgBuilder.AddComponents(new DiscordUserSelectComponent("supportchild_interviewuserselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) msgBuilder.AddComponents(new DiscordUserSelectComponent("supportboi_interviewuserselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a user..." : step.selectorPlaceholder)); ? "Select a user..." : step.selectorPlaceholder));
break; break;
case MessageType.CHANNEL_SELECTOR: case MessageType.CHANNEL_SELECTOR:
msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportchild_interviewchannelselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) msgBuilder.AddComponents(new DiscordChannelSelectComponent("supportboi_interviewchannelselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a channel..." : step.selectorPlaceholder)); ? "Select a channel..." : step.selectorPlaceholder));
break; break;
case MessageType.MENTIONABLE_SELECTOR: case MessageType.MENTIONABLE_SELECTOR:
msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportchild_interviewmentionableselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder) msgBuilder.AddComponents(new DiscordMentionableSelectComponent("supportboi_interviewmentionableselector", string.IsNullOrWhiteSpace(step.selectorPlaceholder)
? "Select a user or role..." : step.selectorPlaceholder)); ? "Select a user or role..." : step.selectorPlaceholder));
break; break;
case MessageType.TEXT_INPUT: case MessageType.TEXT_INPUT:

View file

@ -6,7 +6,7 @@
"definitions": { "definitions": {
"reference": { "reference": {
"type": "object", "type": "object",
"title": "Interview Step", "title": "Interview Step Reference",
"description": "Contains a reference to a step defined in the 'definitions' property. You can also specify a button-style and selector-description here to override the one from the referenced step.", "description": "Contains a reference to a step defined in the 'definitions' property. You can also specify a button-style and selector-description here to override the one from the referenced step.",
"properties": { "properties": {
"id": { "id": {
@ -31,6 +31,12 @@
"title": "Selector Description", "title": "Selector Description",
"description": "Description for this option in the parent step's selection box. Requires that the parent step is a 'TEXT_SELECTOR'.", "description": "Description for this option in the parent step's selection box. Requires that the parent step is a 'TEXT_SELECTOR'.",
"minLength": 1 "minLength": 1
},
"after-reference-step": {
"type": "object",
"title": "Steps",
"description": "Any REFERENCE_END steps in the reference will continue to this step.",
"$ref": "#/definitions/step"
} }
}, },
"required": [ "required": [
@ -69,7 +75,8 @@
"ROLE_SELECTOR", "ROLE_SELECTOR",
"MENTIONABLE_SELECTOR", "MENTIONABLE_SELECTOR",
"CHANNEL_SELECTOR", "CHANNEL_SELECTOR",
"TEXT_INPUT" "TEXT_INPUT",
"REFERENCE_END"
] ]
}, },
"color": { "color": {
@ -186,8 +193,7 @@
}, },
"required": [ "required": [
"message", "message",
"message-type", "message-type"
"color"
], ],
"unevaluatedProperties": false "unevaluatedProperties": false
} }