From 3f376605f2df4cfa719b17a2f7ea3f02e4c9e760 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Mon, 9 Oct 2017 11:54:12 -0700 Subject: [PATCH] ModTools configuration redone Defining config for ModTools now is similar to that of other features. Additionally, merged the Ban and Kick commands into a single file, and it no longer scans itself for attribute data on load. --- Feature/ModTools/CommandBase.cs | 67 ++++++++++--- Feature/ModTools/CommandTypeAttribute.cs | 54 ---------- .../{BanCommand.cs => Commands/BanKick.cs} | 69 ++++++++++--- Feature/ModTools/KickCommand.cs | 98 ------------------- .../{CommandListener.cs => ModTools.cs} | 49 +++------- Feature/ModTools/TestCommand.cs | 24 ----- RegexBot.cs | 2 +- RegexBot.csproj | 2 +- 8 files changed, 125 insertions(+), 240 deletions(-) delete mode 100644 Feature/ModTools/CommandTypeAttribute.cs rename Feature/ModTools/{BanCommand.cs => Commands/BanKick.cs} (60%) delete mode 100644 Feature/ModTools/KickCommand.cs rename Feature/ModTools/{CommandListener.cs => ModTools.cs} (56%) delete mode 100644 Feature/ModTools/TestCommand.cs diff --git a/Feature/ModTools/CommandBase.cs b/Feature/ModTools/CommandBase.cs index 2790427..c496277 100644 --- a/Feature/ModTools/CommandBase.cs +++ b/Feature/ModTools/CommandBase.cs @@ -1,8 +1,11 @@ using Discord.WebSocket; using Newtonsoft.Json.Linq; using Noikoio.RegexBot.ConfigItem; +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics; +using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -11,24 +14,17 @@ namespace Noikoio.RegexBot.Feature.ModTools [DebuggerDisplay("{Label}-type command")] abstract class CommandBase { - private readonly CommandListener _modtools; + private readonly ModTools _modtools; private readonly string _label; private readonly string _command; - - public static readonly Regex UserMention = new Regex(@"<@!?(?\d+)>", RegexOptions.Compiled); - public static readonly Regex RoleMention = new Regex(@"<@&(?\d+)>", RegexOptions.Compiled); - public static readonly Regex ChannelMention = new Regex(@"<#(?\d+)>", RegexOptions.Compiled); - public static readonly Regex EmojiMatch = new Regex(@"<:(?[A-Za-z0-9_]{2,}):(?\d+)>", RegexOptions.Compiled); - + public string Label => _label; public string Command => _command; - protected CommandBase(CommandListener l, JObject conf) + protected CommandBase(ModTools l, string label, JObject conf) { _modtools = l; - _label = conf["label"].Value(); - if (string.IsNullOrWhiteSpace(_label)) - throw new RuleImportException("Command label is missing."); + _label = label; _command = conf["command"].Value(); } @@ -38,5 +34,54 @@ namespace Noikoio.RegexBot.Feature.ModTools { return _modtools.Log($"{Label}: {text}"); } + + #region Config loading + private static readonly ReadOnlyDictionary _commands = + new ReadOnlyDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Define all command types and their corresponding Types here + { "ban", typeof(Commands.Ban) }, + { "kick", typeof(Commands.Kick) } + }); + + public static CommandBase CreateInstance(ModTools root, JProperty def) + { + string label = def.Name; + if (string.IsNullOrWhiteSpace(label)) throw new RuleImportException("Label cannot be blank."); + + var definition = (JObject)def.Value; + string cmdinvoke = definition["command"].Value(); + if (string.IsNullOrWhiteSpace(cmdinvoke)) + throw new RuleImportException($"{label}: 'command' value was not specified."); + if (cmdinvoke.Contains(" ")) + throw new RuleImportException($"{label}: 'command' must not contain spaces."); + + string ctypestr = definition["type"]?.Value(); + if (string.IsNullOrWhiteSpace(ctypestr)) + throw new RuleImportException($"Value 'type' must be specified in definition for '{label}'."); + Type ctype; + if (!_commands.TryGetValue(ctypestr, out ctype)) + throw new RuleImportException($"The given 'type' value is invalid in definition for '{label}'."); + + try + { + return (CommandBase)Activator.CreateInstance(ctype, root, label, definition); + } + catch (TargetInvocationException ex) + { + if (ex.InnerException is RuleImportException) + throw new RuleImportException($"Error in configuration for command '{label}': {ex.InnerException.Message}"); + else throw; + } + } + #endregion + + #region Helper methods and values + protected static readonly Regex UserMention = new Regex(@"<@!?(?\d+)>", RegexOptions.Compiled); + protected static readonly Regex RoleMention = new Regex(@"<@&(?\d+)>", RegexOptions.Compiled); + protected static readonly Regex ChannelMention = new Regex(@"<#(?\d+)>", RegexOptions.Compiled); + protected static readonly Regex EmojiMatch = new Regex(@"<:(?[A-Za-z0-9_]{2,}):(?\d+)>", RegexOptions.Compiled); + #endregion } } diff --git a/Feature/ModTools/CommandTypeAttribute.cs b/Feature/ModTools/CommandTypeAttribute.cs deleted file mode 100644 index daf4d9a..0000000 --- a/Feature/ModTools/CommandTypeAttribute.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace Noikoio.RegexBot.Feature.ModTools -{ - /// - /// Specifies this class's corresponding value when being defined in configuration - /// under a custom command's "type" value. - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] - class CommandTypeAttribute : Attribute - { - readonly string _type; - public string TypeName => _type; - public CommandTypeAttribute(string typeName) => _type = typeName; - - private static Dictionary _sTypes; - /// - /// Translates a command type defined from configuration into a usable - /// deriving from CommandBase. - /// - internal static Type GetCommandType(string input) - { - if (_sTypes == null) - { - var newtypelist = new Dictionary(StringComparer.OrdinalIgnoreCase); - var ctypes = from type in Assembly.GetEntryAssembly().GetTypes() - where typeof(CommandBase).IsAssignableFrom(type) - select type; - foreach (var type in ctypes) - { - var attr = type.GetTypeInfo().GetCustomAttribute(); - if (attr == null) - { -#if DEBUG - Console.WriteLine($"{type.FullName} does not define a {nameof(CommandTypeAttribute)}"); -#endif - continue; - } - newtypelist.Add(attr.TypeName, type); - } - _sTypes = newtypelist; - } - - if (_sTypes.TryGetValue(input, out var cmdtype)) - { - return cmdtype; - } - return null; - } - } -} diff --git a/Feature/ModTools/BanCommand.cs b/Feature/ModTools/Commands/BanKick.cs similarity index 60% rename from Feature/ModTools/BanCommand.cs rename to Feature/ModTools/Commands/BanKick.cs index 11aafe9..e428572 100644 --- a/Feature/ModTools/BanCommand.cs +++ b/Feature/ModTools/Commands/BanKick.cs @@ -6,26 +6,34 @@ using System; using System.Text.RegularExpressions; using System.Threading.Tasks; -namespace Noikoio.RegexBot.Feature.ModTools +namespace Noikoio.RegexBot.Feature.ModTools.Commands { - [CommandType("ban")] - class BanCommand : CommandBase + + class BanKick : CommandBase { + // Ban and kick commands are highly similar in implementation, and thus are handled in a single class. + protected enum CommandMode { Ban, Kick } + private readonly CommandMode _mode; + private readonly bool _forceReason; private readonly int _purgeDays; + private readonly string _successMsg; // Configuration: // "forcereason" - boolean; Force a reason to be given. Defaults to false. - // "purgedays" - integer; Number of days of target's post history to delete. Must be between 0-7 inclusive. - // Defaults to 0. - public BanCommand(CommandListener l, JObject conf) : base(l, conf) + // "purgedays" - integer; Number of days of target's post history to delete, if banning. + // Must be between 0-7 inclusive. Defaults to 0. + // "successmsg" - Message to display on command success. Overrides default. + protected BanKick(ModTools l, string label, JObject conf, CommandMode mode) : base(l, label, conf) { + _mode = mode; _forceReason = conf["forcereason"]?.Value() ?? false; _purgeDays = conf["purgedays"]?.Value() ?? 0; - if (_purgeDays > 7 || _purgeDays < 0) + if (_mode == CommandMode.Ban && (_purgeDays > 7 || _purgeDays < 0)) { throw new RuleImportException("The value of 'purgedays' must be between 0 and 7."); } + _successMsg = conf["successmsg"]?.Value(); } // Usage: (command) (mention) (reason) @@ -51,7 +59,7 @@ namespace Noikoio.RegexBot.Feature.ModTools // No reason given if (_forceReason) { - await SendUsageMessage(msg, ":x: **You must specify a ban reason.**"); + await SendUsageMessage(msg, ":x: **You must specify a reason.**"); return; } } @@ -66,7 +74,7 @@ namespace Noikoio.RegexBot.Feature.ModTools if (ulong.TryParse(targetstr, out targetuid)) { targetobj = g.GetUser(targetuid); - targetdisp = (targetobj == null ? $"with ID {targetuid}" : targetobj.ToString()); + targetdisp = (targetobj == null ? $"ID {targetuid}" : targetobj.ToString()); } else { @@ -74,26 +82,35 @@ namespace Noikoio.RegexBot.Feature.ModTools return; } + if (_mode == CommandMode.Kick && targetobj == null) + { + // Can't kick without obtaining the user object + await SendUsageMessage(msg, ":x: **Unable to find the target user.**"); + return; + } + try { if (reason != null) reason = Uri.EscapeDataString(reason); // TODO remove when fixed in library - await g.AddBanAsync(targetuid, _purgeDays, reason); - await msg.Channel.SendMessageAsync($":white_check_mark: Banned user **{targetdisp}**."); + if (_mode == CommandMode.Ban) await g.AddBanAsync(targetuid, _purgeDays, reason); + else await targetobj.KickAsync(reason); + string resultmsg = BuildSuccessMessage(targetdisp); + await msg.Channel.SendMessageAsync(resultmsg); } catch (Discord.Net.HttpException ex) { - const string err = ":x: **Failed to ban user.** "; + string err = ":x: **Failed to " + (_mode == CommandMode.Ban ? "ban" : "kick") + " user:** "; if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { - await msg.Channel.SendMessageAsync(err + "I do not have permission to do that action."); + await msg.Channel.SendMessageAsync(err + "I do not have sufficient permissions to do that action."); } else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) { - await msg.Channel.SendMessageAsync(err + "The target user appears to have left the server."); + await msg.Channel.SendMessageAsync(err + "The target user appears to no longer exist."); } else { - await msg.Channel.SendMessageAsync(err + "An unknown error prevented me from doing that action."); + await msg.Channel.SendMessageAsync(err + "An unknown error occurred. Details have been logged."); await Log(ex.ToString()); } } @@ -114,5 +131,27 @@ namespace Noikoio.RegexBot.Feature.ModTools }; await m.Channel.SendMessageAsync(message ?? "", embed: usageEmbed); } + + private string BuildSuccessMessage(string targetstr) + { + const string defaultmsgBan = ":white_check_mark: Banned user **$target**."; + const string defaultmsgKick = ":white_check_mark: Kicked user **$target**."; + + string msg = _successMsg ?? (_mode == CommandMode.Ban ? defaultmsgBan : defaultmsgKick); + + return msg.Replace("$target", targetstr); + } + } + + class Ban : BanKick + { + public Ban(ModTools l, string label, JObject conf) + : base(l, label, conf, CommandMode.Ban) { } + } + + class Kick : BanKick + { + public Kick(ModTools l, string label, JObject conf) + : base(l, label, conf, CommandMode.Kick) { } } } diff --git a/Feature/ModTools/KickCommand.cs b/Feature/ModTools/KickCommand.cs deleted file mode 100644 index 7ebcc65..0000000 --- a/Feature/ModTools/KickCommand.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Discord; -using Discord.WebSocket; -using Newtonsoft.Json.Linq; -using System; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -namespace Noikoio.RegexBot.Feature.ModTools -{ - [CommandType("kick")] - class KickCommand : CommandBase - { - private readonly bool _forceReason; - - // Configuration: - // "forcereason" - boolean; Force a reason to be given. Defaults to false. - public KickCommand(CommandListener l, JObject conf) : base(l, conf) - { - _forceReason = conf["forcereason"]?.Value() ?? false; - } - - public override async Task Invoke(SocketGuild g, SocketMessage msg) - { - string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); - string targetstr; - string reason = null; - if (line.Length < 2) - { - await SendUsageMessage(msg, null); - return; - } - targetstr = line[1]; - - if (line.Length == 3) reason = line[2]; - if (_forceReason && reason == null) - { - await SendUsageMessage(msg, ":x: **You must specify a kick reason.**"); - return; - } - - // Getting SocketGuildUser target - Match m = UserMention.Match(targetstr); - if (m.Success) targetstr = m.Groups["snowflake"].Value; - - SocketGuildUser targetobj = null; - if (ulong.TryParse(targetstr, out var snowflake)) - { - targetobj = g.GetUser(snowflake); - if (targetobj == null) - { - await SendUsageMessage(msg, ":x: **Unable to determine the target user.**"); - return; - } - } - else - { - await SendUsageMessage(msg, ":x: **The given target is not valid.**"); - return; - } - - try - { - if (reason != null) reason = Uri.EscapeDataString(reason); // TODO remove when fixed in library - await targetobj.KickAsync(reason); - await msg.Channel.SendMessageAsync($":white_check_mark: Kicked user **{targetobj.ToString()}**."); - } - catch (Discord.Net.HttpException ex) - { - const string err = ":x: **Failed to kick user.** "; - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - await msg.Channel.SendMessageAsync(err + "I do not have permission to do that action."); - } - else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) - { - await msg.Channel.SendMessageAsync(err + "The target user appears to have left the server."); - } - else - { - await msg.Channel.SendMessageAsync(err + "An unknown error prevented me from doing that action."); - await Log(ex.ToString()); - } - } - } - - private async Task SendUsageMessage(SocketMessage m, string message) - { - var usageEmbed = new EmbedBuilder() - { - Title = "Usage", - Description = $"{this.Command} [user or user ID] " + (_forceReason ? "[reason]" : "*[reason]*") + "\n" + - "Kicks the given user from this server and " + (_forceReason ? "" : "optionally ") + - "logs a reason for the kick." - }; - await m.Channel.SendMessageAsync(message ?? "", embed: usageEmbed); - } - } -} diff --git a/Feature/ModTools/CommandListener.cs b/Feature/ModTools/ModTools.cs similarity index 56% rename from Feature/ModTools/CommandListener.cs rename to Feature/ModTools/ModTools.cs index 687511d..0430e7e 100644 --- a/Feature/ModTools/CommandListener.cs +++ b/Feature/ModTools/ModTools.cs @@ -14,12 +14,12 @@ namespace Noikoio.RegexBot.Feature.ModTools /// Entry point for the ModTools feature. /// This feature implements moderation commands that are defined and enabled in configuration. /// - // We are not using Discord.Net's Commands extension, as it doesn't allow for changes during runtime. - class CommandListener : BotFeature + // We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime. + class ModTools : BotFeature { public override string Name => "ModTools"; - public CommandListener(DiscordSocketClient client) : base(client) + public ModTools(DiscordSocketClient client) : base(client) { client.MessageReceived += Client_MessageReceived; } @@ -67,44 +67,21 @@ namespace Noikoio.RegexBot.Feature.ModTools [ConfigSection("modtools")] public override async Task ProcessConfiguration(JToken configSection) { - var newcmds = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (JObject definition in configSection) - { - string label = definition["label"].Value(); - if (string.IsNullOrWhiteSpace(label)) - throw new RuleImportException("A 'label' value was not specified in a command definition."); + var commands = new Dictionary(StringComparer.OrdinalIgnoreCase); - string cmdinvoke = definition["command"].Value(); - if (string.IsNullOrWhiteSpace(cmdinvoke)) - throw new RuleImportException($"{label}: 'command' value was not specified."); - if (cmdinvoke.Contains(" ")) - throw new RuleImportException($"{label}: 'command' must not contain spaces."); - if (newcmds.TryGetValue(cmdinvoke, out var cmdexisting)) + foreach (var def in configSection.Children()) + { + string label = def.Name; + var cmd = CommandBase.CreateInstance(this, def); + if (commands.ContainsKey(cmd.Command)) throw new RuleImportException( $"{label}: 'command' value must not be equal to that of another definition. " + - $"Given value is being used for {cmdexisting.Label}."); - + $"Given value is being used for {commands[cmd.Command].Label}."); - string ctypestr = definition["type"].Value(); - if (string.IsNullOrWhiteSpace(ctypestr)) - throw new RuleImportException($"Value 'type' must be specified in definition for '{label}'."); - var ctype = CommandTypeAttribute.GetCommandType(ctypestr); - - CommandBase cmd; - try - { - cmd = (CommandBase)Activator.CreateInstance(ctype, this, definition); - } - catch (TargetInvocationException ex) - { - if (ex.InnerException is RuleImportException) - throw new RuleImportException($"Error in configuration for '{label}': {ex.InnerException.Message}"); - throw; - } - await Log($"'{label}' created; using command {cmdinvoke}"); - newcmds.Add(cmdinvoke, cmd); + commands.Add(cmd.Command, cmd); } - return new ReadOnlyDictionary(newcmds); + await Log($"Loaded {commands.Count} command definition(s)."); + return new ReadOnlyDictionary(commands); } public new Task Log(string text) => base.Log(text); diff --git a/Feature/ModTools/TestCommand.cs b/Feature/ModTools/TestCommand.cs deleted file mode 100644 index f14a7df..0000000 --- a/Feature/ModTools/TestCommand.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Discord.WebSocket; -using Newtonsoft.Json.Linq; -using Noikoio.RegexBot.ConfigItem; -using System.Threading.Tasks; - -namespace Noikoio.RegexBot.Feature.ModTools -{ -#if DEBUG - [CommandType("test")] - class TestCommand : CommandBase - { - public TestCommand(CommandListener l, JObject conf) : base(l, conf) { - bool? doCrash = conf["crash"]?.Value(); - if (doCrash.HasValue && doCrash.Value) - throw new RuleImportException("Throwing exception in constructor upon request."); - } - - public override async Task Invoke(SocketGuild g, SocketMessage msg) - { - await msg.Channel.SendMessageAsync("This is the test command. It is labeled: " + this.Label); - } - } -#endif -} diff --git a/RegexBot.cs b/RegexBot.cs index b093021..2d46af7 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -45,7 +45,7 @@ namespace Noikoio.RegexBot _features = new BotFeature[] { new Feature.AutoMod.AutoMod(_client), - new Feature.ModTools.CommandListener(_client), + new Feature.ModTools.ModTools(_client), new Feature.AutoRespond.AutoRespond(_client) }; var dlog = Logger.GetLogger("Discord.Net"); diff --git a/RegexBot.csproj b/RegexBot.csproj index 98a3c4f..82b8819 100644 --- a/RegexBot.csproj +++ b/RegexBot.csproj @@ -4,7 +4,7 @@ Exe netcoreapp2.0 Noikoio.RegexBot - 2.0.0.0 + 2.1.0.0 Highly configurable Discord moderation bot Noikoio