Add commands to update interview templates

This commit is contained in:
Toastie 2024-12-26 19:50:47 +13:00
parent e87208d8bd
commit d212f13b12
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
8 changed files with 250 additions and 61 deletions

View file

@ -1,7 +1,11 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
using DSharpPlus.Commands;
using DSharpPlus.Commands.ContextChecks;
@ -10,6 +14,10 @@ using DSharpPlus.Entities;
using DSharpPlus.Exceptions;
using DSharpPlus.Interactivity;
using DSharpPlus.Interactivity.Extensions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema;
using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
namespace SupportChild.Commands;
@ -215,4 +223,98 @@ public class AdminCommands
Logger.Log("Reloading bot...");
SupportChild.Reload();
}
[Command("getinterviewtemplates")]
[Description("Provides a copy of the interview templates which you can edit and then reupload.")]
public async Task GetInterviewTemplates(SlashCommandContext command)
{
MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(Database.GetInterviewTemplates()));
await command.RespondAsync(new DiscordInteractionResponseBuilder().AddFile("interview-templates.json", stream));
}
[Command("setinterviewtemplates")]
[Description("Uploads an interview template file.")]
public async Task SetInterviewTemplates(SlashCommandContext command, [Parameter("file")] DiscordAttachment file)
{
if (!file.MediaType?.Contains("application/json") ?? false)
{
await command.RespondAsync(new DiscordEmbedBuilder
{
Color = DiscordColor.Red,
Description = "The uploaded file is not a JSON file according to Discord."
});
return;
}
Stream stream = await new HttpClient().GetStreamAsync(file.Url);
string json = await new StreamReader(stream).ReadToEndAsync();
try
{
List<string> errors = [];
// Convert it to an interview object to validate the template
Dictionary<ulong, Interviewer.ValidatedInterviewQuestion> interview = JsonConvert.DeserializeObject<Dictionary<ulong, Interviewer.ValidatedInterviewQuestion>>(json, new JsonSerializerSettings()
{
//NullValueHandling = NullValueHandling.Include,
MissingMemberHandling = MissingMemberHandling.Error,
Error = delegate (object sender, ErrorEventArgs args)
{
// I noticed the main exception mainly has information for developers, not administrators,
// so I switched to using the inner message if available.
if (string.IsNullOrEmpty(args.ErrorContext.Error.InnerException?.Message))
{
errors.Add(args.ErrorContext.Error.Message);
}
else
{
errors.Add(args.ErrorContext.Error.InnerException.Message);
}
Logger.Debug("Exception occured when trying to upload interview template:\n" + args.ErrorContext.Error);
args.ErrorContext.Handled = true;
}
});
if (errors.Count != 0)
{
string errorString = string.Join("\n\n", errors);
if (errorString.Length > 1500)
{
errorString = errorString.Substring(0, 1500);
}
await command.RespondAsync(new DiscordEmbedBuilder
{
Color = DiscordColor.Red,
Description = "The uploaded JSON structure could not be converted to an interview template.\n\nErrors:\n```\n" + errorString + "\n```",
Footer = new DiscordEmbedBuilder.EmbedFooter()
{
Text = "More detailed information may be available as debug messages in the bot logs."
}
});
return;
}
Database.SetInterviewTemplates(JsonConvert.SerializeObject(interview, Formatting.Indented));
}
catch (Exception e)
{
await command.RespondAsync(new DiscordEmbedBuilder
{
Color = DiscordColor.Red,
Description = "The uploaded JSON structure could not be converted to an interview template.\n\nError message:\n```\n" + e.Message + "\n```"
});
return;
}
await command.RespondAsync(new DiscordEmbedBuilder
{
Color = DiscordColor.Green,
Description = "Uploaded interview template."
});
}
}

View file

@ -32,8 +32,6 @@ internal static class Config
internal static string username = "supportchild";
internal static string password = "";
internal static JToken interviews;
private static string configPath = "./config.yml";
public static void LoadConfig()
@ -102,8 +100,5 @@ internal static class Config
database = json.SelectToken("database.name")?.Value<string>() ?? "supportchild";
username = json.SelectToken("database.user")?.Value<string>() ?? "supportchild";
password = json.SelectToken("database.password")?.Value<string>() ?? "";
// Set up interviewer
interviews = json.SelectToken("interviews") ?? new JObject();
}
}

View file

@ -731,6 +731,52 @@ public static class Database
}
}
public static string GetInterviewTemplates()
{
using MySqlConnection c = GetConnection();
c.Open();
using MySqlCommand selection = new MySqlCommand("SELECT * FROM interviews WHERE channel_id=0", c);
selection.Prepare();
MySqlDataReader results = selection.ExecuteReader();
// Check if messages exist in the database
if (!results.Read())
{
return "{}";
}
string templates = results.GetString("interview");
results.Close();
return templates;
}
public static bool SetInterviewTemplates(string templates)
{
try
{
string query;
if (TryGetInterview(0, out _))
{
query = "UPDATE interviews SET interview = @interview WHERE channel_id = 0";
}
else
{
query = "INSERT INTO interviews (channel_id,interview) VALUES (0, @interview)";
}
using MySqlConnection c = GetConnection();
c.Open();
using MySqlCommand cmd = new MySqlCommand(query, c);
cmd.Parameters.AddWithValue("@interview", templates);
cmd.Prepare();
return cmd.ExecuteNonQuery() > 0;
}
catch (MySqlException)
{
return false;
}
}
public static Dictionary<ulong, Interviewer.InterviewQuestion> GetAllInterviews()
{
using MySqlConnection c = GetConnection();
@ -745,14 +791,16 @@ public static class Database
return new Dictionary<ulong, Interviewer.InterviewQuestion>();
}
Dictionary<ulong, Interviewer.InterviewQuestion> questions = new Dictionary<ulong, Interviewer.InterviewQuestion>
Dictionary<ulong, Interviewer.InterviewQuestion> questions = new();
do
{
{ results.GetUInt64("channel_id"), JsonConvert.DeserializeObject<Interviewer.InterviewQuestion>(results.GetString("interview")) }
};
while (results.Read())
{
questions.Add(results.GetUInt64("channel_id"), JsonConvert.DeserializeObject<Interviewer.InterviewQuestion>(results.GetString("interview")));
// Channel ID 0 is the interview template
if (results.GetUInt64("channel_id") != 0)
{
questions.Add(results.GetUInt64("channel_id"), JsonConvert.DeserializeObject<Interviewer.InterviewQuestion>(results.GetString("interview")));
}
}
while (results.Read());
results.Close();
return questions;
@ -762,26 +810,23 @@ public static class Database
{
try
{
string query;
if (TryGetInterview(channelID, out _))
{
using MySqlConnection c = GetConnection();
c.Open();
using MySqlCommand cmd = new MySqlCommand(@"UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id;", c);
cmd.Parameters.AddWithValue("@channel_id", channelID);
cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview));
cmd.Prepare();
return cmd.ExecuteNonQuery() > 0;
query = "UPDATE interviews SET interview = @interview WHERE channel_id = @channel_id";
}
else
{
using MySqlConnection c = GetConnection();
c.Open();
using MySqlCommand cmd = new MySqlCommand(@"INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview);", c);
cmd.Parameters.AddWithValue("@channel_id", channelID);
cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview));
cmd.Prepare();
return cmd.ExecuteNonQuery() > 0;
query = "INSERT INTO interviews (channel_id,interview) VALUES (@channel_id, @interview)";
}
using MySqlConnection c = GetConnection();
c.Open();
using MySqlCommand cmd = new MySqlCommand(query, c);
cmd.Parameters.AddWithValue("@channel_id", channelID);
cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview));
cmd.Prepare();
return cmd.ExecuteNonQuery() > 0;
}
catch (MySqlException)
{

View file

@ -1,9 +1,12 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using DSharpPlus;
using DSharpPlus.Entities;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace SupportChild;
@ -11,8 +14,8 @@ public static class Interviewer
{
public enum QuestionType
{
DONE,
FAIL,
DONE,
BUTTONS,
SELECTOR,
TEXT_INPUT
@ -25,37 +28,95 @@ public static class Interviewer
public class InterviewQuestion
{
// 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;
// The ID of this message, populated after it has been sent.
// The ID of this message where the bot asked this question,
// populated after it has been sent.
[JsonProperty("message-id")]
public ulong messageID;
// Used as label for this question in the post-interview summary.
[JsonProperty("summary-field")]
public string summaryField;
// The user's response to the question.
[JsonProperty("answer")]
public string answer;
// The ID of the user's answer message, populated after it has been received.
[JsonProperty("answer-id")]
public ulong answerID;
// Possible questions to ask next, or DONE/FAIL type in order to finish interview.
[JsonProperty("paths")]
public Dictionary<string, InterviewQuestion> paths;
}
// 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;
// The ID of this message where the bot asked this question,
// populated after it has been sent.
[JsonProperty("message-id", Required = Required.Default)]
public ulong messageID;
// Used as label for this question in the post-interview summary.
[JsonProperty("summary-field", Required = Required.Default)]
public string summaryField;
// The user's response to the question.
[JsonProperty("answer", Required = Required.Default)]
public string answer;
// The ID of the user's answer message, populated after it has been received.
[JsonProperty("answer-id", Required = Required.Default)]
public ulong answerID;
// 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> categoryInterviews = [];
private static Dictionary<ulong, InterviewQuestion> activeInterviews = [];
public static void ParseConfig(JToken interviewConfig)
public static void ParseTemplates(JToken interviewConfig)
{
categoryInterviews = JsonConvert.DeserializeObject<Dictionary<ulong, InterviewQuestion>>(interviewConfig.ToString());
categoryInterviews = JsonConvert.DeserializeObject<Dictionary<ulong, InterviewQuestion>>(interviewConfig.ToString(), new JsonSerializerSettings
{
Error = delegate (object sender, ErrorEventArgs args)
{
Logger.Error("Exception occured when trying to read interview from database:\n" + args.ErrorContext.Error.Message);
Logger.Debug("Detailed exception:", args.ErrorContext.Error);
args.ErrorContext.Handled = true;
}
});
}
public static void LoadActiveInterviews()
@ -107,7 +168,7 @@ public static class Interviewer
List<DiscordButtonComponent> buttonRow = [];
for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < question.paths.Count; nrOfButtons++)
{
buttonRow.Add(new DiscordButtonComponent(ButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key));
buttonRow.Add(new DiscordButtonComponent(DiscordButtonStyle.Primary, "supportchild_interviewbutton " + nrOfButtons, question.paths.ToArray()[nrOfButtons].Key));
}
msgBuilder.AddComponents(buttonRow);
}

View file

@ -93,7 +93,11 @@ internal static class SupportChild
Logger.Log("Starting " + Assembly.GetEntryAssembly()?.GetName().Name + " version " + GetVersion() + "...");
try
{
Reload();
if (!await Reload())
{
Logger.Fatal("Aborting startup due to a fatal error...");
return;
}
// Block this task until the program is closed.
await Task.Delay(-1);
@ -115,7 +119,7 @@ internal static class SupportChild
+ " (" + ThisAssembly.Git.Commit + ")";
}
public static async void Reload()
public static async Task<bool> Reload()
{
if (client != null)
{
@ -141,20 +145,20 @@ internal static class SupportChild
}
catch (Exception e)
{
Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: " + e);
throw;
Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: ", e);
return false;
}
try
{
Logger.Log("Connecting to database... (" + Config.hostName + ":" + Config.port + ")");
Interviewer.ParseConfig(Config.interviews);
Logger.Log("Loading interviews from database...");
Interviewer.ParseTemplates(Database.GetInterviewTemplates());
Interviewer.LoadActiveInterviews();
}
catch (Exception e)
{
Logger.Fatal("Could not set up database tables, please confirm connection settings, status of the server and permissions of MySQL user. Error: " + e);
throw;
Logger.Fatal("Could not load interviews from database. Error: ", e);
return false;
}
Logger.Log("Setting up Discord client...");
@ -233,6 +237,7 @@ internal static class SupportChild
Logger.Log("Connecting to Discord...");
await client.ConnectAsync();
return true;
}
}

View file

@ -42,11 +42,12 @@
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MySqlConnector" Version="2.3.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageReference Include="Polly" Version="8.4.0" />
<PackageReference Include="RazorBlade" Version="0.6.0" />
<PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="YamlDotNet" Version="16.1.0" />
<PackageReference Include="YamlDotNet" Version="16.1.3" />
<PackageReference Include="YoutubeExplode" Version="6.4.0" />
</ItemGroup>

View file

@ -66,7 +66,6 @@ public static class Utilities
return verifiedCategories;
}
public static string ReadManifestData(string embeddedFileName)
{
Assembly assembly = Assembly.GetExecutingAssembly();

View file

@ -19,7 +19,7 @@
# Whether staff members should be randomly assigned tickets when they are made. Individual staff members can opt out using the toggleactive command.
random-assignment: true
# If set to true the rasssign command will include staff members set as inactive if a specific role is specified in the command.
# If set to true the rassign command will include staff members set as inactive if a specific role is specified in the command.
# This can be useful if you have admins set as inactive to not automatically receive tickets and then have moderators elevate tickets when needed.
random-assign-role-override: true
@ -63,23 +63,4 @@ database:
# Username and password for authentication.
user: ""
password: ""
# TODO: May want to move interview entries to subkey to make room for additional interview settings
interviews:
000000000000000000:
message: "Are you appealing your own ban or on behalf of another user?"
type: "BUTTONS"
color: "CYAN"
paths:
- "My own ban": # TODO: Can I add button color support somehow?
text: "What is your user name?"
type: "TEXT_INPUT"
summary-field: "Username"
paths:
- ".*":
text: "Please write your appeal below, motivate why you think you should be unbanned."
- "Another user's ban":
text: "You can only appeal your own ban. Please close this ticket."
type: "FAIL"
color: "CYAN"
password: ""