SupportChild/Interviewer.cs

492 lines
18 KiB
C#
Raw Normal View History

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
2024-12-26 04:55:00 +00:00
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
2024-12-26 04:55:00 +00:00
using DSharpPlus.Entities;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
2024-12-26 04:55:00 +00:00
namespace SupportChild;
public static class Interviewer
{
2024-12-26 07:16:18 +00:00
// TODO: Investigate other types of selectors
2024-12-26 04:55:00 +00:00
public enum QuestionType
{
ERROR,
END_WITH_SUMMARY,
END_WITHOUT_SUMMARY,
2024-12-26 04:55:00 +00:00
BUTTONS,
2024-12-26 07:16:18 +00:00
TEXT_SELECTOR,
2024-12-26 04:55:00 +00:00
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
2024-12-26 04:55:00 +00:00
{
// TODO: Optional properties: embed title, button style
2024-12-26 04:55:00 +00:00
// Message contents sent to the user.
[JsonProperty("message")]
2024-12-26 04:55:00 +00:00
public string message;
// The type of question.
[JsonConverter(typeof(StringEnumConverter))]
[JsonProperty("type")]
2024-12-26 04:55:00 +00:00
public QuestionType type;
// Colour of the message embed.
[JsonProperty("color")]
2024-12-26 04:55:00 +00:00
public string color;
// Used as label for this question in the post-interview summary.
[JsonProperty("summary-field")]
2024-12-26 04:55:00 +00:00
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")]
2024-12-26 04:55:00 +00:00
public string answer;
// The ID of the user's answer message if this is a TEXT_INPUT type.
[JsonProperty("answer-id")]
2024-12-26 04:55:00 +00:00
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);
}
}
2024-12-26 04:55:00 +00:00
}
// 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 = [];
2024-12-26 04:55:00 +00:00
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)
2024-12-26 04:55:00 +00:00
{
if (channel.Parent == null)
{
return;
}
if (interviewTemplates.TryGetValue(channel.Parent.Id, out InterviewQuestion interview))
{
await CreateQuestion(channel, interview);
Database.SaveInterview(channel.Id, interview);
Reload();
}
2024-12-26 04:55:00 +00:00
}
2024-12-26 07:16:18 +00:00
public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction)
2024-12-26 04:55:00 +00:00
{
// TODO: Add error responses.
if (interaction?.Channel == null || interaction?.Message == null)
2024-12-26 04:55:00 +00:00
{
return;
}
// Return if the user didn't select anything
2024-12-26 07:16:18 +00:00
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))
2024-12-26 04:55:00 +00:00
{
return;
2024-12-26 04:55:00 +00:00
}
// 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.
2024-12-26 07:16:18 +00:00
string componentID = "";
switch (interaction.Data.ComponentType)
{
2024-12-26 07:16:18 +00:00
case DiscordComponentType.StringSelect:
componentID = interaction.Data.Values[0];
break;
case DiscordComponentType.Button:
componentID = interaction.Data.CustomId.Replace("supportchild_interviewbutton ", "");
break;
default:
throw new ArgumentOutOfRangeException();
}
2024-12-26 07:16:18 +00:00
if (!int.TryParse(componentID, out int pathIndex))
{
2024-12-26 07:16:18 +00:00
Logger.Error("Invalid interview button/selector index: " + componentID);
return;
}
2024-12-26 07:16:18 +00:00
if (pathIndex >= currentQuestion.paths.Count || pathIndex < 0)
{
2024-12-26 07:16:18 +00:00
Logger.Error("Invalid interview button/selector index: " + pathIndex);
return;
}
2024-12-26 07:16:18 +00:00
(string questionString, InterviewQuestion nextQuestion) = currentQuestion.paths.ElementAt(pathIndex);
2024-12-26 07:16:18 +00:00
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, interaction.Channel);
2024-12-26 04:55:00 +00:00
}
public static async Task ProcessResponseMessage(DiscordMessage message)
2024-12-26 04:55:00 +00:00
{
// 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;
2024-12-26 07:16:18 +00:00
await HandleAnswer(questionString, nextQuestion, interviewRoot, currentQuestion, message.Channel, message);
return;
}
2024-12-26 07:16:18 +00:00
// TODO: No matching path found.
2024-12-26 07:16:18 +00:00
}
2024-12-26 07:16:18 +00:00
private static async Task HandleAnswer(string questionString,
InterviewQuestion nextQuestion,
InterviewQuestion interviewRoot,
InterviewQuestion previousQuestion,
DiscordChannel channel,
DiscordMessage answerMessage = null)
2024-12-26 07:16:18 +00:00
{
if (nextQuestion.type != QuestionType.ERROR)
2024-12-26 07:16:18 +00:00
{
// 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 }
};
}
2024-12-26 07:16:18 +00:00
// 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:
2024-12-26 07:16:18 +00:00
OrderedDictionary summaryFields = new OrderedDictionary();
interviewRoot.GetSummary(ref summaryFields);
2024-12-26 07:16:18 +00:00
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
{
Color = Utilities.StringToColor(nextQuestion.color),
Title = "Summary:", // TODO: Set title
2024-12-26 07:16:18 +00:00
Description = nextQuestion.message,
};
2024-12-26 07:16:18 +00:00
foreach (DictionaryEntry entry in summaryFields)
{
embed.AddField((string)entry.Key, (string)entry.Value);
}
2024-12-26 07:16:18 +00:00
await channel.SendMessageAsync(embed);
await DeletePreviousMessages(interviewRoot, channel);
if (!Database.TryDeleteInterview(channel.Id))
2024-12-26 07:16:18 +00:00
{
Logger.Error("Could not delete interview from database. Channel ID: " + channel.Id);
2024-12-26 07:16:18 +00:00
}
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);
2024-12-26 07:16:18 +00:00
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);
}
2024-12-26 07:16:18 +00:00
break;
}
2024-12-26 04:55:00 +00:00
}
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)
2024-12-26 04:55:00 +00:00
{
DiscordMessageBuilder msgBuilder = new DiscordMessageBuilder();
DiscordEmbedBuilder embed = new DiscordEmbedBuilder()
2024-12-26 04:55:00 +00:00
{
Color = Utilities.StringToColor(question.color),
Description = question.message
2024-12-26 04:55:00 +00:00
};
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));
2024-12-26 04:55:00 +00:00
}
msgBuilder.AddComponents(buttonRow);
}
break;
2024-12-26 07:16:18 +00:00
case QuestionType.TEXT_SELECTOR:
2024-12-26 04:55:00 +00:00
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));
2024-12-26 04:55:00 +00:00
}
msgBuilder.AddComponents(selectionComponents);
break;
case QuestionType.TEXT_INPUT:
embed.WithFooter("Reply to this message with your answer. You cannot include images or files.");
2024-12-26 04:55:00 +00:00
break;
case QuestionType.END_WITH_SUMMARY:
case QuestionType.END_WITHOUT_SUMMARY:
2024-12-26 07:18:07 +00:00
case QuestionType.ERROR:
2024-12-26 04:55:00 +00:00
default:
break;
}
msgBuilder.AddEmbed(embed);
2024-12-26 04:55:00 +00:00
DiscordMessage message = await channel.SendMessageAsync(msgBuilder);
question.messageID = message.Id;
}
}