diff --git a/Feature/ModTools/BanCommand.cs b/Feature/ModTools/BanCommand.cs new file mode 100644 index 0000000..07cdcce --- /dev/null +++ b/Feature/ModTools/BanCommand.cs @@ -0,0 +1,116 @@ +using Discord; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Feature.ModTools +{ + [CommandType("ban")] + class BanCommand : CommandBase + { + private readonly bool _forceReason; + private readonly int _purgeDays; + + // 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) + { + _forceReason = conf["forcereason"]?.Value() ?? false; + _purgeDays = conf["purgedays"]?.Value() ?? 0; + if (_purgeDays > 7 || _purgeDays < 0) + { + throw new RuleImportException("The value of 'purgedays' must be between 0 and 7."); + } + } + + // Usage: (command) (mention) (reason) + public override async Task Invoke(SocketGuild g, SocketMessage msg) + { + string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + string reason = $"Invoked by {msg.Author.ToString()}."; + if (line.Length < 2) + { + await SendUsageMessage(msg, null); + return; + } + targetstr = line[1]; + + if (line.Length == 3) + { + // Reason exists + reason += " Reason: " + line[2]; + } + else + { + // No reason given + if (_forceReason) + { + await SendUsageMessage(msg, ":x: **You must specify a ban reason.**"); + return; + } + } + + // Getting SocketGuildUser kick target (ensuring that it's the parameter) + SocketGuildUser targetobj = null; + if (UserMention.IsMatch(targetstr)) + { + targetobj = msg.MentionedUsers.ElementAt(0) as SocketGuildUser; + } + else 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; + } + + try + { + await g.AddBanAsync(targetobj, _purgeDays, reason); + await msg.Channel.SendMessageAsync($":white_check_mark: Banned user **{targetobj.ToString()}**."); + } + catch (Discord.Net.HttpException ex) + { + const string err = ":x: **Failed to ban 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) + { + string desc = $"{this.Command} [user or user ID] " + (_forceReason ? "[reason]" : "*[reason]*") + "\n"; + desc += "Removes the given user from this server and prevents the user from rejoining. "; + desc += (_forceReason ? "L" : "Optionally l") + "ogs the reason for the ban to the Audit Log."; + if (_purgeDays > 0) + desc += $"\nAdditionally removes the user's post history for the last {_purgeDays} day(s)."; + + var usageEmbed = new EmbedBuilder() + { + Title = "Usage", + Description = desc + }; + await m.Channel.SendMessageAsync(message ?? "", embed: usageEmbed); + } + } +} diff --git a/Feature/ModTools/CommandBase.cs b/Feature/ModTools/CommandBase.cs new file mode 100644 index 0000000..2790427 --- /dev/null +++ b/Feature/ModTools/CommandBase.cs @@ -0,0 +1,42 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Feature.ModTools +{ + [DebuggerDisplay("{Label}-type command")] + abstract class CommandBase + { + private readonly CommandListener _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) + { + _modtools = l; + _label = conf["label"].Value(); + if (string.IsNullOrWhiteSpace(_label)) + throw new RuleImportException("Command label is missing."); + _command = conf["command"].Value(); + } + + public abstract Task Invoke(SocketGuild g, SocketMessage msg); + + protected Task Log(string text) + { + return _modtools.Log($"{Label}: {text}"); + } + } +} diff --git a/Feature/ModTools/CommandListener.cs b/Feature/ModTools/CommandListener.cs new file mode 100644 index 0000000..9397b78 --- /dev/null +++ b/Feature/ModTools/CommandListener.cs @@ -0,0 +1,180 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +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 + { + public override string Name => "ModTools"; + + public CommandListener(DiscordSocketClient client) : base(client) + { + client.MessageReceived += Client_MessageReceived; + } + + private async Task Client_MessageReceived(SocketMessage arg) + { + // Disregard if not in a guild + SocketGuild g = (arg.Author as SocketGuildUser)?.Guild; + if (g == null) return; + + // Get guild config + ServerConfig sc = RegexBot.Config.Servers.FirstOrDefault(s => s.Id == g.Id); + if (sc == null) return; + + // Disregard if not a bot moderator + // TODO have this and RegexResponder call the same relevant code + if (!IsInList(sc.Moderators, arg)) return; + + // Disregard if the message contains a newline character + if (arg.Content.Contains("\n")) return; + + // Check for and invoke command... + string cmdchk; + int spc = arg.Content.IndexOf(' '); + if (spc != -1) cmdchk = arg.Content.Substring(0, spc); + else cmdchk = arg.Content; + if (((IDictionary)GetConfig(g.Id)).TryGetValue(cmdchk, out var c)) + { + // ...on the thread pool. + await Task.Run(async () => + { + try + { + await Log($"'{c.Label}' invoked by {arg.Author.ToString()} in {g.Name}/#{arg.Channel.Name}"); + await c.Invoke(g, arg); + } + catch (Exception ex) + { + await Log($"Encountered an error for the command '{c.Label}'. Details follow:"); + await Log(ex.ToString()); + } + }); + } + } + + [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."); + + 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)) + throw new RuleImportException( + $"{label}: 'command' value must not be equal to that of another definition. " + + $"Given value is being used for {cmdexisting.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); + } + return new ReadOnlyDictionary(newcmds); + } + + public new Task Log(string text) => base.Log(text); + + private bool IsInList(EntityList ignorelist, SocketMessage m) + { + if (ignorelist == null) + { + // This happens when getting a message from a server not defined in config. + return false; + } + + var author = m.Author as SocketGuildUser; + foreach (var item in ignorelist.Users) + { + if (!item.Id.HasValue) + { + // Attempt to update ID if given nick matches + if (string.Equals(item.Name, author.Nickname, StringComparison.OrdinalIgnoreCase) + || string.Equals(item.Name, author.Username, StringComparison.OrdinalIgnoreCase)) + { + item.UpdateId(author.Id); + return true; + } + } + else + { + if (item.Id.Value == author.Id) return true; + } + } + + foreach (var item in ignorelist.Roles) + { + if (!item.Id.HasValue) + { + // Try to update ID if none exists + foreach (var role in author.Roles) + { + if (string.Equals(item.Name, role.Name, StringComparison.OrdinalIgnoreCase)) + { + item.UpdateId(role.Id); + return true; + } + } + } + else + { + if (author.Roles.Any(r => r.Id == item.Id)) return true; + } + } + + foreach (var item in ignorelist.Channels) + { + if (!item.Id.HasValue) + { + // Try get ID + if (string.Equals(item.Name, m.Channel.Name, StringComparison.OrdinalIgnoreCase)) + { + item.UpdateId(m.Channel.Id); + return true; + } + } + else + { + if (item.Id == m.Channel.Id) return true; + } + } + + return false; + } + } +} diff --git a/Feature/ModTools/CommandTypeAttribute.cs b/Feature/ModTools/CommandTypeAttribute.cs new file mode 100644 index 0000000..daf4d9a --- /dev/null +++ b/Feature/ModTools/CommandTypeAttribute.cs @@ -0,0 +1,54 @@ +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/KickCommand.cs b/Feature/ModTools/KickCommand.cs new file mode 100644 index 0000000..59b2618 --- /dev/null +++ b/Feature/ModTools/KickCommand.cs @@ -0,0 +1,94 @@ +using Discord; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using System; +using System.Linq; +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 kick target (ensuring that it's the parameter) + SocketGuildUser targetobj = null; + if (UserMention.IsMatch(targetstr)) + { + targetobj = msg.MentionedUsers.ElementAt(0) as SocketGuildUser; + } + else 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; + } + + try + { + 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/TestCommand.cs b/Feature/ModTools/TestCommand.cs new file mode 100644 index 0000000..f14a7df --- /dev/null +++ b/Feature/ModTools/TestCommand.cs @@ -0,0 +1,24 @@ +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 ddb58ed..4efbb7f 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -44,7 +44,8 @@ namespace Noikoio.RegexBot // Initialize features _features = new BotFeature[] { - new Feature.RegexResponder.EventProcessor(_client) + new Feature.RegexResponder.EventProcessor(_client), + new Feature.ModTools.CommandListener(_client) }; var dlog = Logger.GetLogger("Discord.Net"); _client.Log += async (arg) => diff --git a/RegexBot.csproj b/RegexBot.csproj index 53dfc70..6124784 100644 --- a/RegexBot.csproj +++ b/RegexBot.csproj @@ -4,7 +4,7 @@ Exe netcoreapp1.1 Noikoio.RegexBot - 1.0.0.0 + 1.1.0.0 Highly configurable Discord moderation bot Noikoio