diff --git a/Modules/HelpModule.cs b/Modules/HelpModule.cs index d193ac5..0ac8e63 100644 --- a/Modules/HelpModule.cs +++ b/Modules/HelpModule.cs @@ -8,6 +8,7 @@ using Morpheus.Extensions; using Morpheus.Handlers; using Morpheus.Utilities; +using System.Globalization; using System.Reflection; namespace Morpheus.Modules; @@ -44,7 +45,7 @@ private async Task HandleHelpSelectorInteraction(SocketInteraction interaction) Guild? guild = await dbContext.Guilds.FirstOrDefaultAsync(g => g.DiscordId == interaction.GuildId); string commandPrefix = guild?.Prefix ?? Env.Variables["BOT_DEFAULT_COMMAND_PREFIX"]; - string selectedModule = messageComponent.Data.Values.First(); + string? selectedModule = messageComponent.Data.Values.FirstOrDefault(); Embed embed = CreateModuleHelpEmbed(selectedModule, commandPrefix); // Fetch the original message using message ID @@ -57,13 +58,57 @@ private async Task HandleHelpSelectorInteraction(SocketInteraction interaction) } } - private Embed CreateModuleHelpEmbed(string moduleName, string commandPrefix) + internal static bool TryParseHelpModuleName(string? moduleName, out int page, out string moduleKey) { + page = 0; + moduleKey = string.Empty; + + if (string.IsNullOrEmpty(moduleName)) + return false; + + int separatorIndex = moduleName.IndexOf('_'); + if (separatorIndex <= 0 || separatorIndex == moduleName.Length - 1) + return false; + + if (!int.TryParse(moduleName[..separatorIndex], NumberStyles.None, CultureInfo.InvariantCulture, out page) || page < 1) + return false; + + moduleKey = moduleName[(separatorIndex + 1)..]; + return moduleKey.IndexOf('_') < 0; + } + + internal static bool TryGetHelpPageBounds(int page, int visibleCommandCount, out int startIndex, out int endIndex) + { + startIndex = 0; + endIndex = 0; + + if (page < 1 || visibleCommandCount < 1) + return false; + + long start = ((long)page - 1) * HelpPageSize; + if (start >= visibleCommandCount) + return false; + + startIndex = (int)start; + endIndex = (int)Math.Min(start + HelpPageSize, visibleCommandCount); + return true; + } + + private static Embed CreateInvalidHelpEmbed() => new EmbedBuilder() + { + Color = Colors.Blue, + Title = "Unable to load help", + Description = "The selected help page is invalid. Please run the help command again." + }.Build(); + + private Embed CreateModuleHelpEmbed(string? moduleName, string commandPrefix) + { + if (!TryParseHelpModuleName(moduleName, out int page, out string moduleKey)) + return CreateInvalidHelpEmbed(); + if (helpModules.TryGetValue(commandPrefix + moduleName, out Embed? embed)) return embed; - int page = int.Parse(moduleName.Split("_")[0]); - string moduleKey = moduleName.Split("_")[1]; string name = moduleKey.Replace("Module", ""); EmbedBuilder builder = new() @@ -77,34 +122,37 @@ private Embed CreateModuleHelpEmbed(string moduleName, string commandPrefix) string.Equals(m.Name, moduleKey, StringComparison.OrdinalIgnoreCase) || string.Equals(m.Name, moduleKey + "Module", StringComparison.OrdinalIgnoreCase)); - if (module != null) - { - var visibleCommands = module.Commands.Where(c => !c.Attributes.OfType().Any()).ToList(); - for (int i = (page - 1) * HelpPageSize; i < page * HelpPageSize && i < visibleCommands.Count; i++) - { - CommandInfo cmd = visibleCommands[i]; + if (module == null) + return CreateInvalidHelpEmbed(); - string aliases = cmd.Aliases.Count > 1 - ? $"Aliases: {string.Join(", ", cmd.Aliases.Skip(1).Select(a => commandPrefix + a))}" - : "No aliases available."; + var visibleCommands = module.Commands.Where(c => !c.Attributes.OfType().Any()).ToList(); + if (!TryGetHelpPageBounds(page, visibleCommands.Count, out int startIndex, out int endIndex)) + return CreateInvalidHelpEmbed(); - string commandDescription = cmd.Summary ?? "No description available."; - if (commandDescription.Length > CommandDescriptionMaxLength) - { - commandDescription = commandDescription.Substring(0, CommandDescriptionMaxLength).TrimEnd() + "…"; - } + for (int i = startIndex; i < endIndex; i++) + { + CommandInfo cmd = visibleCommands[i]; - string commandUsage = cmd.Parameters.Count > 0 && cmd.Parameters.Any(p => p.Name != "_") - ? $"Usage: `{commandPrefix}{cmd.Aliases[0]} {string.Join(" ", cmd.Parameters.Select(p => $"[{p.Name}{(p.IsOptional ? "?" : "")}{(!string.IsNullOrEmpty(p.DefaultValue?.ToString()) ? " = " + p.DefaultValue.ToString() : "")}]"))}`" - : $"Usage: `{commandPrefix}{cmd.Aliases[0]}`"; + string aliases = cmd.Aliases.Count > 1 + ? $"Aliases: {string.Join(", ", cmd.Aliases.Skip(1).Select(a => commandPrefix + a))}" + : "No aliases available."; - builder.AddField(x => - { - x.Name = cmd.Name; - x.Value = $"{commandDescription}\n{commandUsage}\n{aliases}"; - x.IsInline = false; - }); + string commandDescription = cmd.Summary ?? "No description available."; + if (commandDescription.Length > CommandDescriptionMaxLength) + { + commandDescription = commandDescription.Substring(0, CommandDescriptionMaxLength).TrimEnd() + "…"; } + + string commandUsage = cmd.Parameters.Count > 0 && cmd.Parameters.Any(p => p.Name != "_") + ? $"Usage: `{commandPrefix}{cmd.Aliases[0]} {string.Join(" ", cmd.Parameters.Select(p => $"[{p.Name}{(p.IsOptional ? "?" : "")}{(!string.IsNullOrEmpty(p.DefaultValue?.ToString()) ? " = " + p.DefaultValue.ToString() : "")}]"))}`" + : $"Usage: `{commandPrefix}{cmd.Aliases[0]}`"; + + builder.AddField(x => + { + x.Name = cmd.Name; + x.Value = $"{commandDescription}\n{commandUsage}\n{aliases}"; + x.IsInline = false; + }); } embed = builder.Build(); diff --git a/Morpheus.Tests/HelpCommandRegistrationTests.cs b/Morpheus.Tests/HelpCommandRegistrationTests.cs index 2af0560..9960fed 100644 --- a/Morpheus.Tests/HelpCommandRegistrationTests.cs +++ b/Morpheus.Tests/HelpCommandRegistrationTests.cs @@ -6,6 +6,52 @@ namespace Morpheus.Tests; public class HelpCommandRegistrationTests { + [Theory] + [InlineData("2_StocksModule", 2, "StocksModule")] + [InlineData("10_Misc", 10, "Misc")] + public void TryParseHelpModuleName_ParsesPageAndModule(string input, int expectedPage, string expectedModule) + { + Assert.True(HelpModule.TryParseHelpModuleName(input, out int page, out string module)); + Assert.Equal(expectedPage, page); + Assert.Equal(expectedModule, module); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData("0_StocksModule")] + [InlineData("-1_StocksModule")] + [InlineData("2_")] + public void TryParseHelpModuleName_RejectsMalformedSelections(string input) + { + Assert.False(HelpModule.TryParseHelpModuleName(input, out _, out _)); + } + + [Fact] + public void TryParseHelpModuleName_RejectsAbsentSelection() + { + Assert.False(HelpModule.TryParseHelpModuleName(null, out _, out _)); + } + + [Theory] + [InlineData(1, 11, true, 0, 10)] + [InlineData(2, 11, true, 10, 11)] + [InlineData(3, 11, false, 0, 0)] + [InlineData(int.MaxValue, 11, false, 0, 0)] + public void TryGetHelpPageBounds_ValidatesActualPageRange( + int page, + int visibleCommandCount, + bool expectedResult, + int expectedStart, + int expectedEnd) + { + bool result = HelpModule.TryGetHelpPageBounds(page, visibleCommandCount, out int start, out int end); + + Assert.Equal(expectedResult, result); + Assert.Equal(expectedStart, start); + Assert.Equal(expectedEnd, end); + } + [Fact] public void HelpCommand_AcceptsMultiWordCommandNames() {