492 lines
No EOL
18 KiB
C#
492 lines
No EOL
18 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using DSharpPlus.Entities;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Converters;
|
|
|
|
namespace SupportChild;
|
|
|
|
public static class Interviewer
|
|
{
|
|
// TODO: Investigate other types of selectors
|
|
public enum QuestionType
|
|
{
|
|
ERROR,
|
|
END_WITH_SUMMARY,
|
|
END_WITHOUT_SUMMARY,
|
|
BUTTONS,
|
|
TEXT_SELECTOR,
|
|
TEXT_INPUT
|
|
}
|
|
|
|
// 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.
|
|
// 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.
|
|
public class InterviewQuestion
|
|
{
|
|
// TODO: Optional properties: embed title, button style
|
|
|
|
// Message contents sent to the user.
|
|
[JsonProperty("message")]
|
|
public string message;
|
|
|
|
// The type of question.
|
|
[JsonConverter(typeof(StringEnumConverter))]
|
|
[JsonProperty("type")]
|
|
public QuestionType type;
|
|
|
|
// Colour of the message embed.
|
|
[JsonProperty("color")]
|
|
public string color;
|
|
|
|
// Used as label for this question in the post-interview summary.
|
|
[JsonProperty("summary-field")]
|
|
public string summaryField;
|
|
|
|
// Possible questions to ask next, or DONE/FAIL type in order to finish interview.
|
|
[JsonProperty("paths")]
|
|
public Dictionary<string, InterviewQuestion> paths;
|
|
|
|
// ////////////////////////////////////////////////////////////////////////////
|
|
// The following parameters are populated by the bot, not the json template. //
|
|
// ////////////////////////////////////////////////////////////////////////////
|
|
|
|
// The ID of this message where the bot asked this question.
|
|
[JsonProperty("message-id")]
|
|
public ulong messageID;
|
|
|
|
// The contents of the user's answer.
|
|
[JsonProperty("answer")]
|
|
public string answer;
|
|
|
|
// The ID of the user's answer message if this is a TEXT_INPUT type.
|
|
[JsonProperty("answer-id")]
|
|
public ulong answerID;
|
|
|
|
// Any extra messages generated by the bot that should be removed when the interview ends.
|
|
[JsonProperty("related-message-ids")]
|
|
public List<ulong> relatedMessageIDs;
|
|
|
|
public bool TryGetCurrentQuestion(out InterviewQuestion question)
|
|
{
|
|
// This object has not been initialized, we have checked too deep.
|
|
if (messageID == 0)
|
|
{
|
|
question = null;
|
|
return false;
|
|
}
|
|
|
|
// Check children.
|
|
foreach (KeyValuePair<string, InterviewQuestion> path in paths)
|
|
{
|
|
// This child either is the one we are looking for or contains the one we are looking for.
|
|
if (path.Value.TryGetCurrentQuestion(out question))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// This object is the deepest object with a message ID set, meaning it is the latest asked question.
|
|
question = this;
|
|
return true;
|
|
}
|
|
|
|
public void GetSummary(ref OrderedDictionary summary)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(summaryField))
|
|
{
|
|
summary.Add(summaryField, answer);
|
|
}
|
|
|
|
// This will always contain exactly one or zero children.
|
|
foreach (KeyValuePair<string, InterviewQuestion> path in paths)
|
|
{
|
|
path.Value.GetSummary(ref summary);
|
|
}
|
|
}
|
|
|
|
public void GetMessageIDs(ref List<ulong> messageIDs)
|
|
{
|
|
if (messageID != 0)
|
|
{
|
|
messageIDs.Add(messageID);
|
|
}
|
|
|
|
if (answerID != 0)
|
|
{
|
|
messageIDs.Add(answerID);
|
|
}
|
|
|
|
if (relatedMessageIDs != null)
|
|
{
|
|
messageIDs.AddRange(relatedMessageIDs);
|
|
}
|
|
|
|
// This will always contain exactly one or zero children.
|
|
foreach (KeyValuePair<string, InterviewQuestion> path in paths)
|
|
{
|
|
path.Value.GetMessageIDs(ref messageIDs);
|
|
}
|
|
}
|
|
|
|
public void AddRelatedMessageIDs(params ulong[] messageIDs)
|
|
{
|
|
if (relatedMessageIDs == null)
|
|
{
|
|
relatedMessageIDs = messageIDs.ToList();
|
|
}
|
|
else
|
|
{
|
|
relatedMessageIDs.AddRange(messageIDs);
|
|
}
|
|
}
|
|
}
|
|
|
|
// This class is identical to the one above and just exists as a hack to get JSON validation when
|
|
// new entries are entered but not when read from database in order to be more lenient with old interviews.
|
|
// I might do this in a more proper way at some point.
|
|
public class ValidatedInterviewQuestion
|
|
{
|
|
// Message contents sent to the user.
|
|
[JsonProperty("message", Required = Required.Always)]
|
|
public string message;
|
|
|
|
// The type of question.
|
|
[JsonConverter(typeof(StringEnumConverter))]
|
|
[JsonProperty("type", Required = Required.Always)]
|
|
public QuestionType type;
|
|
|
|
// Colour of the message embed.
|
|
[JsonProperty("color", Required = Required.Always)]
|
|
public string color;
|
|
|
|
// Used as label for this question in the post-interview summary.
|
|
[JsonProperty("summary-field", Required = Required.Always)]
|
|
public string summaryField;
|
|
|
|
// Possible questions to ask next, or DONE/FAIL type in order to finish interview.
|
|
[JsonProperty("paths", Required = Required.Always)]
|
|
public Dictionary<string, ValidatedInterviewQuestion> paths;
|
|
}
|
|
|
|
private static Dictionary<ulong, InterviewQuestion> interviewTemplates = [];
|
|
|
|
private static Dictionary<ulong, InterviewQuestion> activeInterviews = [];
|
|
|
|
// TODO: Maybe split into two functions?
|
|
public static void Reload()
|
|
{
|
|
interviewTemplates = Database.GetInterviewTemplates();
|
|
activeInterviews = Database.GetAllInterviews();
|
|
}
|
|
|
|
public static async void StartInterview(DiscordChannel channel)
|
|
{
|
|
if (channel.Parent == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (interviewTemplates.TryGetValue(channel.Parent.Id, out InterviewQuestion interview))
|
|
{
|
|
await CreateQuestion(channel, interview);
|
|
Database.SaveInterview(channel.Id, interview);
|
|
Reload();
|
|
}
|
|
}
|
|
|
|
public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction)
|
|
{
|
|
// TODO: Add error responses.
|
|
|
|
if (interaction?.Channel == null || interaction?.Message == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Return if the user didn't select anything
|
|
if (interaction.Data.ComponentType == DiscordComponentType.StringSelect && interaction.Data.Values.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate);
|
|
|
|
// Return if there is no active interview in this channel
|
|
if (!activeInterviews.TryGetValue(interaction.Channel.Id, out InterviewQuestion interviewRoot))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Return if the current question cannot be found in the interview.
|
|
if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if this button/selector is for an older question.
|
|
if (interaction.Message.Id != currentQuestion.messageID)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Parse the response index from the button/selector.
|
|
string componentID = "";
|
|
|
|
switch (interaction.Data.ComponentType)
|
|
{
|
|
case DiscordComponentType.StringSelect:
|
|
componentID = interaction.Data.Values[0];
|
|
break;
|
|
case DiscordComponentType.Button:
|
|
componentID = interaction.Data.CustomId.Replace("supportchild_interviewbutton ", "");
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException();
|
|
}
|
|
|
|
|
|
if (!int.TryParse(componentID, out int pathIndex))
|
|
{
|
|
Logger.Error("Invalid interview button/selector index: " + componentID);
|
|
return;
|
|
}
|
|
|
|
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0)
|
|
{
|
|
Logger.Error("Invalid interview button/selector index: " + pathIndex);
|
|
return;
|
|
}
|
|
|
|
(string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex);
|
|
|
|
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel);
|
|
}
|
|
|
|
public static async Task ProcessResponseMessage(DiscordMessage message)
|
|
{
|
|
// Either the message or the referenced message is null.
|
|
if (message.Channel == null || message.ReferencedMessage?.Channel == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The channel does not have an active interview.
|
|
if (!activeInterviews.TryGetValue(message.ReferencedMessage.Channel.Id, out InterviewQuestion interviewRoot))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The user responded to something other than the latest interview question.
|
|
if (message.ReferencedMessage.Id != currentQuestion.messageID)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The user responded to a question which does not take a text response.
|
|
if (currentQuestion.type != QuestionType.TEXT_INPUT)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach ((string questionString, InterviewQuestion nextQuestion) in currentQuestion.paths)
|
|
{
|
|
// Skip to the matching path.
|
|
if (!Regex.IsMatch(message.Content, questionString)) continue;
|
|
|
|
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, message.Channel, message);
|
|
return;
|
|
}
|
|
|
|
// TODO: No matching path found.
|
|
|
|
}
|
|
|
|
private static async Task HandleAnswer(string questionString,
|
|
InterviewQuestion nextQuestion,
|
|
InterviewQuestion interviewRoot,
|
|
InterviewQuestion previousQuestion,
|
|
DiscordChannel channel,
|
|
DiscordMessage answerMessage = null)
|
|
{
|
|
if (nextQuestion.type != QuestionType.ERROR)
|
|
{
|
|
// The answer was provided using a button or selector
|
|
if (answerMessage == null)
|
|
{
|
|
previousQuestion.answer = questionString;
|
|
previousQuestion.answerID = 0;
|
|
}
|
|
else
|
|
{
|
|
previousQuestion.answer = answerMessage.Content;
|
|
previousQuestion.answerID = answerMessage.Id;
|
|
}
|
|
|
|
// Remove any other paths from the previous question.
|
|
previousQuestion.paths = new Dictionary<string, InterviewQuestion>
|
|
{
|
|
{ questionString, nextQuestion }
|
|
};
|
|
}
|
|
|
|
// Create next question, or finish the interview.
|
|
switch (nextQuestion.type)
|
|
{
|
|
case QuestionType.TEXT_INPUT:
|
|
case QuestionType.BUTTONS:
|
|
case QuestionType.TEXT_SELECTOR:
|
|
await CreateQuestion(channel, nextQuestion);
|
|
Database.SaveInterview(channel.Id, interviewRoot);
|
|
break;
|
|
case QuestionType.END_WITH_SUMMARY:
|
|
OrderedDictionary summaryFields = new OrderedDictionary();
|
|
interviewRoot.GetSummary(ref summaryFields);
|
|
|
|
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(nextQuestion.color),
|
|
Title = "Summary:", // TODO: Set title
|
|
Description = nextQuestion.message,
|
|
};
|
|
|
|
foreach (DictionaryEntry entry in summaryFields)
|
|
{
|
|
embed.AddField((string)entry.Key, (string)entry.Value);
|
|
}
|
|
|
|
await channel.SendMessageAsync(embed);
|
|
|
|
await DeletePreviousMessages(interviewRoot, channel);
|
|
if (!Database.TryDeleteInterview(channel.Id))
|
|
{
|
|
Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id);
|
|
}
|
|
Reload();
|
|
return;
|
|
case QuestionType.END_WITHOUT_SUMMARY:
|
|
// TODO: Add command to restart interview.
|
|
await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(nextQuestion.color),
|
|
Description = nextQuestion.message
|
|
});
|
|
|
|
await DeletePreviousMessages(interviewRoot, channel);
|
|
if (!Database.TryDeleteInterview(channel.Id))
|
|
{
|
|
Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id);
|
|
}
|
|
Reload();
|
|
break;
|
|
case QuestionType.ERROR:
|
|
default:
|
|
if (answerMessage == null)
|
|
{
|
|
DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(nextQuestion.color),
|
|
Description = nextQuestion.message
|
|
});
|
|
previousQuestion.AddRelatedMessageIDs(errorMessage.Id);
|
|
}
|
|
else
|
|
{
|
|
DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder()
|
|
.AddEmbed(new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(nextQuestion.color),
|
|
Description = nextQuestion.message
|
|
}).WithReply(answerMessage.Id);
|
|
DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
|
|
previousQuestion.AddRelatedMessageIDs(errorMessage.Id, answerMessage.Id);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static async Task DeletePreviousMessages(InterviewQuestion interviewRoot, DiscordChannel channel)
|
|
{
|
|
List<ulong> previousMessages = new List<ulong> { };
|
|
interviewRoot.GetMessageIDs(ref previousMessages);
|
|
|
|
foreach (ulong previousMessageID in previousMessages)
|
|
{
|
|
try
|
|
{
|
|
DiscordMessage previousMessage = await channel.GetMessageAsync(previousMessageID);
|
|
await channel.DeleteMessageAsync(previousMessage, "Deleting old interview message.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Logger.Warn("Failed to delete old interview message: ", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task CreateQuestion(DiscordChannel channel, InterviewQuestion question)
|
|
{
|
|
DiscordMessageBuilder msgBuilder = new DiscordMessageBuilder();
|
|
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(question.color),
|
|
Description = question.message
|
|
};
|
|
|
|
switch (question.type)
|
|
{
|
|
case QuestionType.BUTTONS:
|
|
int nrOfButtons = 0;
|
|
for (int nrOfButtonRows = 0; nrOfButtonRows < 5 && nrOfButtons < question.paths.Count; nrOfButtonRows++)
|
|
{
|
|
List<DiscordButtonComponent> buttonRow = [];
|
|
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++)
|
|
{
|
|
buttonRow.Add(new DiscordButtonComponent(DiscordButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key));
|
|
}
|
|
msgBuilder.AddComponents(buttonRow);
|
|
}
|
|
break;
|
|
case QuestionType.TEXT_SELECTOR:
|
|
List<DiscordSelectComponent> selectionComponents = [];
|
|
|
|
int selectionOptions = 0;
|
|
for (int selectionBoxes = 0; selectionBoxes < 5 && selectionOptions < question.paths.Count; selectionBoxes++)
|
|
{
|
|
List<DiscordSelectComponentOption> categoryOptions = [];
|
|
for (; selectionOptions < 25 * (selectionBoxes + 1) && selectionOptions < question.paths.Count; selectionOptions++)
|
|
{
|
|
categoryOptions.Add(new DiscordSelectComponentOption(question.paths.ToArray()[selectionOptions].Key, selectionOptions.ToString()));
|
|
}
|
|
selectionComponents.Add(new DiscordSelectComponent("supportchild_interviewselector " + selectionBoxes, "Select an option...", categoryOptions, false, 0, 1));
|
|
}
|
|
|
|
msgBuilder.AddComponents(selectionComponents);
|
|
break;
|
|
case QuestionType.TEXT_INPUT:
|
|
embed.WithFooter("Reply to this message with your answer. You cannot include images or files.");
|
|
break;
|
|
case QuestionType.END_WITH_SUMMARY:
|
|
case QuestionType.END_WITHOUT_SUMMARY:
|
|
case QuestionType.ERROR:
|
|
default:
|
|
break;
|
|
}
|
|
|
|
msgBuilder.AddEmbed(embed);
|
|
DiscordMessage message = await channel.SendMessageAsync(msgBuilder);
|
|
question.messageID = message.Id;
|
|
}
|
|
} |