diff --git a/Module/ModTools/ModTools.cs b/Module/ModCommands/CommandListener.cs
similarity index 67%
rename from Module/ModTools/ModTools.cs
rename to Module/ModCommands/CommandListener.cs
index 231de46..884e31c 100644
--- a/Module/ModTools/ModTools.cs
+++ b/Module/ModCommands/CommandListener.cs
@@ -6,30 +6,35 @@ using System;
using System.Linq;
using System.Threading.Tasks;
-namespace Noikoio.RegexBot.Module.ModTools
+namespace Noikoio.RegexBot.Module.ModCommands
{
///
- /// ModTools module.
/// 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.
///
- class ModTools : BotModule
+ ///
+ /// 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.
+ ///
+ 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;
}
private async Task Client_MessageReceived(SocketMessage arg)
{
- // Always ignore bots
- if (arg.Author.IsBot) return;
+ // Always ignore these
+ if (arg.Author.IsBot || arg.Author.IsWebhook) return;
if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg);
}
- [ConfigSection("ModTools")]
+ [ConfigSection("ModCommands")]
public override async Task ProcessConfiguration(JToken configSection)
{
// Constructor throws exception on config errors
@@ -69,12 +74,13 @@ namespace Noikoio.RegexBot.Module.ModTools
{
try
{
- await Log($"'{c.Label}' invoked by {arg.Author.ToString()} in {g.Name}/#{arg.Channel.Name}");
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)
{
- 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());
}
}
diff --git a/Module/ModTools/Commands/BanKick.cs b/Module/ModCommands/Commands/BanKick.cs
similarity index 72%
rename from Module/ModTools/Commands/BanKick.cs
rename to Module/ModCommands/Commands/BanKick.cs
index 2009926..ec644cd 100644
--- a/Module/ModTools/Commands/BanKick.cs
+++ b/Module/ModCommands/Commands/BanKick.cs
@@ -1,17 +1,14 @@
-using Discord;
-using Discord.WebSocket;
+using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
-using System.Linq;
-using System.Text.RegularExpressions;
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 }
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
// 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.
- 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;
_forceReason = conf["forcereason"]?.Value() ?? false;
@@ -49,13 +46,20 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
if (string.IsNullOrWhiteSpace(val)) _notifyMsg = null; // empty value - disable message
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
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 target user is no longer available.";
- const string FailDefault = "An unknown error occurred. Notify the bot operator.";
+ const string Fail404 = "The specified user is no longer in the server.";
const string NotifyDefault = "You have been {0} from $s for the following reason:\n$r";
const string NotifyReasonNone = "No reason specified.";
const string NotifyFailed = "\n(User was unable to receive notification message.)";
@@ -71,7 +75,7 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
string reason;
if (line.Length < 2)
{
- await SendUsageMessage(msg, null);
+ await SendUsageMessageAsync(msg.Channel, null);
return;
}
targetstr = line[1];
@@ -86,33 +90,36 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
// No reason given
if (_forceReason)
{
- await SendUsageMessage(msg, ReasonRequired);
+ await SendUsageMessageAsync(msg.Channel, ReasonRequired);
return;
}
reason = null;
}
- // Getting SocketGuildUser target
- SocketGuildUser targetobj = null;
-
- // 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)
+ // Retrieve target user
+ var (targetId, targetData) = await GetUserDataFromString(g.Id, targetstr);
+ if (targetId == 1)
{
- await SendUsageMessage(msg, TargetNotFound);
+ await msg.Channel.SendMessageAsync(FailPrefix + FailDefault);
return;
}
- ulong targetuid = qres.UserId;
- targetobj = g.GetUser(targetuid);
- string targetdisp = targetobj?.ToString() ?? $"ID {targetuid}";
+ if (targetId == 0)
+ {
+ 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)
{
// Can't kick without obtaining the user object
- await SendUsageMessage(msg, TargetNotFound);
+ await SendUsageMessageAsync(msg.Channel, TargetNotFound);
return;
}
@@ -125,15 +132,16 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
string reasonlog = $"Invoked by {msg.Author.ToString()}.";
if (reason != null) reasonlog += $" Reason: {reason}";
reasonlog = Uri.EscapeDataString(reasonlog);
+ await notifyTask;
#warning Remove EscapeDataString call on next Discord.Net update
#if !DEBUG
- if (_mode == CommandMode.Ban) await g.AddBanAsync(targetuid, _purgeDays, reasonlog);
- else await targetobj.KickAsync(reason);
+ if (_mode == CommandMode.Ban) await g.AddBanAsync(targetId, _purgeDays, reasonlog);
+ else await targetobj.KickAsync(reasonlog);
#else
#warning "Actual kick/ban action is DISABLED during debug."
#endif
string resultmsg = BuildSuccessMessage(targetdisp);
- if (await notifyTask == false) resultmsg += NotifyFailed;
+ if (notifyTask.Result == false) resultmsg += NotifyFailed;
await msg.Channel.SendMessageAsync(resultmsg);
}
catch (Discord.Net.HttpException ex)
@@ -178,22 +186,6 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
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)
{
const string defaultmsgBan = ":white_check_mark: Banned user **$target**.";
@@ -207,13 +199,13 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands
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) { }
}
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) { }
}
}
diff --git a/Module/ModCommands/Commands/ConfReload.cs b/Module/ModCommands/Commands/ConfReload.cs
new file mode 100644
index 0000000..22d66df
--- /dev/null
+++ b/Module/ModCommands/Commands/ConfReload.cs
@@ -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.
+ }
+}
diff --git a/Module/ModCommands/Commands/RoleManipulation.cs b/Module/ModCommands/Commands/RoleManipulation.cs
new file mode 100644
index 0000000..e6a0769
--- /dev/null
+++ b/Module/ModCommands/Commands/RoleManipulation.cs
@@ -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();
+ if (string.IsNullOrWhiteSpace(rolestr)) throw new RuleImportException("Role must be provided.");
+ _role = new EntityName(rolestr, EntityType.Role);
+ _successMsg = conf["successmsg"]?.Value();
+
+ 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) { }
+ }
+}
diff --git a/Module/ModTools/Commands/Say.cs b/Module/ModCommands/Commands/Say.cs
similarity index 64%
rename from Module/ModTools/Commands/Say.cs
rename to Module/ModCommands/Commands/Say.cs
index baf2424..ea8518a 100644
--- a/Module/ModTools/Commands/Say.cs
+++ b/Module/ModCommands/Commands/Say.cs
@@ -1,49 +1,44 @@
-using Discord;
-using Discord.WebSocket;
+using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
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
+ 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)
{
string[] line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
if (line.Length <= 1)
{
- await SendUsageMessage(msg, ":x: You must specify a channel.");
+ await SendUsageMessageAsync(msg.Channel, ChannelRequired);
return;
}
if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2]))
{
- await SendUsageMessage(msg, ":x: You must specify a message.");
+ await SendUsageMessageAsync(msg.Channel, MessageRequired);
return;
}
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]);
}
- 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)
{
// Method 1: Check for channel mention
diff --git a/Module/ModCommands/Commands/Unban.cs b/Module/ModCommands/Commands/Unban.cs
new file mode 100644
index 0000000..a2e74bf
--- /dev/null
+++ b/Module/ModCommands/Commands/Unban.cs
@@ -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());
+ }
+ }
+ }
+ }
+}
diff --git a/Module/ModCommands/Commands/_CommandBase.cs b/Module/ModCommands/Commands/_CommandBase.cs
new file mode 100644
index 0000000..fa58f9b
--- /dev/null
+++ b/Module/ModCommands/Commands/_CommandBase.cs
@@ -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
+{
+ ///
+ /// Base class for a command within the module.
+ /// After implementing, don't forget to add a reference to
+ /// .
+ ///
+ [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();
+ }
+
+ 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 _commands =
+ new ReadOnlyDictionary(
+ new Dictionary(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();
+ 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}'.");
+ 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(@"<@!?(?\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);
+ 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; }
+ ///
+ /// Sends out the default usage message ( ) within an embed.
+ /// An optional message can be included, for uses such as notifying users of incorrect usage.
+ ///
+ /// Target channel for sending the message.
+ /// The message to send alongside the default usage message.
+ 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);
+ }
+
+ ///
+ /// Helper method for turning input into user data. Only returns the first cache result.
+ ///
+ ///
+ /// First value: 0 for no data, 1 for no data + exception.
+ /// May return a partial result: a valid ulong value but no CacheUser.
+ ///
+ 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
+ }
+}
diff --git a/Module/ModCommands/ConfigItem.cs b/Module/ModCommands/ConfigItem.cs
new file mode 100644
index 0000000..7657dff
--- /dev/null
+++ b/Module/ModCommands/ConfigItem.cs
@@ -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
+{
+ ///
+ /// Contains a server's ModCommands configuration.
+ ///
+ class ConfigItem
+ {
+ private readonly ReadOnlyDictionary _cmdInstances;
+
+ public ReadOnlyDictionary 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(StringComparer.OrdinalIgnoreCase);
+ foreach (var def in inconf.Children())
+ {
+ 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(commands);
+ }
+ }
+}
diff --git a/Module/ModTools/CommandBase.cs b/Module/ModTools/CommandBase.cs
deleted file mode 100644
index 9542ea4..0000000
--- a/Module/ModTools/CommandBase.cs
+++ /dev/null
@@ -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
-{
- ///
- /// Base class for ModTools command.
- /// We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime.
- ///
- [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();
- }
-
- 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 _commands =
- new ReadOnlyDictionary(
- new Dictionary(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();
- 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/Module/ModTools/ConfigItem.cs b/Module/ModTools/ConfigItem.cs
deleted file mode 100644
index 5a29ac6..0000000
--- a/Module/ModTools/ConfigItem.cs
+++ /dev/null
@@ -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
-{
- ///
- /// Represents ModTools configuration within one server.
- ///
- class ConfigItem
- {
- private readonly ReadOnlyDictionary _cmdInstances;
-
- public ReadOnlyDictionary 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(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())
- {
- 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(commands);
- }
- }
-}
diff --git a/RegexBot.cs b/RegexBot.cs
index ef73229..e33db08 100644
--- a/RegexBot.cs
+++ b/RegexBot.cs
@@ -56,7 +56,7 @@ namespace Noikoio.RegexBot
{
new Module.DMLogger.DMLogger(_client),
new Module.AutoMod.AutoMod(_client),
- new Module.ModTools.ModTools(_client),
+ new Module.ModCommands.CommandListener(_client),
new Module.AutoRespond.AutoRespond(_client),
// EntityCache loads before anything using it
diff --git a/docs/modtools.md b/docs/modcommands.md
similarity index 62%
rename from docs/modtools.md
rename to docs/modcommands.md
index e7210ef..685099a 100644
--- a/docs/modtools.md
+++ b/docs/modcommands.md
@@ -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):
```
-"ModTools": {
- "Commands": {
- "Kick": { // a plain and simple kick command
- "type": "kick",
- "command": "!!kick"
- },
- "Party Ban": { // self-explanatory
- "type": "ban",
- "command": "!!party",
- "successmsg": "Yay! $target got banned!\nhttps://i.imgur.com/i4CIBtT.jpg"
- }
+"ModCommands": {
+ "Kick": { // a plain and simple kick command
+ "type": "kick",
+ "command": "!!kick"
+ },
+ "Party Ban": { // self-explanatory
+ "type": "ban",
+ "command": "!!party",
+ "successmsg": "Yay! $target got banned!\nhttps://i.imgur.com/i4CIBtT.jpg"
}
}
```
### 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:
-* 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 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
* `"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.
* 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
* `"type": "kick"`
* 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:
* 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.
* 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
* `"type": "say"`
* Usage: (*command*) (*channel name or ID*) (*message*)
diff --git a/docs/serverdef.md b/docs/serverdef.md
index 2d6de3a..00d9980 100644
--- a/docs/serverdef.md
+++ b/docs/serverdef.md
@@ -25,7 +25,7 @@ servers: [
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-).
* 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.
* [autoresponses](autorespond.html) (*name/value pairs*) - See respective page.
-* [ModTools](modtools.html) (*name/value pairs*) - See respective page.
\ No newline at end of file
+* [ModCommands](modcommands.html) (*name/value pairs*) - See respective page.
\ No newline at end of file