From 61b57d0afcaf59616a2d629ebcfe960b0c820a85 Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Tue, 4 Feb 2025 16:32:21 +1300
Subject: [PATCH] Added step references, ability to jump to other steps
 depending on user actions

---
 Commands/InterviewCommands.cs             |   2 +-
 Commands/InterviewTemplateCommands.cs     |  23 ++-
 Database.cs                               |  39 ++--
 Interviews/Interview.cs                   | 121 +++++++++---
 Interviews/Interviewer.cs                 | 222 ++++++++++++++++------
 Interviews/interview_template.schema.json |  14 +-
 6 files changed, 315 insertions(+), 106 deletions(-)

diff --git a/Commands/InterviewCommands.cs b/Commands/InterviewCommands.cs
index 1629f77..b70491c 100644
--- a/Commands/InterviewCommands.cs
+++ b/Commands/InterviewCommands.cs
@@ -62,7 +62,7 @@ public class InterviewCommands
             return;
         }
 
-        if (!Database.TryGetInterview(command.Channel.Id, out InterviewStep interviewRoot))
+        if (!Database.TryGetInterview(command.Channel.Id, out Interview _))
         {
             await command.RespondAsync(new DiscordEmbedBuilder
             {
diff --git a/Commands/InterviewTemplateCommands.cs b/Commands/InterviewTemplateCommands.cs
index c3af250..7955d26 100644
--- a/Commands/InterviewTemplateCommands.cs
+++ b/Commands/InterviewTemplateCommands.cs
@@ -102,17 +102,34 @@ public class InterviewTemplateCommands
             JsonSerializer serializer = new();
             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)
             {
                 await command.RespondAsync(new DiscordEmbedBuilder
                 {
                     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);
                 return;
             }
 
+            //TODO: Validate that any references with reference end steps have an after reference step
+
             List<string> errors = [];
             List<string> warnings = [];
             template.interview.Validate(ref errors, ref warnings, "interview", 0, 0);
@@ -208,7 +225,7 @@ public class InterviewTemplateCommands
             return;
         }
 
-        if (!Database.TryGetInterviewTemplate(category.Id, out InterviewStep _))
+        if (!Database.TryGetInterviewFromTemplate(category.Id, 0, out Interview _))
         {
             await command.RespondAsync(new DiscordEmbedBuilder
             {
diff --git a/Database.cs b/Database.cs
index abfd4cd..76b2b36 100644
--- a/Database.cs
+++ b/Database.cs
@@ -113,7 +113,8 @@ public static class Database
         using MySqlCommand createInterviews = new MySqlCommand(
             "CREATE TABLE IF NOT EXISTS interviews(" +
             "channel_id BIGINT UNSIGNED NOT NULL PRIMARY KEY," +
-            "interview JSON NOT NULL)",
+            "interview JSON NOT NULL," +
+            "definitions JSON NOT NULL)",
             c);
         using MySqlCommand createInterviewTemplates = new MySqlCommand(
             "CREATE TABLE IF NOT EXISTS interview_templates(" +
@@ -753,7 +754,7 @@ public static class Database
         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();
         c.Open();
@@ -765,7 +766,7 @@ public static class Database
         // Check if messages exist in the database
         if (!results.Read())
         {
-            template = null;
+            interview = null;
             return false;
         }
 
@@ -774,7 +775,7 @@ public static class Database
 
         try
         {
-            template = JsonConvert.DeserializeObject<Interviews.Template>(templateString, new JsonSerializerSettings
+            Template template = JsonConvert.DeserializeObject<Template>(templateString, new JsonSerializerSettings
             {
                 Error = delegate (object sender, ErrorEventArgs args)
                 {
@@ -782,21 +783,22 @@ public static class Database
                     Logger.Debug("Detailed exception:", args.ErrorContext.Error);
                     args.ErrorContext.Handled = false;
                 }
-            }).interview;
+            });
+            interview = new Interview(channelID, template.interview, template.definitions);
             return true;
         }
         catch (Exception)
         {
-            template = null;
+            interview = null;
             return false;
         }
     }
 
-    public static bool SetInterviewTemplate(Interviews.Template template)
+    public static bool SetInterviewTemplate(Template template)
     {
         try
         {
-            string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings()
+            string templateString = JsonConvert.SerializeObject(template, new JsonSerializerSettings
             {
                 NullValueHandling = NullValueHandling.Ignore,
                 MissingMemberHandling = MissingMemberHandling.Error,
@@ -805,7 +807,7 @@ public static class Database
             });
 
             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";
             }
@@ -845,25 +847,26 @@ public static class Database
         }
     }
 
-    public static bool SaveInterview(ulong channelID, Interviews.InterviewStep interview)
+    public static bool SaveInterview(Interview interview)
     {
         try
         {
             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
             {
-                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();
             c.Open();
             using MySqlCommand cmd = new MySqlCommand(query, c);
-            cmd.Parameters.AddWithValue("@channel_id", channelID);
-            cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview, Formatting.Indented));
+            cmd.Parameters.AddWithValue("@channel_id", interview.channelID);
+            cmd.Parameters.AddWithValue("@interview", JsonConvert.SerializeObject(interview.interviewRoot, Formatting.Indented));
+            cmd.Parameters.AddWithValue("@definitions", JsonConvert.SerializeObject(interview.definitions, Formatting.Indented));
             cmd.Prepare();
             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();
         c.Open();
@@ -888,7 +891,9 @@ public static class Database
             interview = null;
             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();
         return true;
     }
diff --git a/Interviews/Interview.cs b/Interviews/Interview.cs
index 505e7c1..3b7d544 100644
--- a/Interviews/Interview.cs
+++ b/Interviews/Interview.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Collections.Specialized;
 using System.Linq;
 using System.Reflection;
+using System.Text.RegularExpressions;
 using DSharpPlus.Entities;
 using Newtonsoft.Json;
 using Newtonsoft.Json.Converters;
@@ -22,7 +23,8 @@ public enum MessageType
     ROLE_SELECTOR,
     MENTIONABLE_SELECTOR, // User or role
     CHANNEL_SELECTOR,
-    TEXT_INPUT
+    TEXT_INPUT,
+    REFERENCE_END
 }
 
 public enum ButtonType
@@ -33,6 +35,45 @@ public enum ButtonType
     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.
 // 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.
@@ -54,7 +95,7 @@ public class InterviewStep
 
     // Colour of the message embed.
     [JsonProperty("color")]
-    public string color;
+    public string color = "CYAN";
 
     // Used as label for this answer in the post-interview summary.
     [JsonProperty("summary-field")]
@@ -65,11 +106,11 @@ public class InterviewStep
     [JsonProperty("button-style")]
     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")]
     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")]
     public string selectorDescription;
 
@@ -81,6 +122,10 @@ public class InterviewStep
     [JsonProperty("min-length")]
     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.
     [JsonProperty("steps")]
     public Dictionary<string, InterviewStep> steps = new();
@@ -105,12 +150,23 @@ public class InterviewStep
     [JsonProperty("related-message-ids")]
     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.
         if (messageID == 0)
         {
-            step = null;
+            previousSteps = null;
             return false;
         }
 
@@ -118,14 +174,15 @@ public class InterviewStep
         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.
-            if (childStep.Value.TryGetCurrentStep(out step))
+            if (childStep.Value.TryGetTakenSteps(out previousSteps))
             {
+                previousSteps.Add(this);
                 return true;
             }
         }
 
         // 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;
     }
 
@@ -138,7 +195,8 @@ public class InterviewStep
 
         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.
@@ -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,
                          ref List<string> warnings,
                          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 (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");
             }
@@ -247,7 +293,7 @@ public class InterviewStep
                 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");
         }
@@ -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
     {
         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)]
     public ulong categoryID = categoryID;
 
     [JsonProperty("interview", Required = Required.Always)]
     public InterviewStep interview = interview;
+
+    [JsonProperty("definitions", Required = Required.Default)]
+    public Dictionary<string, InterviewStep> definitions = definitions;
 }
\ No newline at end of file
diff --git a/Interviews/Interviewer.cs b/Interviews/Interviewer.cs
index fb5ed0b..3b32630 100644
--- a/Interviews/Interviewer.cs
+++ b/Interviews/Interviewer.cs
@@ -14,22 +14,22 @@ public static class Interviewer
 {
     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;
         }
 
-        await SendNextMessage(channel, template);
-        return Database.SaveInterview(channel.Id, template);
+        await SendNextMessage(channel, interview.interviewRoot);
+        return Database.SaveInterview(interview);
     }
 
     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)
             {
-                await DeletePreviousMessages(interviewRoot, channel);
+                await DeletePreviousMessages(interview, channel);
             }
 
             if (!Database.TryDeleteInterview(channel.Id))
@@ -43,11 +43,11 @@ public static class Interviewer
 
     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)
             {
-                await DeletePreviousMessages(interviewRoot, channel);
+                await DeletePreviousMessages(interview, channel);
             }
 
             if (!Database.TryDeleteInterview(channel.Id))
@@ -61,7 +61,7 @@ public static class Interviewer
 
     public static async Task ProcessButtonOrSelectorResponse(DiscordInteraction interaction)
     {
-        if (interaction?.Channel == null || interaction?.Message == null)
+        if (interaction?.Channel == null || interaction.Message == null)
         {
             return;
         }
@@ -74,7 +74,7 @@ public static class Interviewer
         }
 
         // 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()
                 .AddEmbed(new DiscordEmbedBuilder()
@@ -85,7 +85,7 @@ public static class Interviewer
         }
 
         // 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()
                 .AddEmbed(new DiscordEmbedBuilder()
@@ -126,19 +126,19 @@ public static class Interviewer
             case DiscordComponentType.RoleSelect:
             case DiscordComponentType.ChannelSelect:
             case DiscordComponentType.MentionableSelect:
-                if (interaction.Data.Resolved?.Roles?.Any() ?? false)
+                if (interaction.Data.Resolved.Roles.Any())
                 {
                     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;
                 }
-                else if (interaction.Data.Resolved?.Channels?.Any() ?? false)
+                else if (interaction.Data.Resolved.Channels.Any())
                 {
                     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();
                 }
@@ -147,7 +147,7 @@ public static class Interviewer
                 componentID = interaction.Data.Values[0];
                 break;
             case DiscordComponentType.Button:
-                componentID = interaction.Data.CustomId.Replace("supportchild_interviewbutton ", "");
+                componentID = interaction.Data.CustomId.Replace("supportboi_interviewbutton ", "");
                 break;
             case DiscordComponentType.ActionRow:
             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.
         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)
             {
                 // Skip to the first matching step.
                 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;
                 }
             }
@@ -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."
             }).AsEphemeral());
             currentStep.AddRelatedMessageIDs(followupMessage.Id);
-            Database.SaveInterview(interaction.Channel.Id, interviewRoot);
+            Database.SaveInterview(interview);
         }
         else
         {
@@ -192,10 +207,31 @@ public static class Interviewer
             }
 
             (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)
     {
         // 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.
-        if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id, out InterviewStep interviewRoot))
+        if (!Database.TryGetInterview(answerMessage.ReferencedMessage.Channel.Id,
+                                      out Interview interview))
         {
             return;
         }
 
-        if (!interviewRoot.TryGetCurrentStep(out InterviewStep currentStep))
+        if (!interview.interviewRoot.TryGetCurrentStep(out InterviewStep currentStep))
         {
             return;
         }
@@ -238,7 +275,7 @@ public static class Interviewer
                 Color = DiscordColor.Red
             });
             currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
-            Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
+            Database.SaveInterview(interview);
             return;
         }
 
@@ -250,7 +287,7 @@ public static class Interviewer
                 Color = DiscordColor.Red
             });
             currentStep.AddRelatedMessageIDs(answerMessage.Id, lengthMessage.Id);
-            Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
+            Database.SaveInterview(interview);
             return;
         }
 
@@ -262,7 +299,7 @@ public static class Interviewer
                 continue;
             }
 
-            await HandleAnswer(answerMessage.Content, nextStep, interviewRoot, currentStep, answerMessage.Channel, answerMessage);
+            await HandleAnswer(answerMessage.Content, nextStep, interview, currentStep, answerMessage.Channel, answerMessage);
             return;
         }
 
@@ -273,12 +310,12 @@ public static class Interviewer
             Color = DiscordColor.Red
         });
         currentStep.AddRelatedMessageIDs(answerMessage.Id, errorMessage.Id);
-        Database.SaveInterview(answerMessage.Channel.Id, interviewRoot);
+        Database.SaveInterview(interview);
     }
 
     private static async Task HandleAnswer(string answer,
                                            InterviewStep nextStep,
-                                           InterviewStep interviewRoot,
+                                           Interview interview,
                                            InterviewStep previousStep,
                                            DiscordChannel channel,
                                            DiscordMessage answerMessage = null)
@@ -302,14 +339,37 @@ public static class Interviewer
             case MessageType.USER_SELECTOR:
             case MessageType.CHANNEL_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);
-                Database.SaveInterview(channel.Id, interviewRoot);
+                Database.SaveInterview(interview);
                 break;
             case MessageType.END_WITH_SUMMARY:
                 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),
                     Title = nextStep.heading,
@@ -318,14 +378,14 @@ public static class Interviewer
 
                 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);
 
                 if (Config.deleteMessagesAfterSummary)
                 {
-                    await DeletePreviousMessages(interviewRoot, channel);
+                    await DeletePreviousMessages(interview, channel);
                 }
 
                 if (!Database.TryDeleteInterview(channel.Id))
@@ -334,7 +394,7 @@ public static class Interviewer
                 }
                 return;
             case MessageType.END_WITHOUT_SUMMARY:
-                await channel.SendMessageAsync(new DiscordEmbedBuilder()
+                await channel.SendMessageAsync(new DiscordEmbedBuilder
                 {
                     Color = Utilities.StringToColor(nextStep.color),
                     Title = nextStep.heading,
@@ -343,7 +403,7 @@ public static class Interviewer
 
                 if (Config.deleteMessagesAfterNoSummary)
                 {
-                    await DeletePreviousMessages(interviewRoot, channel);
+                    await DeletePreviousMessages(interview, channel);
                 }
 
                 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);
                 }
                 break;
-            case MessageType.ERROR:
-            default:
+            case MessageType.REFERENCE_END:
+                // 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)
                 {
-                    DiscordMessage errorMessage = await channel.SendMessageAsync(new DiscordEmbedBuilder()
-                    {
-                        Color = Utilities.StringToColor(nextStep.color),
-                        Title = nextStep.heading,
-                        Description = nextStep.message
-                    });
+                    DiscordMessage errorMessage = await channel.SendMessageAsync(error);
                     previousStep.AddRelatedMessageIDs(errorMessage.Id);
                 }
                 else
                 {
-                    DiscordMessageBuilder errorMessageBuilder = new DiscordMessageBuilder()
-                        .AddEmbed(new DiscordEmbedBuilder()
-                        {
-                            Color = Utilities.StringToColor(nextStep.color),
-                            Title = nextStep.heading,
-                            Description = nextStep.message
-                        }).WithReply(answerMessage.Id);
-                    DiscordMessage errorMessage = await answerMessage.RespondAsync(errorMessageBuilder);
+                    DiscordMessage errorMessage = await answerMessage.RespondAsync(error);
                     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;
         }
     }
 
-    private static async Task DeletePreviousMessages(InterviewStep interviewRoot, DiscordChannel channel)
+    private static async Task DeletePreviousMessages(Interview interview, DiscordChannel channel)
     {
         List<ulong> previousMessages = [];
-        interviewRoot.GetMessageIDs(ref previousMessages);
+        interview.interviewRoot.GetMessageIDs(ref previousMessages);
 
         foreach (ulong previousMessageID in previousMessages)
         {
@@ -420,7 +528,7 @@ public static class Interviewer
                     for (; nrOfButtons < 5 * (nrOfButtonRows + 1) && nrOfButtons < step.steps.Count; 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);
                 }
@@ -438,26 +546,26 @@ public static class Interviewer
                         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));
                 }
 
                 msgBuilder.AddComponents(selectionComponents);
                 break;
             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));
                 break;
             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));
                 break;
             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));
                 break;
             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));
                 break;
             case MessageType.TEXT_INPUT:
diff --git a/Interviews/interview_template.schema.json b/Interviews/interview_template.schema.json
index baad265..343ce82 100644
--- a/Interviews/interview_template.schema.json
+++ b/Interviews/interview_template.schema.json
@@ -6,7 +6,7 @@
   "definitions": {
     "reference": {
       "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.",
       "properties": {
         "id": {
@@ -31,6 +31,12 @@
           "title": "Selector Description",
           "description": "Description for this option in the parent step's selection box. Requires that the parent step is a 'TEXT_SELECTOR'.",
           "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": [
@@ -69,7 +75,8 @@
             "ROLE_SELECTOR",
             "MENTIONABLE_SELECTOR",
             "CHANNEL_SELECTOR",
-            "TEXT_INPUT"
+            "TEXT_INPUT",
+            "REFERENCE_END"
           ]
         },
         "color": {
@@ -186,8 +193,7 @@
       },
       "required": [
         "message",
-        "message-type",
-        "color"
+        "message-type"
       ],
       "unevaluatedProperties": false
     }