Merge branch 'dev/modtools'

This commit is contained in:
Noikoio 2018-03-17 14:21:30 -07:00
commit 73d17405fa
13 changed files with 545 additions and 245 deletions

View file

@ -6,30 +6,35 @@ using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModTools namespace Noikoio.RegexBot.Module.ModCommands
{ {
/// <summary> /// <summary>
/// ModTools module.
/// This class manages reading configuration and creating instances based on it. /// This class manages reading configuration and creating instances based on it.
/// It processes input and looks for messages that intend to invoke commands defined in configuration.
/// </summary> /// </summary>
class ModTools : BotModule /// <remarks>
/// Discord.Net has its own recommended way of implementing commands, but it's not exactly
/// done in a way that would easily allow for flexibility and modifications during runtime.
/// Thus, reinventing the wheel right here.
/// </remarks>
class CommandListener : BotModule
{ {
public override string Name => "ModTools"; public override string Name => "ModCommands";
public ModTools(DiscordSocketClient client) : base(client) public CommandListener(DiscordSocketClient client) : base(client)
{ {
client.MessageReceived += Client_MessageReceived; client.MessageReceived += Client_MessageReceived;
} }
private async Task Client_MessageReceived(SocketMessage arg) private async Task Client_MessageReceived(SocketMessage arg)
{ {
// Always ignore bots // Always ignore these
if (arg.Author.IsBot) return; if (arg.Author.IsBot || arg.Author.IsWebhook) return;
if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg); if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg);
} }
[ConfigSection("ModTools")] [ConfigSection("ModCommands")]
public override async Task<object> ProcessConfiguration(JToken configSection) public override async Task<object> ProcessConfiguration(JToken configSection)
{ {
// Constructor throws exception on config errors // Constructor throws exception on config errors
@ -69,12 +74,13 @@ namespace Noikoio.RegexBot.Module.ModTools
{ {
try try
{ {
await Log($"'{c.Label}' invoked by {arg.Author.ToString()} in {g.Name}/#{arg.Channel.Name}");
await c.Invoke(g, arg); await c.Invoke(g, arg);
// TODO Custom invocation log messages? Not by the user, but by the command.
await Log($"{g.Name}/#{arg.Channel.Name}: {arg.Author} invoked {arg.Content}");
} }
catch (Exception ex) catch (Exception ex)
{ {
await Log($"Encountered an error for the command '{c.Label}'. Details follow:"); await Log($"Encountered an unhandled exception while processing '{c.Label}'. Details follow:");
await Log(ex.ToString()); await Log(ex.ToString());
} }
} }

View file

@ -1,17 +1,14 @@
using Discord; using Discord.WebSocket;
using Discord.WebSocket;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem; using Noikoio.RegexBot.ConfigItem;
using System; using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModTools.Commands namespace Noikoio.RegexBot.Module.ModCommands.Commands
{ {
class BanKick : CommandBase // Ban and kick commands are highly similar in implementation, and thus are handled in a single class.
class BanKick : Command
{ {
// Ban and kick commands are highly similar in implementation, and thus are handled in a single class.
protected enum CommandMode { Ban, Kick } protected enum CommandMode { Ban, Kick }
private readonly CommandMode _mode; private readonly CommandMode _mode;
@ -28,7 +25,7 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
// "notifymsg" - Message to send to the target user being acted upon. Default message is used // "notifymsg" - Message to send to the target user being acted upon. Default message is used
// if the value is not specified. If a blank value is given, the feature is disabled. // if the value is not specified. If a blank value is given, the feature is disabled.
// Takes the special values $s for server name and $r for reason text. // Takes the special values $s for server name and $r for reason text.
protected BanKick(ModTools l, string label, JObject conf, CommandMode mode) : base(l, label, conf) protected BanKick(CommandListener l, string label, JObject conf, CommandMode mode) : base(l, label, conf)
{ {
_mode = mode; _mode = mode;
_forceReason = conf["forcereason"]?.Value<bool>() ?? false; _forceReason = conf["forcereason"]?.Value<bool>() ?? false;
@ -49,13 +46,20 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
if (string.IsNullOrWhiteSpace(val)) _notifyMsg = null; // empty value - disable message if (string.IsNullOrWhiteSpace(val)) _notifyMsg = null; // empty value - disable message
else _notifyMsg = val; else _notifyMsg = val;
} }
// Building usage message here
DefaultUsageMsg = $"{this.Trigger} [user or user ID] " + (_forceReason ? "[reason]" : "*[reason]*") + "\n"
+ "Removes the given user from this server"
+ (_mode == CommandMode.Ban ? " and prevents the user from rejoining" : "") + ". "
+ (_forceReason ? "L" : "Optionally l") + "ogs the reason for the "
+ (_mode == CommandMode.Ban ? "ban" : "kick") + " to the Audit Log.";
if (_purgeDays > 0)
DefaultUsageMsg += $"\nAdditionally removes the user's post history for the last {_purgeDays} day(s).";
} }
#region Strings #region Strings
const string FailPrefix = ":x: **Failed to {0} user:** "; const string FailPrefix = ":x: **Failed to {0} user:** ";
const string Fail403 = "I do not have the required permissions to perform that action."; const string Fail404 = "The specified user is no longer in the server.";
const string Fail404 = "The target user is no longer available.";
const string FailDefault = "An unknown error occurred. Notify the bot operator.";
const string NotifyDefault = "You have been {0} from $s for the following reason:\n$r"; const string NotifyDefault = "You have been {0} from $s for the following reason:\n$r";
const string NotifyReasonNone = "No reason specified."; const string NotifyReasonNone = "No reason specified.";
const string NotifyFailed = "\n(User was unable to receive notification message.)"; const string NotifyFailed = "\n(User was unable to receive notification message.)";
@ -71,7 +75,7 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
string reason; string reason;
if (line.Length < 2) if (line.Length < 2)
{ {
await SendUsageMessage(msg, null); await SendUsageMessageAsync(msg.Channel, null);
return; return;
} }
targetstr = line[1]; targetstr = line[1];
@ -86,33 +90,36 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
// No reason given // No reason given
if (_forceReason) if (_forceReason)
{ {
await SendUsageMessage(msg, ReasonRequired); await SendUsageMessageAsync(msg.Channel, ReasonRequired);
return; return;
} }
reason = null; reason = null;
} }
// Getting SocketGuildUser target // Retrieve target user
SocketGuildUser targetobj = null; var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
// Extract snowflake value from mention (if a mention was given)
Match m = UserMention.Match(targetstr);
if (m.Success) targetstr = m.Groups["snowflake"].Value;
var qres = (await EntityCache.EntityCache.QueryAsync(g.Id, targetstr)).FirstOrDefault();
if (qres == null)
{ {
await SendUsageMessage(msg, TargetNotFound); await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return; return;
} }
ulong targetuid = qres.UserId; if (targetId == 0)
targetobj = g.GetUser(targetuid); {
string targetdisp = targetobj?.ToString() ?? $"ID {targetuid}"; await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
SocketGuildUser targetobj = g.GetUser(targetId);
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
if (_mode == CommandMode.Kick && targetobj == null) if (_mode == CommandMode.Kick && targetobj == null)
{ {
// Can't kick without obtaining the user object // Can't kick without obtaining the user object
await SendUsageMessage(msg, TargetNotFound); await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return; return;
} }
@ -125,15 +132,16 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
string reasonlog = $"Invoked by {msg.Author.ToString()}."; string reasonlog = $"Invoked by {msg.Author.ToString()}.";
if (reason != null) reasonlog += $" Reason: {reason}"; if (reason != null) reasonlog += $" Reason: {reason}";
reasonlog = Uri.EscapeDataString(reasonlog); reasonlog = Uri.EscapeDataString(reasonlog);
await notifyTask;
#warning Remove EscapeDataString call on next Discord.Net update #warning Remove EscapeDataString call on next Discord.Net update
#if !DEBUG #if !DEBUG
if (_mode == CommandMode.Ban) await g.AddBanAsync(targetuid, _purgeDays, reasonlog); if (_mode == CommandMode.Ban) await g.AddBanAsync(targetId, _purgeDays, reasonlog);
else await targetobj.KickAsync(reason); else await targetobj.KickAsync(reasonlog);
#else #else
#warning "Actual kick/ban action is DISABLED during debug." #warning "Actual kick/ban action is DISABLED during debug."
#endif #endif
string resultmsg = BuildSuccessMessage(targetdisp); string resultmsg = BuildSuccessMessage(targetdisp);
if (await notifyTask == false) resultmsg += NotifyFailed; if (notifyTask.Result == false) resultmsg += NotifyFailed;
await msg.Channel.SendMessageAsync(resultmsg); await msg.Channel.SendMessageAsync(resultmsg);
} }
catch (Discord.Net.HttpException ex) catch (Discord.Net.HttpException ex)
@ -178,22 +186,6 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
return true; return true;
} }
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);
}
private string BuildSuccessMessage(string targetstr) private string BuildSuccessMessage(string targetstr)
{ {
const string defaultmsgBan = ":white_check_mark: Banned user **$target**."; const string defaultmsgBan = ":white_check_mark: Banned user **$target**.";
@ -207,13 +199,13 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
class Ban : BanKick class Ban : BanKick
{ {
public Ban(ModTools l, string label, JObject conf) public Ban(CommandListener l, string label, JObject conf)
: base(l, label, conf, CommandMode.Ban) { } : base(l, label, conf, CommandMode.Ban) { }
} }
class Kick : BanKick class Kick : BanKick
{ {
public Kick(ModTools l, string label, JObject conf) public Kick(CommandListener l, string label, JObject conf)
: base(l, label, conf, CommandMode.Kick) { } : base(l, label, conf, CommandMode.Kick) { }
} }
} }

View file

@ -0,0 +1,25 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
class ConfReload : Command
{
// No configuration.
public ConfReload(CommandListener l, string label, JObject conf) : base(l, label, conf) { }
// Usage: (command)
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
bool status = await RegexBot.Config.ReloadServerConfig();
string res;
if (status) res = ":white_check_mark: Configuration reloaded with no issues. Check the console to verify.";
else res = ":x: Reload failed. Check the console.";
await msg.Channel.SendMessageAsync(res);
}
// Crazy idea: somehow redirect all logging messages created from invoking config reloading
// and pass them onto the invoking channel.
}
}

View file

@ -0,0 +1,133 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
// Role adding and removing is largely the same, and thus are handled in a single class.
class RoleManipulation : Command
{
protected enum CommandMode { Add, Del }
private readonly CommandMode _mode;
private readonly EntityName _role;
private readonly string _successMsg;
// Configuration:
// "role" - string; The given role that applies to this command.
// "successmsg" - string; Messages to display on command success. Overrides default.
protected RoleManipulation(CommandListener l, string label, JObject conf, CommandMode mode) : base(l, label, conf)
{
_mode = mode;
var rolestr = conf["role"]?.Value<string>();
if (string.IsNullOrWhiteSpace(rolestr)) throw new RuleImportException("Role must be provided.");
_role = new EntityName(rolestr, EntityType.Role);
_successMsg = conf["successmsg"]?.Value<string>();
DefaultUsageMsg = $"{this.Trigger} [user or user ID]\n"
+ (_mode == CommandMode.Add ? "Adds" : "Removes") + " the specified role "
+ (_mode == CommandMode.Add ? "to" : "from") + " the given user.";
}
#region Strings
const string FailPrefix = ":x: **Failed to apply role change:** ";
const string TargetNotFound = ":x: **Unable to determine the target user.**";
const string RoleNotFound = ":x: **Failed to determine the specified role for this command.**";
const string Success = ":white_check_mark: Successfully {0} role for **{1}**.";
#endregion
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
string targetstr;
if (line.Length < 2)
{
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
// Retrieve target user
var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return;
}
if (targetId == 0)
{
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
// Determine role
SocketRole cmdRole;
if (_role.Id.HasValue)
{
cmdRole = g.GetRole(_role.Id.Value);
}
else
{
var res = g.Roles.Where(rn =>
string.Equals(rn.Name, _role.Name, StringComparison.InvariantCultureIgnoreCase))
.FirstOrDefault();
if (res == null)
{
await msg.Channel.SendMessageAsync(RoleNotFound);
await Log(RoleNotFound);
return;
}
cmdRole = res;
}
// Do the action
try
{
var u = g.GetUser(targetId);
if (_mode == CommandMode.Add)
await u.AddRoleAsync(cmdRole);
else
await u.RemoveRoleAsync(cmdRole);
await msg.Channel.SendMessageAsync(BuildSuccessMessage(targetdisp));
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail403);
}
else
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
await Log(ex.ToString());
}
}
}
private string BuildSuccessMessage(string targetstr)
{
const string defaultmsg = ":white_check_mark: Successfully {0} role for **$target**.";
string msg = _successMsg ?? string.Format(defaultmsg, _mode == CommandMode.Add ? "set" : "unset");
return msg.Replace("$target", targetstr);
}
}
class RoleAdd : RoleManipulation
{
public RoleAdd(CommandListener l, string label, JObject conf) : base(l, label, conf, CommandMode.Add) { }
}
class RoleDel : RoleManipulation
{
public RoleDel(CommandListener l, string label, JObject conf) : base(l, label, conf, CommandMode.Del) { }
}
}

View file

@ -1,49 +1,44 @@
using Discord; using Discord.WebSocket;
using Discord.WebSocket;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModTools.Commands namespace Noikoio.RegexBot.Module.ModCommands.Commands
{ {
class Say : CommandBase class Say : Command
{ {
public Say(ModTools l, string label, JObject conf) : base(l, label, conf) { } // No configuration at the moment.
// TODO: Whitelist/blacklist - to limit which channels it can "say" into // TODO: Whitelist/blacklist - to limit which channels it can "say" into
public Say(CommandListener l, string label, JObject conf) : base(l, label, conf) {
DefaultUsageMsg = $"{this.Trigger} [channel] [message]\n"
+ "Displays the given message exactly as specified to the given channel.";
}
#region Strings
const string ChannelRequired = ":x: You must specify a channel.";
const string MessageRequired = ":x: You must specify a message.";
const string TargetNotFound = ":x: Unable to find given channel.";
#endregion
public override async Task Invoke(SocketGuild g, SocketMessage msg) public override async Task Invoke(SocketGuild g, SocketMessage msg)
{ {
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
if (line.Length <= 1) if (line.Length <= 1)
{ {
await SendUsageMessage(msg, ":x: You must specify a channel."); await SendUsageMessageAsync(msg.Channel, ChannelRequired);
return; return;
} }
if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2])) if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2]))
{ {
await SendUsageMessage(msg, ":x: You must specify a message."); await SendUsageMessageAsync(msg.Channel, MessageRequired);
return; return;
} }
var ch = GetTextChannelFromString(g, line[1]); var ch = GetTextChannelFromString(g, line[1]);
if (ch == null) await SendUsageMessage(msg, ":x: Unable to find given channel."); if (ch == null) await SendUsageMessageAsync(msg.Channel, TargetNotFound);
await ch.SendMessageAsync(line[2]); await ch.SendMessageAsync(line[2]);
} }
private async Task SendUsageMessage(SocketMessage m, string message)
{
string desc = $"{this.Command} [channel] [message]\n";
desc += "Displays the given message exactly as specified to the given channel.";
var usageEmbed = new EmbedBuilder()
{
Title = "Usage",
Description = desc
};
await m.Channel.SendMessageAsync(message ?? "", embed: usageEmbed);
}
private SocketTextChannel GetTextChannelFromString(SocketGuild g, string input) private SocketTextChannel GetTextChannelFromString(SocketGuild g, string input)
{ {
// Method 1: Check for channel mention // Method 1: Check for channel mention

View file

@ -0,0 +1,80 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
class Unban : Command
{
// No configuration.
// TODO bring in some options from BanKick. Particularly custom success msg.
// TODO when ModLogs fully implemented, add a reason?
public Unban(CommandListener l, string label, JObject conf) : base(l, label, conf) {
DefaultUsageMsg = $"{this.Trigger} [user or user ID]\n"
+ "Unbans the given user, allowing them to rejoin the server.";
}
#region Strings
const string FailPrefix = ":x: **Unable to unban:** ";
protected const string Fail404 = "The specified user does not exist or is not in the ban list.";
const string TargetNotFound = ":x: **Unable to determine the target user.**";
const string Success = ":white_check_mark: Unbanned user **{0}**.";
#endregion
// Usage: (command) (user query)
public override async Task Invoke(SocketGuild g, SocketMessage msg)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
string targetstr;
if (line.Length < 2)
{
await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
// Retrieve target user
var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
if (targetId == 1)
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return;
}
if (targetId == 0)
{
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
string targetdisp;
if (targetData != null)
targetdisp = $"{targetData.Username}#{targetData.Discriminator}";
else
targetdisp = $"ID {targetId}";
// Do the action
try
{
await g.RemoveBanAsync(targetId);
await msg.Channel.SendMessageAsync(string.Format(Success, targetdisp));
}
catch (Discord.Net.HttpException ex)
{
if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail403);
}
else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound)
{
await msg.Channel.SendMessageAsync(FailPrefix + Fail404);
}
else
{
await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
await Log(ex.ToString());
}
}
}
}
}

View file

@ -0,0 +1,159 @@
using Discord;
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.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModCommands.Commands
{
/// <summary>
/// Base class for a command within the module.
/// After implementing, don't forget to add a reference to
/// <see cref="CreateInstance(CommandListener, JProperty)"/>.
/// </summary>
[DebuggerDisplay("Command def: {Label}")]
abstract class Command
{
private readonly CommandListener _mod;
private readonly string _label;
private readonly string _command;
protected CommandListener Module => _mod;
public string Label => _label;
public string Trigger => _command;
public Command(CommandListener l, string label, JObject conf)
{
_mod = l;
_label = label;
_command = conf["command"].Value<string>();
}
public abstract Task Invoke(SocketGuild g, SocketMessage msg);
protected Task Log(string text)
{
return _mod.Log($"{Label}: {text}");
}
#region Config loading
private static readonly ReadOnlyDictionary<string, Type> _commands =
new ReadOnlyDictionary<string, Type>(
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
// Define all command types and their corresponding Types here
{ "ban", typeof(Ban) },
{ "confreload", typeof(ConfReload) },
{ "kick", typeof(Kick) },
{ "say", typeof(Say) },
{ "unban", typeof(Unban) },
{ "addrole", typeof(RoleAdd) },
{ "delrole", typeof(RoleDel) }
});
public static Command CreateInstance(CommandListener 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<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.");
string ctypestr = definition["type"]?.Value<string>();
if (string.IsNullOrWhiteSpace(ctypestr))
throw new RuleImportException($"Value 'type' must be specified in definition for '{label}'.");
if (_commands.TryGetValue(ctypestr, out Type ctype))
{
try
{
return (Command)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;
}
}
else
{
throw new RuleImportException($"The given 'type' value is invalid in definition for '{label}'.");
}
}
#endregion
#region Helper methods and common values
protected static readonly Regex UserMention = new Regex(@"<@!?(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex RoleMention = new Regex(@"<@&(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex ChannelMention = new Regex(@"<#(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex EmojiMatch = new Regex(@"<:(?<name>[A-Za-z0-9_]{2,}):(?<ID>\d+)>", RegexOptions.Compiled);
protected const string Fail403 = "I do not have the required permissions to perform that action.";
protected const string FailDefault = "An unknown error occurred. Notify the bot operator.";
protected string DefaultUsageMsg { get; set; }
/// <summary>
/// Sends out the default usage message (<see cref="DefaultUsageMsg"/>) within an embed.
/// An optional message can be included, for uses such as notifying users of incorrect usage.
/// </summary>
/// <param name="target">Target channel for sending the message.</param>
/// <param name="message">The message to send alongside the default usage message.</param>
protected async Task SendUsageMessageAsync(ISocketMessageChannel target, string message = null)
{
if (DefaultUsageMsg == null)
throw new InvalidOperationException("DefaultUsage was not defined.");
var usageEmbed = new EmbedBuilder()
{
Title = "Usage",
Description = DefaultUsageMsg
};
await target.SendMessageAsync(message ?? "", embed: usageEmbed);
}
/// <summary>
/// Helper method for turning input into user data. Only returns the first cache result.
/// </summary>
/// <returns>
/// First value: 0 for no data, 1 for no data + exception.
/// May return a partial result: a valid ulong value but no CacheUser.
/// </returns>
protected async Task<(ulong, EntityCache.CacheUser)> GetUserDataFromString(ulong guild, string input)
{
ulong uid = 0;
EntityCache.CacheUser cdata = null;
Match m = UserMention.Match(input);
if (m.Success)
{
input = m.Groups["snowflake"].Value;
uid = ulong.Parse(input);
}
try
{
cdata = (await EntityCache.EntityCache.QueryAsync(guild, input))
.FirstOrDefault();
if (cdata != null) uid = cdata.UserId;
}
catch (Npgsql.NpgsqlException ex)
{
await Log("A databasae error occurred during user lookup: " + ex.Message);
if (uid == 0) uid = 1;
}
return (uid, cdata);
}
#endregion
}
}

View file

@ -0,0 +1,42 @@
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using Noikoio.RegexBot.Module.ModCommands.Commands;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Noikoio.RegexBot.Module.ModCommands
{
/// <summary>
/// Contains a server's ModCommands configuration.
/// </summary>
class ConfigItem
{
private readonly ReadOnlyDictionary<string, Command> _cmdInstances;
public ReadOnlyDictionary<string, Command> Commands => _cmdInstances;
public ConfigItem(CommandListener instance, JToken inconf)
{
if (inconf.Type != JTokenType.Object)
{
throw new RuleImportException("Configuration for this section is invalid.");
}
// Command instance creation
var commands = new Dictionary<string, Command>(StringComparer.OrdinalIgnoreCase);
foreach (var def in inconf.Children<JProperty>())
{
string label = def.Name;
var cmd = Command.CreateInstance(instance, def);
if (commands.ContainsKey(cmd.Trigger))
throw new RuleImportException(
$"{label}: 'command' value must not be equal to that of another definition. " +
$"Given value is being used for \"{commands[cmd.Trigger].Label}\".");
commands.Add(cmd.Trigger, cmd);
}
_cmdInstances = new ReadOnlyDictionary<string, Command>(commands);
}
}
}

View file

@ -1,93 +0,0 @@
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;
namespace Noikoio.RegexBot.Module.ModTools
{
/// <summary>
/// Base class for ModTools command.
/// We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime.
/// </summary>
[DebuggerDisplay("{Label}-type command")]
abstract class CommandBase
{
private readonly ModTools _modtools;
private readonly string _label;
private readonly string _command;
protected ModTools Mt => _modtools;
public string Label => _label;
public string Command => _command;
protected CommandBase(ModTools l, string label, JObject conf)
{
_modtools = l;
_label = label;
_command = conf["command"].Value<string>();
}
public abstract Task Invoke(SocketGuild g, SocketMessage msg);
protected Task Log(string text)
{
return _modtools.Log($"{Label}: {text}");
}
#region Config loading
private static readonly ReadOnlyDictionary<string, Type> _commands =
new ReadOnlyDictionary<string, Type>(
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
// Define all command types and their corresponding Types here
{ "ban", typeof(Commands.Ban) },
{ "kick", typeof(Commands.Kick) },
{ "say", typeof(Commands.Say) }
});
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<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.");
string ctypestr = definition["type"]?.Value<string>();
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(@"<@!?(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex RoleMention = new Regex(@"<@&(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex ChannelMention = new Regex(@"<#(?<snowflake>\d+)>", RegexOptions.Compiled);
protected static readonly Regex EmojiMatch = new Regex(@"<:(?<name>[A-Za-z0-9_]{2,}):(?<ID>\d+)>", RegexOptions.Compiled);
#endregion
}
}

View file

@ -1,52 +0,0 @@
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Noikoio.RegexBot.Module.ModTools
{
/// <summary>
/// Represents ModTools configuration within one server.
/// </summary>
class ConfigItem
{
private readonly ReadOnlyDictionary<string, CommandBase> _cmdInstances;
public ReadOnlyDictionary<string, CommandBase> Commands => _cmdInstances;
public ConfigItem(ModTools instance, JToken inconf)
{
if (inconf.Type != JTokenType.Object)
{
throw new RuleImportException("Configuration for this section is invalid.");
}
var config = (JObject)inconf;
// Command instances
var commands = new Dictionary<string, CommandBase>(StringComparer.OrdinalIgnoreCase);
var commandconf = config["Commands"];
if (commandconf != null)
{
if (commandconf.Type != JTokenType.Object)
{
throw new RuleImportException("CommandDefs is not properly defined.");
}
foreach (var def in commandconf.Children<JProperty>())
{
string label = def.Name;
var cmd = CommandBase.CreateInstance(instance, 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 {commands[cmd.Command].Label}.");
commands.Add(cmd.Command, cmd);
}
}
_cmdInstances = new ReadOnlyDictionary<string, CommandBase>(commands);
}
}
}

View file

@ -56,7 +56,7 @@ namespace Noikoio.RegexBot
{ {
new Module.DMLogger.DMLogger(_client), new Module.DMLogger.DMLogger(_client),
new Module.AutoMod.AutoMod(_client), new Module.AutoMod.AutoMod(_client),
new Module.ModTools.ModTools(_client), new Module.ModCommands.CommandListener(_client),
new Module.AutoRespond.AutoRespond(_client), new Module.AutoRespond.AutoRespond(_client),
// EntityCache loads before anything using it // EntityCache loads before anything using it

View file

@ -1,33 +1,31 @@
## ModTools ## ModCommands
ModTools is the current name for the component that provides commands for use by moderators. Commands are defined based on a number of available template-like *type*s, which can then be customized with further configuration. ModCommands is the name of the component that provides the ability for one to create useful commands for moderation. These commands are defined based on a number of available template-like *type*s, which can then be customized with further configuration.
Sample within a [server definition](serverdef.html): Sample within a [server definition](serverdef.html):
``` ```
"ModTools": { "ModCommands": {
"Commands": { "Kick": { // a plain and simple kick command
"Kick": { // a plain and simple kick command "type": "kick",
"type": "kick", "command": "!!kick"
"command": "!!kick" },
}, "Party Ban": { // self-explanatory
"Party Ban": { // self-explanatory "type": "ban",
"type": "ban", "command": "!!party",
"command": "!!party", "successmsg": "Yay! $target got banned!\nhttps://i.imgur.com/i4CIBtT.jpg"
"successmsg": "Yay! $target got banned!\nhttps://i.imgur.com/i4CIBtT.jpg"
}
} }
} }
``` ```
### Definition structure ### Definition structure
Commands are defined within a JSON object named `Commands` within another object named `ModTools`. They are defined by means of name/value pairs, with the name serving as its label. Commands are defined within a `ModCommands` object, itself within a [server definition](serverdef.html). They are defined by means of name/value pairs, with the name serving as its label.
The following values are **required** in a definition: The following values are **required** in a definition:
* type (*string*) - Specifies the behavior that the command should take. * type (*string*) - Specifies the type of behavior that the command should take.
* command (*string*) - The text trigger for the command being defined. Must not contain spaces, and it is recommended to start it with an uncommon symbol, such as `!`. * command (*string*) - The text trigger for the command being defined. Must not contain spaces, and it is recommended to start it with an uncommon symbol, such as `!`.
### Command types ### Command types
Each command type specifies the action taken by the bot when the command is invoked. Certain types offer additional configuration options as well. Each command type specifies the action taken by the bot when the command is invoked. Certain types offer additional configuration options.
#### Ban #### Ban
* `"type": "ban"` * `"type": "ban"`
@ -44,10 +42,15 @@ Additional behavior can be specified in its configuration:
* Uses a default message if this configuration value is not specified. * Uses a default message if this configuration value is not specified.
* To disable, specify a blank value. * To disable, specify a blank value.
#### Configuration Reload
* `"type": "confreload"`
* Usage: (*command*)
Reloads server configuration. The bot will reply indicating if the reload was successful.
#### Kick #### Kick
* `"type": "kick"` * `"type": "kick"`
* Usage: (*command*) (*user name or ID*) [*reason*] * Usage: (*command*) (*user name or ID*) [*reason*]
Removes the given user from the server the command was invoked in, and sends the reason, if any, to the server's audit log. Removes the given user from the server the command was invoked in and sends the reason, if any, to the server's audit log.
Additional behavior can be specified in its configuration: Additional behavior can be specified in its configuration:
* forcereason (*boolean*) - Forces the reason to be specified if set to true. If none is specified, the action is not taken. Defaults to *false*. * forcereason (*boolean*) - Forces the reason to be specified if set to true. If none is specified, the action is not taken. Defaults to *false*.
@ -58,6 +61,16 @@ Additional behavior can be specified in its configuration:
* Uses a default message if this configuration value is not specified. * Uses a default message if this configuration value is not specified.
* To disable, specify a blank value. * To disable, specify a blank value.
#### Role manipulation
* `"type": "addrole"` or `"type": "delrole"
* Usage: (*command*) (*user name or ID*)
Sets or unsets a predefined role upon the given user.
Additional configuration:
* role (*string*) - The role that applies to this command. May be defined in the same type of format accepted within [entity lists](entitylist.html).
* successmsg (*string*) - Custom message to display on success. If not specified, a default message is used.
* The string *$target* can be used in the value to represent the command target.
#### Say #### Say
* `"type": "say"` * `"type": "say"`
* Usage: (*command*) (*channel name or ID*) (*message*) * Usage: (*command*) (*channel name or ID*) (*message*)

View file

@ -25,7 +25,7 @@ servers: [
The following is a list of accepted members within a server definition. The following is a list of accepted members within a server definition.
* id (*integer*) - **Required.** A value containing the server's [unique ID](https://support.discordapp.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). * id (*integer*) - **Required.** A value containing the server's [unique ID](https://support.discordapp.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-).
* name (*string*) - Preferably a readable version of the server's name. Not used for anything other than internal logging. * name (*string*) - Preferably a readable version of the server's name. Not used for anything other than internal logging.
* moderators (*[entity list](entitylist.html)*) - A list of entities to consider as moderators. Actions done by members of this list are able to execute *ModTools* commands and are exempt from certain *AutoMod* rules. See their respective pages for more details. * moderators (*[entity list](entitylist.html)*) - A list of entities to consider as moderators. Actions done by members of this list are able to execute *ModCommands* commands and are exempt from certain *AutoMod* rules. See their respective pages for more details.
* [automod](automod.html) (*name/value pairs*) - See respective page. * [automod](automod.html) (*name/value pairs*) - See respective page.
* [autoresponses](autorespond.html) (*name/value pairs*) - See respective page. * [autoresponses](autorespond.html) (*name/value pairs*) - See respective page.
* [ModTools](modtools.html) (*name/value pairs*) - See respective page. * [ModCommands](modcommands.html) (*name/value pairs*) - See respective page.