451 lines
No EOL
16 KiB
C#
451 lines
No EOL
16 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Specialized;
|
|
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
|
|
{
|
|
public enum QuestionType
|
|
{
|
|
ERROR,
|
|
CANCEL,
|
|
DONE,
|
|
BUTTONS,
|
|
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;
|
|
|
|
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);
|
|
}
|
|
|
|
// This will always contain exactly one or zero children.
|
|
foreach (KeyValuePair<string, InterviewQuestion> path in paths)
|
|
{
|
|
path.Value.GetMessageIDs(ref 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 bool IsInterviewActive(ulong channelID)
|
|
{
|
|
return activeInterviews.ContainsKey(channelID);
|
|
}
|
|
|
|
// TODO: Add selection box handling.
|
|
|
|
public static async Task ProcessButtonResponse(DiscordInteraction interaction)
|
|
{
|
|
// TODO: Add error responses.
|
|
|
|
if (interaction?.Channel == null || interaction?.Message == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await interaction.CreateResponseAsync(DiscordInteractionResponseType.DeferredMessageUpdate);
|
|
|
|
// Could not find active interview.
|
|
if (!activeInterviews.TryGetValue(interaction.Channel.Id, out InterviewQuestion interviewRoot))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Could not find message id in interview.
|
|
if (!interviewRoot.TryGetCurrentQuestion(out InterviewQuestion currentQuestion))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// This button is for an older question.
|
|
if (interaction.Message.Id != currentQuestion.messageID)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Parse the response index from the button.
|
|
if (!int.TryParse(interaction.Data.CustomId.Replace("supportchild_interviewbutton ", ""), out int pathIndex))
|
|
{
|
|
Logger.Error("Invalid interview button index: " + interaction.Data.CustomId.Replace("supportchild_interviewbutton ", ""));
|
|
return;
|
|
}
|
|
|
|
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0)
|
|
{
|
|
Logger.Error("Invalid interview button index: " + pathIndex);
|
|
return;
|
|
}
|
|
|
|
KeyValuePair<string, InterviewQuestion> questionPath = currentQuestion.paths.ElementAt(pathIndex);
|
|
|
|
|
|
currentQuestion.answer = interaction.Data.Name;
|
|
currentQuestion.answerID = 0;
|
|
|
|
// Create next question, or finish the interview.
|
|
InterviewQuestion nextQuestion = questionPath.Value;
|
|
switch (questionPath.Value.type)
|
|
{
|
|
case QuestionType.TEXT_INPUT:
|
|
await CreateQuestion(interaction.Channel, nextQuestion);
|
|
break;
|
|
case QuestionType.BUTTONS:
|
|
await CreateQuestion(interaction.Channel, nextQuestion);
|
|
// TODO: Remove buttons
|
|
break;
|
|
case QuestionType.SELECTOR:
|
|
await CreateQuestion(interaction.Channel, nextQuestion);
|
|
// TODO: Remove selector
|
|
break;
|
|
case QuestionType.DONE:
|
|
// TODO: Create summary.
|
|
// TODO: Remove previous interview messages.
|
|
// TODO: Remove active interview.
|
|
Logger.Error("INTERVIEW DONE");
|
|
break;
|
|
case QuestionType.CANCEL:
|
|
default:
|
|
// TODO: Post fail message.
|
|
// TODO: Remove active interview.
|
|
Logger.Error("INTERVIEW FAILED");
|
|
break;
|
|
}
|
|
|
|
// Remove other paths.
|
|
currentQuestion.paths = new Dictionary<string, InterviewQuestion>
|
|
{
|
|
{ questionPath.Key, nextQuestion }
|
|
};
|
|
|
|
await interaction.EditOriginalResponseAsync(new DiscordWebhookBuilder().AddEmbed(interaction.Message.Embeds[0]));
|
|
|
|
Database.SaveInterview(interaction.Channel.Id, interviewRoot);
|
|
}
|
|
|
|
public static async Task ProcessResponseMessage(DiscordMessage message)
|
|
{
|
|
// TODO: Handle other interactions like button presses.
|
|
// TODO: Handle FAIL event, cancelling the interview.
|
|
// TODO: Handle DONE event, creating a summary.
|
|
|
|
// 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;
|
|
|
|
// TODO: Refactor this into separate function to reduce duplication
|
|
|
|
currentQuestion.answer = message.Content;
|
|
currentQuestion.answerID = message.Id;
|
|
|
|
// Create next question, or finish the interview.
|
|
switch (nextQuestion.type)
|
|
{
|
|
case QuestionType.ERROR:
|
|
break;
|
|
case QuestionType.TEXT_INPUT:
|
|
case QuestionType.BUTTONS:
|
|
case QuestionType.SELECTOR:
|
|
await CreateQuestion(message.Channel, nextQuestion);
|
|
|
|
// Remove other paths.
|
|
currentQuestion.paths = new Dictionary<string, InterviewQuestion>
|
|
{
|
|
{ questionString, nextQuestion }
|
|
};
|
|
|
|
Database.SaveInterview(message.Channel.Id, interviewRoot);
|
|
break;
|
|
case QuestionType.DONE:
|
|
// TODO: Remove previous interview messages.
|
|
OrderedDictionary summaryFields = new OrderedDictionary();
|
|
interviewRoot.GetSummary(ref summaryFields);
|
|
|
|
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
|
|
{
|
|
Color = Utilities.StringToColor(nextQuestion.color),
|
|
Title = "Summary:",
|
|
Description = nextQuestion.message,
|
|
};
|
|
|
|
foreach (DictionaryEntry entry in summaryFields)
|
|
{
|
|
embed.AddField((string)entry.Key, (string)entry.Value);
|
|
}
|
|
|
|
await message.Channel.SendMessageAsync(embed);
|
|
|
|
List<ulong> previousMessages = new List<ulong> { };
|
|
interviewRoot.GetMessageIDs(ref previousMessages);
|
|
|
|
foreach (ulong previousMessageID in previousMessages)
|
|
{
|
|
try
|
|
{
|
|
Logger.Debug("Deleting message: " + previousMessageID);
|
|
DiscordMessage previousMessage = await message.Channel.GetMessageAsync(previousMessageID);
|
|
await message.Channel.DeleteMessageAsync(previousMessage, "Deleting old interview message.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Logger.Error("Failed to delete old interview message: " + e.Message);
|
|
}
|
|
|
|
}
|
|
|
|
if (!Database.TryDeleteInterview(message.Channel.Id))
|
|
{
|
|
Logger.Error("Could not delete interview from database. Channel ID: " + message.Channel.Id);
|
|
}
|
|
Reload();
|
|
return;
|
|
case QuestionType.CANCEL:
|
|
default:
|
|
// TODO: Post fail message.
|
|
// TODO: Remove active interview.
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// TODO: No matching path found.
|
|
|
|
}
|
|
|
|
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.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.DONE:
|
|
case QuestionType.CANCEL:
|
|
default:
|
|
break;
|
|
}
|
|
|
|
msgBuilder.AddEmbed(embed);
|
|
DiscordMessage message = await channel.SendMessageAsync(msgBuilder);
|
|
question.messageID = message.Id;
|
|
}
|
|
|
|
public static void CreateSummary()
|
|
{
|
|
|
|
}
|
|
} |