From 79e7252f99df1c045050d6b62a14672d6712f232 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:51:19 +1200 Subject: [PATCH] Added Help module --- .../Modules/Help/CommandJsonObject.cs | 13 + src/EllieBot/Modules/Help/CommandsOptions.cs | 26 + src/EllieBot/Modules/Help/Help.cs | 593 ++++++++++++++++++ src/EllieBot/Modules/Help/HelpService.cs | 44 ++ 4 files changed, 676 insertions(+) create mode 100644 src/EllieBot/Modules/Help/CommandJsonObject.cs create mode 100644 src/EllieBot/Modules/Help/CommandsOptions.cs create mode 100644 src/EllieBot/Modules/Help/Help.cs create mode 100644 src/EllieBot/Modules/Help/HelpService.cs diff --git a/src/EllieBot/Modules/Help/CommandJsonObject.cs b/src/EllieBot/Modules/Help/CommandJsonObject.cs new file mode 100644 index 0000000..062a0b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandJsonObject.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Modules.Help; + +internal class CommandJsonObject +{ + public string[] Aliases { get; set; } + public string Description { get; set; } + public string[] Usage { get; set; } + public string Submodule { get; set; } + public string Module { get; set; } + public List Options { get; set; } + public string[] Requirements { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/CommandsOptions.cs b/src/EllieBot/Modules/Help/CommandsOptions.cs new file mode 100644 index 0000000..ecbb06c --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandsOptions.cs @@ -0,0 +1,26 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Help.Common; + +public class CommandsOptions : IEllieCommandOptions +{ + public enum ViewType + { + Hide, + Cross, + All + } + + [Option('v', + "view", + Required = false, + Default = ViewType.Hide, + HelpText = + "Specifies how to output the list of commands. 0 - Hide commands which you can't use, 1 - Cross out commands which you can't use, 2 - Show all.")] + public ViewType View { get; set; } = ViewType.Hide; + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/Help.cs b/src/EllieBot/Modules/Help/Help.cs new file mode 100644 index 0000000..79367b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/Help.cs @@ -0,0 +1,593 @@ +#nullable disable +using EllieBot.Modules.Help.Common; +using EllieBot.Modules.Help.Services; +using Newtonsoft.Json; +using System.Text; +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules.Help; + +public sealed partial class Help : EllieModule +{ + public const string PATREON_URL = "https://patreon.com/toastie_t0ast"; + public const string PAYPAL_URL = "https://paypal.me/EmotionChild"; + + private readonly ICommandsUtilityService _cus; + private readonly CommandService _cmds; + private readonly BotConfigService _bss; + private readonly IPermissionChecker _perms; + private readonly IServiceProvider _services; + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + + private readonly AsyncLazy _lazyClientId; + private readonly IMarmaladeLoaderService _marmalades; + + public Help( + ICommandsUtilityService _cus, + IPermissionChecker perms, + CommandService cmds, + BotConfigService bss, + IServiceProvider services, + DiscordSocketClient client, + IBotStrings strings, + IMarmaladeLoaderService marmalades) + { + this._cus = _cus; + _cmds = cmds; + _bss = bss; + _perms = perms; + _services = services; + _client = client; + _strings = strings; + _marmalades = marmalades; + + _lazyClientId = new(async () => (await _client.GetApplicationInfoAsync()).Id); + } + + public async Task GetHelpString() + { + var botSettings = _bss.Data; + if (string.IsNullOrWhiteSpace(botSettings.HelpText) || botSettings.HelpText == "-") + return default; + + var clientId = await _lazyClientId.Value; + var repCtx = new ReplacementContext(Context) + .WithOverride("{0}", () => clientId.ToString()) + .WithOverride("{1}", () => prefix) + .WithOverride("%prefix%", () => prefix) + .WithOverride("%bot.prefix%", () => prefix); + + var text = SmartText.CreateFrom(botSettings.HelpText); + return await repSvc.ReplaceAsync(text, repCtx); + } + + [Cmd] + public async Task Modules(int page = 1) + { + if (--page < 0) + return; + + var topLevelModules = new List(); + foreach (var m in _cmds.Modules.GroupBy(x => x.GetTopLevelModule()).OrderBy(x => x.Key.Name).Select(x => x.Key)) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + m.Name, + null); + +#if GLOBAL_ELLIE + if (m.Preconditions.Any(x => x is NoPublicBotAttribute)) + continue; +#endif + + if (result.IsAllowed) + topLevelModules.Add(m); + } + + var menu = new SelectMenuBuilder() + .WithPlaceholder("Select a module to see its commands") + .WithCustomId("cmds:modules_select"); + + foreach (var m in topLevelModules) + menu.AddOption(m.Name, m.Name, GetModuleEmoji(m.Name)); + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + var val = smc.Data.Values.FirstOrDefault(); + if (val is null) + return; + + await Commands(val); + }); + + await Response() + .Paginated() + .Items(topLevelModules) + .PageSize(12) + .CurrentPage(page) + .Interaction(inter) + .AddFooter(false) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.list_of_modules)); + + if (!items.Any()) + { + embed = embed.WithOkColor().WithDescription(GetText(strs.module_page_empty)); + return embed; + } + + items + .ToList() + .ForEach(module => embed.AddField($"{GetModuleEmoji(module.Name)} {module.Name}", + GetModuleDescription(module.Name) + + "\n" + + Format.Code(GetText(strs.module_footer(prefix, module.Name.ToLowerInvariant()))), + true)); + + return embed; + }) + .SendAsync(); + } + + private string GetModuleDescription(string moduleName) + { + var key = GetModuleLocStr(moduleName); + + if (key.Key == strs.module_description_missing.Key) + { + var desc = _marmalades + .GetLoadedMarmalades(Culture) + .FirstOrDefault(m => m.Canaries + .Any(x => x.Name.Equals(moduleName, + StringComparison.InvariantCultureIgnoreCase))) + ?.Description; + + if (desc is not null) + return desc; + } + + return GetText(key); + } + + private LocStr GetModuleLocStr(string moduleName) + { + switch (moduleName.ToLowerInvariant()) + { + case "help": + return strs.module_description_help; + case "administration": + return strs.module_description_administration; + case "expressions": + return strs.module_description_expressions; + case "searches": + return strs.module_description_searches; + case "utility": + return strs.module_description_utility; + case "games": + return strs.module_description_games; + case "gambling": + return strs.module_description_gambling; + case "music": + return strs.module_description_music; + case "nsfw": + return strs.module_description_nsfw; + case "permissions": + return strs.module_description_permissions; + case "xp": + return strs.module_description_xp; + case "marmalade": + return strs.module_description_marmalade; + case "patronage": + return strs.module_description_patronage; + default: + return strs.module_description_missing; + } + } + + private string GetModuleEmoji(string moduleName) + { + moduleName = moduleName.ToLowerInvariant(); + switch (moduleName) + { + case "help": + return "❓"; + case "administration": + return "🛠️"; + case "expressions": + return "🗣️"; + case "searches": + return "🔍"; + case "utility": + return "🔧"; + case "games": + return "🎲"; + case "gambling": + return "💰"; + case "music": + return "🎶"; + case "nsfw": + return "😳"; + case "permissions": + return "🚓"; + case "xp": + return "📝"; + case "patronage": + return "💝"; + default: + return "📖"; + } + } + + [Cmd] + [EllieOptions] + public async Task Commands(string module = null, params string[] args) + { + if (string.IsNullOrWhiteSpace(module)) + { + await Modules(); + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new CommandsOptions(), args); + + // Find commands for that module + // don't show commands which are blocked + // order by name + var allowed = new List(); + + var mdls = _cmds.Commands + .Where(c => c.Module.GetTopLevelModule() + .Name + .StartsWith(module, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + + if (mdls.Length == 0) + { + var group = _cmds.Modules + .Where(x => x.Parent is not null) + .FirstOrDefault(x => string.Equals(x.Name.Replace("Commands", ""), + module, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + } + + foreach (var cmd in mdls) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + cmd.Module.GetTopLevelModule().Name, + cmd.Name); + + if (result.IsAllowed) + allowed.Add(cmd); + } + + + var cmds = allowed.OrderBy(c => c.Aliases[0]) + .DistinctBy(x => x.Aliases[0]) + .ToList(); + + + // check preconditions for all commands, but only if it's not 'all' + // because all will show all commands anyway, no need to check + var succ = new HashSet(); + if (opts.View != CommandsOptions.ViewType.All) + { + succ = + [ + ..(await cmds.Select(async x => + { + var pre = await x.CheckPreconditionsAsync(Context, _services); + return (Cmd: x, Succ: pre.IsSuccess); + }) + .WhenAll()).Where(x => x.Succ) + .Select(x => x.Cmd) + ]; + + if (opts.View == CommandsOptions.ViewType.Hide) + // if hidden is specified, completely remove these commands from the list + cmds = cmds.Where(x => succ.Contains(x)).ToList(); + } + + var cmdsWithGroup = cmds.GroupBy(c => c.Module.GetGroupName()) + .OrderBy(x => x.Key == x.First().Module.Name ? int.MaxValue : x.Count()) + .ToList(); + + if (cmdsWithGroup.Count == 0) + { + if (opts.View != CommandsOptions.ViewType.Hide) + await Response().Error(strs.module_not_found).SendAsync(); + else + await Response().Error(strs.module_not_found_or_cant_exec).SendAsync(); + return; + } + + var sb = new SelectMenuBuilder() + .WithCustomId("cmds:submodule_select") + .WithPlaceholder("Select a submodule to see detailed commands"); + + var groups = cmdsWithGroup.ToArray(); + var embed = _sender.CreateEmbed().WithOkColor(); + foreach (var g in groups) + { + sb.AddOption(g.Key, g.Key); + var transformed = g + .Select(x => + { + //if cross is specified, and the command doesn't satisfy the requirements, cross it out + if (opts.View == CommandsOptions.ViewType.Cross) + { + return $"{(succ.Contains(x) ? "✅" : "❌")} {prefix + x.Aliases[0]}"; + } + + + if (x.Aliases.Count == 1) + return prefix + x.Aliases[0]; + + return prefix + x.Aliases[0] + " | " + prefix + x.Aliases[1]; + }); + + embed.AddField(g.Key, "" + string.Join("\n", transformed) + "", true); + } + + embed.WithFooter(GetText(strs.commands_instr(prefix))); + + + var inter = _inter.Create(ctx.User.Id, + sb, + async (smc) => + { + var groupName = smc.Data.Values.FirstOrDefault(); + var mdl = _cmds.Modules.FirstOrDefault(x + => string.Equals(x.Name.Replace("Commands", ""), groupName, StringComparison.InvariantCultureIgnoreCase)); + await smc.DeferAsync(); + await Group(mdl); + } + ); + + await Response().Embed(embed).Interaction(inter).SendAsync(); + } + + private async Task Group(ModuleInfo group) + { + var menu = new SelectMenuBuilder() + .WithCustomId("cmds:group_select") + .WithPlaceholder("Select a command to see its details"); + + foreach (var cmd in group.Commands.DistinctBy(x => x.Aliases[0])) + { + menu.AddOption(prefix + cmd.Aliases[0], cmd.Aliases[0]); + } + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + + await H(smc.Data.Values.FirstOrDefault()); + }); + + await Response() + .Paginated() + .Items(group.Commands.DistinctBy(x => x.Aliases[0]).ToArray()) + .PageSize(25) + .Interaction(inter) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.cmd_group_commands(group.Name))) + .WithOkColor(); + + foreach (var cmd in items) + { + string cmdName; + if (cmd.Aliases.Count > 1) + cmdName = Format.Code(prefix + cmd.Aliases[0]) + " | " + Format.Code(prefix + cmd.Aliases[1]); + else + cmdName = Format.Code(prefix + cmd.Aliases.First()); + + eb.AddField(cmdName, cmd.RealSummary(_strings, _marmalades, Culture, prefix)); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [Priority(0)] + public async Task H([Leftover] string fail) + { + var prefixless = + _cmds.Commands.FirstOrDefault(x => x.Aliases.Any(cmdName => cmdName.ToLowerInvariant() == fail)); + if (prefixless is not null) + { + await H(prefixless); + return; + } + + if (fail.StartsWith(prefix)) + fail = fail.Substring(prefix.Length); + + var group = _cmds.Modules + .SelectMany(x => x.Submodules) + .FirstOrDefault(x => string.Equals(x.Group, + fail, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + + await Response().Error(strs.command_not_found).SendAsync(); + } + + [Cmd] + [Priority(1)] + public async Task H([Leftover] CommandInfo com = null) + { + var channel = ctx.Channel; + if (com is null) + { + try + { + var ch = channel is ITextChannel ? await ctx.User.CreateDMChannelAsync() : channel; + var data = await GetHelpString(); + if (data == default) + return; + + await Response().Channel(ch).Text(data).SendAsync(); + try + { + await ctx.OkAsync(); + } + catch + { + } // ignore if bot can't react + } + catch (Exception) + { + await Response().Error(strs.cant_dm).SendAsync(); + } + + return; + } + + var embed = _cus.GetCommandHelp(com, ctx.Guild); + await _sender.Response(channel).Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GenCmdList() + { + _ = ctx.Channel.TriggerTypingAsync(); + + // order commands by top level module name + // and make a dictionary of > + var cmdData = _cmds.Commands.GroupBy(x => x.Module.GetTopLevelModule().Name) + .OrderBy(x => x.Key) + .ToDictionary(x => x.Key, + x => x.DistinctBy(c => c.Aliases.First()) + .Select(com => + { + List optHelpStr = null; + + var opt = CommandsUtilityService.GetEllieOptionType(com.Attributes); + if (opt is not null) + optHelpStr = CommandsUtilityService.GetCommandOptionHelpList(opt); + + return new CommandJsonObject + { + Aliases = com.Aliases.Select(alias => prefix + alias).ToArray(), + Description = com.RealSummary(_strings, _marmalades, Culture, prefix), + Usage = com.RealRemarksArr(_strings, _marmalades, Culture, prefix), + Submodule = com.Module.Name, + Module = com.Module.GetTopLevelModule().Name, + Options = optHelpStr, + Requirements = CommandsUtilityService.GetCommandRequirements(com) + }; + }) + .ToList()); + + var readableData = JsonConvert.SerializeObject(cmdData, Formatting.Indented); + + // send the indented file to chat + await using var rDataStream = new MemoryStream(Encoding.ASCII.GetBytes(readableData)); + await ctx.Channel.SendFileAsync(rDataStream, "cmds.json", GetText(strs.commandlist_regen)); + } + + [Cmd] + public async Task Guide() + => await Response() + .Confirm(strs.guide("https://commands.elliebot.net", + "https://docs.elliebot.net/")) + .SendAsync(); + + + private Task SelfhostAction(SocketMessageComponent smc) + => smc.RespondConfirmAsync(_sender, + """ + - In case you don't want or cannot Donate to EllieBot project, but you + - EllieBot is a free and [open source](https://toastielab.dev/Emotions-stuff/Ellie) project which means you can run your own "selfhosted" instance on your computer. + + *Keep in mind that running the bot on your computer means that the bot will be offline when you turn off your computer* + + - You can find the selfhosting guides by using the `.guide` command and clicking on the second link that pops up. + - If you decide to selfhost the bot, still consider [supporting the project](https://patreon.com/join/toastie_t0ast) to keep the development going :) + """, + true); + + [Cmd] + [OnlyPublicBot] + public async Task Donate() + { + var selfhostInter = _inter.Create(ctx.User.Id, + new ButtonBuilder( + emote: new Emoji("🖥️"), + customId: "donate:selfhosting", + label: "Selfhosting"), + SelfhostAction); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Thank you for considering to donate to the EllieBot project!"); + + eb + .WithDescription(""" + EllieBot relies on donations to keep the servers, services and APIs running. + Donating will give you access to some exclusive features. You can read about them on the [patreon page](https://patreon.com/join/toastie_t0ast) + """) + .AddField("Donation Instructions", + $""" + 🗒️ Before pledging it is recommended to open your DMs as Ellie will send you a welcome message with instructions after you pledge has been processed and confirmed. + + **Step 1:** ❤️ Pledge on Patreon ❤️ + + `1.` Go to and choose a tier. + `2.` Make sure your payment is processed and accepted. + + **Step 2** 🤝 Connect your Discord account 🤝 + + `1.` Go to your profile settings on Patreon and connect your Discord account to it. + *please make sure you're logged into the correct Discord account* + + If you do not know how to do it, you may [follow instructions here](https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron-) + + **Step 3** ⏰ Wait a short while (usually 1-3 minutes) ⏰ + + Ellie will DM you the welcome instructions, and you will receive your rewards! + 🎉 **Enjoy!** 🎉 + """); + + try + { + await Response() + .Channel(await ctx.User.CreateDMChannelAsync()) + .Embed(eb) + .Interaction(selfhostInter) + .SendAsync(); + + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/HelpService.cs b/src/EllieBot/Modules/Help/HelpService.cs new file mode 100644 index 0000000..5904c03 --- /dev/null +++ b/src/EllieBot/Modules/Help/HelpService.cs @@ -0,0 +1,44 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Help.Services; + +public class HelpService : IExecNoCommand, IEService +{ + private readonly BotConfigService _bss; + private readonly IReplacementService _rs; + private readonly IMessageSenderService _sender; + + public HelpService(BotConfigService bss, IReplacementService repSvc, IMessageSenderService sender) + { + _bss = bss; + _rs = repSvc; + _sender = sender; + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + var settings = _bss.Data; + if (guild is null) + { + if (string.IsNullOrWhiteSpace(settings.DmHelpText) || settings.DmHelpText == "-") + return; + + // only send dm help text if it contains one of the keywords, if they're specified + // if they're not, then reply to every DM + if (settings.DmHelpTextKeywords is not null + && !settings.DmHelpTextKeywords.Any(k => msg.Content.Contains(k))) + { + return; + } + + var repCtx = new ReplacementContext(guild: guild, channel: msg.Channel, users: msg.Author) + .WithOverride("%prefix%", () => _bss.Data.Prefix) + .WithOverride("%bot.prefix%", () => _bss.Data.Prefix); + + var text = SmartText.CreateFrom(settings.DmHelpText); + text = await _rs.ReplaceAsync(text, repCtx); + + await _sender.Response(msg.Channel).Text(text).SendAsync(); + } + } +} \ No newline at end of file