Added ModTools feature

Implements moderation commands that are fully configurable.
Commands are defined in configuration per-server under "modtools",
and allows for custom command triggers as well as individual options.

For now, only commands with "kick" and "ban" capabilities are
available, but more will be added in the near future.
This commit is contained in:
Noikoio 2017-08-06 13:05:44 -07:00
parent ae53276647
commit e3d40a5f60
8 changed files with 513 additions and 2 deletions

View file

@ -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<bool>() ?? false;
_purgeDays = conf["purgedays"]?.Value<int>() ?? 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);
}
}
}

View file

@ -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(@"<@!?(?<snowflake>\d+)>", RegexOptions.Compiled);
public static readonly Regex RoleMention = new Regex(@"<@&(?<snowflake>\d+)>", RegexOptions.Compiled);
public static readonly Regex ChannelMention = new Regex(@"<#(?<snowflake>\d+)>", RegexOptions.Compiled);
public static readonly Regex EmojiMatch = new Regex(@"<:(?<name>[A-Za-z0-9_]{2,}):(?<ID>\d+)>", RegexOptions.Compiled);
public string Label => _label;
public string Command => _command;
protected CommandBase(CommandListener l, JObject conf)
{
_modtools = l;
_label = conf["label"].Value<string>();
if (string.IsNullOrWhiteSpace(_label))
throw new RuleImportException("Command label is missing.");
_command = conf["command"].Value<string>();
}
public abstract Task Invoke(SocketGuild g, SocketMessage msg);
protected Task Log(string text)
{
return _modtools.Log($"{Label}: {text}");
}
}
}

View file

@ -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
{
/// <summary>
/// Entry point for the ModTools feature.
/// This feature implements moderation commands that are defined and enabled in configuration.
/// </summary>
// 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<string, CommandBase>)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<object> ProcessConfiguration(JToken configSection)
{
var newcmds = new Dictionary<string, CommandBase>(StringComparer.OrdinalIgnoreCase);
foreach (JObject definition in configSection)
{
string label = definition["label"].Value<string>();
if (string.IsNullOrWhiteSpace(label))
throw new RuleImportException("A 'label' value was not specified in a command definition.");
string cmdinvoke = definition["command"].Value<string>();
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<string>();
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<string, CommandBase>(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;
}
}
}

View file

@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Noikoio.RegexBot.Feature.ModTools
{
/// <summary>
/// Specifies this class's corresponding value when being defined in configuration
/// under a custom command's "type" value.
/// </summary>
[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<string, Type> _sTypes;
/// <summary>
/// Translates a command type defined from configuration into a usable
/// <see cref="System.Type"/> deriving from CommandBase.
/// </summary>
internal static Type GetCommandType(string input)
{
if (_sTypes == null)
{
var newtypelist = new Dictionary<string, Type>(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<CommandTypeAttribute>();
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;
}
}
}

View file

@ -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<bool>() ?? 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);
}
}
}

View file

@ -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<bool>();
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
}

View file

@ -44,7 +44,8 @@ namespace Noikoio.RegexBot
// Initialize features // Initialize features
_features = new BotFeature[] _features = new BotFeature[]
{ {
new Feature.RegexResponder.EventProcessor(_client) new Feature.RegexResponder.EventProcessor(_client),
new Feature.ModTools.CommandListener(_client)
}; };
var dlog = Logger.GetLogger("Discord.Net"); var dlog = Logger.GetLogger("Discord.Net");
_client.Log += async (arg) => _client.Log += async (arg) =>

View file

@ -4,7 +4,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>netcoreapp1.1</TargetFramework> <TargetFramework>netcoreapp1.1</TargetFramework>
<RootNamespace>Noikoio.RegexBot</RootNamespace> <RootNamespace>Noikoio.RegexBot</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<Description>Highly configurable Discord moderation bot</Description> <Description>Highly configurable Discord moderation bot</Description>
<Authors>Noikoio</Authors> <Authors>Noikoio</Authors>
<Company /> <Company />