diff --git a/BotFeature.cs b/BotFeature.cs
index e3fcf08..deb9c57 100644
--- a/BotFeature.cs
+++ b/BotFeature.cs
@@ -18,6 +18,7 @@ namespace Noikoio.RegexBot
private readonly AsyncLogger _logger;
public abstract string Name { get; }
+ protected DiscordSocketClient Client => _client;
protected BotFeature(DiscordSocketClient client)
{
@@ -60,6 +61,20 @@ namespace Noikoio.RegexBot
if (sc.FeatureConfigs.TryGetValue(this, out var item)) return item;
else return null;
}
+
+ ///
+ /// Determines if the given message author or channel is in the server configuration's moderator list.
+ ///
+ protected bool IsModerator(ulong guildId, SocketMessage m)
+ {
+ var sc = RegexBot.Config.Servers.FirstOrDefault(g => g.Id == guildId);
+ if (sc == null)
+ {
+ throw new ArgumentException("There is no known configuration associated with the given Guild ID.");
+ }
+
+ return sc.Moderators.ExistsInList(m);
+ }
protected async Task Log(string text)
{
diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs
index bc60378..2b2588d 100644
--- a/Feature/AutoMod/AutoMod.cs
+++ b/Feature/AutoMod/AutoMod.cs
@@ -1,31 +1,96 @@
-using System;
-using System.Collections.Generic;
-using System.Text;
-using System.Threading.Tasks;
-using Discord.WebSocket;
+using Discord.WebSocket;
using Newtonsoft.Json.Linq;
+using Noikoio.RegexBot.Feature.AutoMod.Responses;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
namespace Noikoio.RegexBot.Feature.AutoMod
{
///
/// Implements per-message regex matching and executes customizable responses.
/// The name RegexBot comes from the existence of this feature.
- ///
- /// Strictly for use as a moderation tool only. Triggers that respond only to messages
- /// should be configured using .
///
+ ///
+ /// Strictly for use as a moderation tool only. Triggers that simply reply to messages
+ /// should be implemented using .
+ ///
class AutoMod : BotFeature
{
public override string Name => "AutoMod";
public AutoMod(DiscordSocketClient client) : base(client)
{
- throw new NotImplementedException();
+ client.MessageReceived += CMessageReceived;
+ client.MessageUpdated += CMessageUpdated;
}
- public override Task ProcessConfiguration(JToken configSection)
+ [ConfigSection("automod")]
+ public override async Task ProcessConfiguration(JToken configSection)
{
- throw new NotImplementedException();
+ List rules = new List();
+ foreach (JObject ruleconf in configSection)
+ {
+ var rule = new Rule(this, ruleconf);
+ rules.Add(rule);
+ await Log($"Added rule '{rule.Label}'");
+ }
+ return rules.AsReadOnly();
}
+
+ private async Task CMessageReceived(SocketMessage arg)
+ => await ReceiveMessage(arg);
+ private async Task CMessageUpdated(Discord.Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3)
+ => await ReceiveMessage(arg2);
+
+ ///
+ /// Does initial message checking before sending to further processing.
+ ///
+ private async Task ReceiveMessage(SocketMessage m)
+ {
+ // Determine if incoming channel is in a guild
+ var ch = m.Channel as SocketGuildChannel;
+ if (ch == null) return;
+
+ // Get rules
+ var rules = GetConfig(ch.Guild.Id) as IEnumerable;
+ if (rules == null) return;
+
+ foreach (var rule in rules)
+ {
+ // Checking for mod bypass here (Rule doesn't have access to mod list)
+ bool isMod = IsModerator(ch.Guild.Id, m);
+ //await Task.Run(async () => await ProcessMessage(m, rule, isMod));
+ await ProcessMessage(m, rule, isMod);
+ }
+ }
+
+ ///
+ /// Checks if the incoming message matches the given rule, and executes responses if necessary.
+ ///
+ private async Task ProcessMessage(SocketMessage m, Rule r, bool isMod)
+ {
+ if (!r.Match(m, isMod)) return;
+
+ // TODO make log optional; configurable
+ await Log($"{r} triggered by {m.Author.ToString()}");
+
+ foreach (Response resp in r.Response)
+ {
+ // TODO foreach await (when that becomes available)
+ try
+ {
+ await resp.Invoke(m);
+ }
+ catch (Exception ex)
+ {
+ await Log($"Encountered an error while processing '{resp.CmdArg0}'. Details follow:");
+ await Log(ex.ToString());
+ }
+ }
+ }
+
+ public new Task Log(string text) => base.Log(text);
+ public new DiscordSocketClient Client => base.Client;
}
}
diff --git a/Feature/AutoMod/Responses/Ban.cs b/Feature/AutoMod/Responses/Ban.cs
new file mode 100644
index 0000000..41b7139
--- /dev/null
+++ b/Feature/AutoMod/Responses/Ban.cs
@@ -0,0 +1,50 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Bans the invoking user.
+ /// Parameters: ban [days = 0]
+ ///
+ class Ban : Response
+ {
+ readonly int _purgeDays;
+
+ public Ban(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (line.Length == 1)
+ {
+ _purgeDays = 0;
+ }
+ else if (line.Length == 2)
+ {
+ if (int.TryParse(line[1], out _purgeDays))
+ {
+ if (_purgeDays < 0 || _purgeDays > 7)
+ {
+ throw new RuleImportException("Parameter must be an integer between 0 and 7.");
+ }
+ }
+ else
+ {
+ throw new RuleImportException("Parameter must be an integer between 0 and 7.");
+ }
+ }
+ else
+ {
+ throw new RuleImportException("Incorrect number of parameters.");
+ }
+ }
+
+ public override async Task Invoke(SocketMessage msg)
+ {
+ var target = (SocketGuildUser)msg.Author;
+ await target.Guild.AddBanAsync(target, _purgeDays, Uri.EscapeDataString($"Rule '{Rule.Label}'"));
+ // TODO remove string escaping when fixed in library
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Kick.cs b/Feature/AutoMod/Responses/Kick.cs
new file mode 100644
index 0000000..df43cfe
--- /dev/null
+++ b/Feature/AutoMod/Responses/Kick.cs
@@ -0,0 +1,28 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Kicks the invoking user.
+ /// Takes no parameters.
+ ///
+ class Kick : Response
+ {
+ public Kick(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ // Throw exception if extra parameters found
+ if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
+ throw new RuleImportException("Incorrect number of parameters.");
+ }
+
+ public override async Task Invoke(SocketMessage msg)
+ {
+ var target = (SocketGuildUser)msg.Author;
+ await target.KickAsync(Uri.EscapeDataString($"Rule '{Rule.Label}'"));
+ // TODO remove string escaping when fixed in library
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Remove.cs b/Feature/AutoMod/Responses/Remove.cs
new file mode 100644
index 0000000..7f4d949
--- /dev/null
+++ b/Feature/AutoMod/Responses/Remove.cs
@@ -0,0 +1,23 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Removes the invoking message.
+ /// Takes no parameters.
+ ///
+ class Remove : Response
+ {
+ public Remove(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ // Throw exception if extra parameters found
+ if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
+ throw new RuleImportException("Incorrect number of parameters.");
+ }
+
+ public override Task Invoke(SocketMessage msg) => msg.DeleteAsync();
+ }
+}
diff --git a/Feature/AutoMod/Responses/Report.cs b/Feature/AutoMod/Responses/Report.cs
new file mode 100644
index 0000000..00efe50
--- /dev/null
+++ b/Feature/AutoMod/Responses/Report.cs
@@ -0,0 +1,93 @@
+using Discord;
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Sends a summary of the invoking message, along with information
+ /// about the rule making use of this command, to the given target.
+ /// Parameters: report (target)
+ ///
+ class Report : Response
+ {
+ readonly string _target;
+
+ public Report(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (line.Length != 2) throw new RuleImportException("Incorrect number of parameters");
+ _target = line[1];
+ }
+
+ public override async Task Invoke(SocketMessage msg)
+ {
+ var target = await GetMessageTargetAsync(_target, msg);
+ if (target == null)
+ {
+ await Log("Error: Unable to resolve the given target.");
+ }
+ await target.SendMessageAsync("", embed: BuildReportEmbed(msg));
+ }
+
+ private EmbedBuilder BuildReportEmbed(SocketMessage msg)
+ {
+ string invokeLine = msg.Content;
+
+ var responsebody = new StringBuilder();
+ responsebody.AppendLine("```");
+ foreach (var item in Rule.Response)
+ {
+ responsebody.AppendLine(item.CmdLine.Replace("\r", "").Replace("\n", "\\n"));
+ }
+ responsebody.Append("```");
+
+ // Discord has a 2000 character limit per single message.
+ // Enforcing separate length limits on line and response.
+ const int DescriptionLengthMax = 1600;
+ const int ResponseBodyLengthMax = 200;
+ if (invokeLine.Length > DescriptionLengthMax)
+ {
+ invokeLine = $"**Message length too long; showing first {DescriptionLengthMax} characters.**\n\n"
+ + invokeLine.Substring(0, DescriptionLengthMax);
+ }
+ if (responsebody.Length > ResponseBodyLengthMax)
+ {
+ responsebody = new StringBuilder("(Response body too large to display.)");
+ }
+
+ return new EmbedBuilder()
+ {
+ Color = new Color(0xEDCE00), // configurable later?
+
+ Author = new EmbedAuthorBuilder()
+ {
+ Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
+ IconUrl = msg.Author.GetAvatarUrl()
+ },
+ Description = invokeLine,
+
+ Footer = new EmbedFooterBuilder()
+ {
+ Text = $"Rule '{Rule.Label}'",
+ IconUrl = Rule.Discord.CurrentUser.GetAvatarUrl()
+ },
+ Timestamp = msg.EditedTimestamp ?? msg.Timestamp
+ }.AddField(new EmbedFieldBuilder()
+ {
+ Name = "Additional info",
+ Value = $"Username: {msg.Author.Mention}\n"
+ + $"Channel: <#{msg.Channel.Id}> #{msg.Channel.Name} ({msg.Channel.Id})\n"
+ + $"Message ID: {msg.Id}"
+ }).AddField(new EmbedFieldBuilder()
+ {
+ // TODO consider replacing with configurable note. this section is a bit too much
+ Name = "Executing response:",
+ Value = responsebody.ToString()
+ });
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Response.cs b/Feature/AutoMod/Responses/Response.cs
new file mode 100644
index 0000000..5149aed
--- /dev/null
+++ b/Feature/AutoMod/Responses/Response.cs
@@ -0,0 +1,171 @@
+using Discord;
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Base class for all Response classes.
+ /// Contains helper methods for use by response code.
+ ///
+ [DebuggerDisplay("Response: {_cmdline}")]
+ abstract class Response
+ {
+ private readonly Rule _rule;
+ private readonly string _cmdline;
+
+ protected Rule Rule => _rule;
+ private DiscordSocketClient Client => _rule.Discord;
+ public string CmdLine => _cmdline;
+ public string CmdArg0 {
+ get {
+ int i = _cmdline.IndexOf(' ');
+ if (i != -1) return _cmdline.Substring(0, i);
+ return _cmdline;
+ }
+ }
+
+ ///
+ /// Deriving constructor should do validation of incoming .
+ ///
+ public Response(Rule rule, string cmdline)
+ {
+ _rule = rule;
+ _cmdline = cmdline;
+ }
+
+ public abstract Task Invoke(SocketMessage msg);
+
+ protected async Task Log(string text)
+ {
+ int dl = _cmdline.IndexOf(' ');
+ var prefix = _cmdline.Substring(0, dl);
+ await Rule.Logger(prefix + ": " + text);
+ }
+
+ #region Config loading
+ private static readonly ReadOnlyDictionary _commands =
+ new ReadOnlyDictionary(
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ // Define all accepted commands and their corresponding types here
+ { "say", typeof(Say) },
+ { "send", typeof(Say) },
+ { "report", typeof(Report) },
+ { "addrole", typeof(RoleManipulation) },
+ { "grantrole", typeof(RoleManipulation) },
+ { "delrole", typeof(RoleManipulation) },
+ { "removerole", typeof(RoleManipulation) },
+ { "revokerole", typeof(RoleManipulation) },
+ { "delete", typeof(Remove) },
+ { "remove", typeof(Remove) },
+ { "kick", typeof(Kick) },
+ { "ban", typeof(Ban) }
+ });
+
+ public static Response[] ReadConfiguration(Rule r, IEnumerable responses)
+ {
+ var result = new List();
+ foreach (var line in responses)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ throw new RuleImportException("Empty response line");
+ int i = line.IndexOf(' ');
+ string basecmd;
+ if (i != -1) basecmd = line.Substring(0, i);
+ else basecmd = line;
+
+ Type rt;
+ if (!_commands.TryGetValue(basecmd, out rt))
+ throw new RuleImportException($"'{basecmd}' is not a valid response");
+
+ var newresponse = Activator.CreateInstance(rt, r, line) as Response;
+ if (newresponse == null)
+ throw new Exception("An unknown error occurred when attempting to create a new Response object.");
+ result.Add(newresponse);
+ }
+ return result.ToArray();
+ }
+ #endregion
+
+ #region Helper methods
+ ///
+ /// Receives a string (beginning with @ or #) and returns an object
+ /// suitable for sending out messages
+ ///
+ protected async Task GetMessageTargetAsync(string targetName, SocketMessage m)
+ {
+ const string AEShort = "Target name is too short.";
+
+ EntityType et;
+ if (targetName.Length <= 1) throw new ArgumentException(AEShort);
+
+ if (targetName[0] == '#') et = EntityType.Channel;
+ else if (targetName[0] == '@') et = EntityType.User;
+ else throw new ArgumentException("Target is not specified to be either a channel or user.");
+
+ targetName = targetName.Substring(1);
+ if (targetName == "_")
+ {
+ if (et == EntityType.Channel) return m.Channel;
+ else return await m.Author.GetOrCreateDMChannelAsync();
+ }
+
+ EntityName ei = new EntityName(targetName, et);
+ SocketGuild g = ((SocketGuildUser)m.Author).Guild;
+
+ if (et == EntityType.Channel)
+ {
+ if (targetName.Length < 2 || targetName.Length > 100)
+ throw new ArgumentException(AEShort);
+
+ foreach (var ch in g.TextChannels)
+ {
+ if (ei.Id.HasValue)
+ {
+ if (ei.Id.Value == ch.Id) return ch;
+ }
+ else
+ {
+ if (string.Equals(ei.Name, ch.Name, StringComparison.OrdinalIgnoreCase)) return ch;
+ }
+ }
+ }
+ else
+ {
+ if (ei.Id.HasValue)
+ {
+ // The easy way
+ return await Client.GetUser(ei.Id.Value).GetOrCreateDMChannelAsync();
+ }
+
+ // The hard way
+ foreach (var u in g.Users)
+ {
+ if (string.Equals(ei.Name, u.Username, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(ei.Name, u.Nickname, StringComparison.OrdinalIgnoreCase))
+ {
+ return await u.GetOrCreateDMChannelAsync();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ protected string ProcessText(string input, SocketMessage m)
+ {
+ // Maybe in the future this will do more.
+ // For now, replaces all instances of @_ with the message sender.
+ return input
+ .Replace("@_", m.Author.Mention)
+ .Replace("@\\_", "@_");
+ }
+ #endregion
+ }
+}
diff --git a/Feature/AutoMod/Responses/RoleManipulation.cs b/Feature/AutoMod/Responses/RoleManipulation.cs
new file mode 100644
index 0000000..3fd57ba
--- /dev/null
+++ b/Feature/AutoMod/Responses/RoleManipulation.cs
@@ -0,0 +1,89 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Manipulates a given user's role.
+ /// Parameters: (command) (target) (role ID)
+ ///
+ class RoleManipulation : Response
+ {
+ enum ManipulationType { None, Add, Remove }
+
+ readonly ManipulationType _action;
+ readonly string _target;
+ readonly EntityName _role;
+
+ public RoleManipulation(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
+ if (line.Length != 3)
+ throw new RuleImportException("Incorrect number of parameters.");
+
+ // Ensure the strings here match those in Response._commands
+ switch (line[0].ToLowerInvariant())
+ {
+ case "addrole":
+ case "grantrole":
+ _action = ManipulationType.Add;
+ break;
+ case "delrole":
+ case "removerole":
+ case "revokerole":
+ _action = ManipulationType.Remove;
+ break;
+ default:
+ _action = ManipulationType.None;
+ break;
+ }
+ if (_action == ManipulationType.None)
+ throw new RuleImportException("Command not defined. This is a bug.");
+
+ _target = line[1];
+ _role = new EntityName(line[2], EntityType.Role);
+ }
+
+ public override async Task Invoke(SocketMessage msg)
+ {
+ // Find role
+ SocketRole rtarget;
+ var g = ((SocketGuildUser)msg.Author).Guild;
+ if (_role.Id.HasValue) rtarget = g.GetRole(_role.Id.Value);
+ else rtarget = g.Roles.FirstOrDefault(r =>
+ string.Equals(r.Name, _role.Name, StringComparison.OrdinalIgnoreCase));
+ if (rtarget == null)
+ {
+ await Log("Error: Target role not found in server.");
+ return;
+ }
+
+ // Find user
+ SocketGuildUser utarget;
+ if (_target == "@_") utarget = (SocketGuildUser)msg.Author;
+ else
+ {
+ utarget = g.Users.FirstOrDefault(u =>
+ {
+ if (string.Equals(u.Nickname, _target, StringComparison.OrdinalIgnoreCase)) return true;
+ if (string.Equals(u.Username, _target, StringComparison.OrdinalIgnoreCase)) return true;
+ return false;
+ });
+ }
+ if (utarget == null)
+ {
+ await Log("Error: Target user not found in server.");
+ return;
+ }
+
+ // Do action
+ if (_action == ManipulationType.Add)
+ await utarget.AddRoleAsync(rtarget);
+ else if (_action == ManipulationType.Remove)
+ await utarget.RemoveRoleAsync(rtarget);
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Say.cs b/Feature/AutoMod/Responses/Say.cs
new file mode 100644
index 0000000..3dcbc8e
--- /dev/null
+++ b/Feature/AutoMod/Responses/Say.cs
@@ -0,0 +1,45 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Sends a message to the given target.
+ /// Parameters: say (target) (message)
+ ///
+ class Say : Response
+ {
+ private readonly string _target;
+ private readonly string _payload;
+
+ public Say(Rule rule, string cmdline) : base(rule, cmdline)
+ {
+ var line = cmdline.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
+ if (line.Length != 3) throw new RuleImportException("Incorrect number of parameters.");
+
+ // Very basic target verification. Could be improved?
+ if (line[1][0] != '@' && line[1][0] != '#')
+ throw new RuleImportException("The given target is not valid.");
+ _target = line[1];
+
+ _payload = line[2];
+ if (string.IsNullOrWhiteSpace(_payload))
+ throw new RuleImportException("Message parameter is blank or missing.");
+ }
+
+ public override async Task Invoke(SocketMessage msg)
+ {
+ // CHANGE THE SAY
+ string reply = ProcessText(_payload, msg);
+
+ var target = await GetMessageTargetAsync(_target, msg);
+ if (target == null)
+ {
+ await Log("Error: Unable to resolve the given target.");
+ }
+ await target.SendMessageAsync(reply);
+ }
+ }
+}
diff --git a/Feature/AutoMod/Rule.cs b/Feature/AutoMod/Rule.cs
new file mode 100644
index 0000000..3c494a5
--- /dev/null
+++ b/Feature/AutoMod/Rule.cs
@@ -0,0 +1,212 @@
+using Discord;
+using Discord.WebSocket;
+using Newtonsoft.Json.Linq;
+using Noikoio.RegexBot.ConfigItem;
+using Noikoio.RegexBot.Feature.AutoMod.Responses;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod
+{
+ ///
+ /// Representation of a single AutoMod rule.
+ /// Data stored within cannot be edited.
+ ///
+ class Rule
+ {
+ readonly AutoMod _instance;
+ readonly string _label;
+ readonly IEnumerable _regex;
+ readonly ICollection _responses;
+ readonly FilterList _filter;
+ readonly int _msgMinLength;
+ readonly int _msgMaxLength;
+ readonly bool _modBypass;
+ readonly bool _embedMode;
+
+ public string Label => _label;
+ public IEnumerable Regex => _regex;
+ public ICollection Response => _responses;
+ public FilterList Filter => _filter;
+ public (int?, int?) MatchLengthMinMaxLimit => (_msgMinLength, _msgMaxLength);
+ public bool AllowsModBypass => _modBypass;
+ public bool MatchEmbed => _embedMode;
+
+ public DiscordSocketClient Discord => _instance.Client;
+ public Func Logger => _instance.Log;
+
+ ///
+ /// Creates a new Rule instance to represent the given configuration.
+ ///
+ public Rule(AutoMod instance, JObject ruleconf)
+ {
+ _instance = instance;
+
+ _label = ruleconf["label"]?.Value();
+ if (string.IsNullOrEmpty(_label))
+ throw new RuleImportException("Label not defined.");
+
+ string errpfx = $" in definition for rule '{_label}'.";
+
+ // regex options
+ RegexOptions opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
+ // TODO consider adding an option to specify Singleline and Multiline matching
+ opts |= RegexOptions.Singleline;
+ // case sensitivity must be explicitly defined, else not case sensitive by default
+ bool? regexci = ruleconf["ignorecase"]?.Value();
+ opts |= RegexOptions.IgnoreCase;
+ if (regexci.HasValue && regexci.Value == false)
+ opts &= ~RegexOptions.IgnoreCase;
+
+ // regex
+ const string NoRegexError = "No regular expression patterns are defined";
+ var regexes = new List();
+ var rxconf = ruleconf["regex"];
+ if (rxconf == null) throw new RuleImportException(NoRegexError + errpfx);
+ if (rxconf.Type == JTokenType.Array)
+ {
+ foreach (var input in rxconf.Values())
+ {
+ try
+ {
+ Regex r = new Regex(input, opts);
+ regexes.Add(r);
+ }
+ catch (ArgumentException)
+ {
+ throw new RuleImportException(
+ $"Failed to parse regular expression pattern '{input}'{errpfx}");
+ }
+ }
+ }
+ else
+ {
+ string rxstr = rxconf.Value();
+ try
+ {
+ Regex r = new Regex(rxstr, opts);
+ regexes.Add(r);
+ }
+ catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
+ {
+ throw new RuleImportException(
+ $"Failed to parse regular expression pattern '{rxstr}'{errpfx}");
+ }
+ }
+ if (regexes.Count == 0)
+ {
+ throw new RuleImportException(NoRegexError + errpfx);
+ }
+ _regex = regexes.ToArray();
+
+ // min/max length
+ try
+ {
+ _msgMinLength = ruleconf["min"]?.Value() ?? -1;
+ _msgMaxLength = ruleconf["max"]?.Value() ?? -1;
+ }
+ catch (FormatException)
+ {
+ throw new RuleImportException("Minimum/maximum values must be an integer.");
+ }
+
+ // responses
+ const string NoResponseError = "No responses have been defined";
+ var responsestrs = new List();
+ var rsconf = ruleconf["response"];
+ if (rsconf == null) throw new RuleImportException(NoResponseError + errpfx);
+ try
+ {
+ if (rsconf.Type == JTokenType.Array)
+ {
+ _responses = Responses.Response.ReadConfiguration(this, rsconf.Values());
+ }
+ else
+ {
+ _responses = Responses.Response.ReadConfiguration(this, new string[] { rsconf.Value() });
+ }
+ }
+ catch (RuleImportException ex)
+ {
+ throw new RuleImportException(ex.Message + errpfx);
+ }
+
+
+ // whitelist/blacklist filtering
+ _filter = new FilterList(ruleconf);
+
+ // moderator bypass toggle - true by default, must be explicitly set to false
+ bool? bypass = ruleconf["AllowModBypass"]?.Value();
+ _modBypass = bypass.HasValue ? bypass.Value : true;
+
+ // embed matching mode
+ bool? embed = ruleconf["MatchEmbeds"]?.Value();
+ _embedMode = (embed.HasValue && embed == true);
+ }
+
+ ///
+ /// Checks given message to see if it matches this rule's constraints.
+ ///
+ /// If true, the rule's response(s) should be executed.
+ public bool Match(SocketMessage m, bool isMod)
+ {
+ // Regular or embed mode?
+ string msgcontent;
+ if (MatchEmbed) msgcontent = SerializeEmbed(m.Embeds);
+ else msgcontent = m.Content;
+
+ // Min/max length check
+ if (_msgMinLength != -1 && msgcontent.Length <= _msgMinLength) return false;
+ if (_msgMaxLength != -1 && msgcontent.Length >= _msgMaxLength) return false;
+
+ // Filter check
+ if (Filter.IsFiltered(m)) return false;
+ // Mod bypass check
+ if (AllowsModBypass && isMod) return false;
+
+ // Finally, regex checks
+ foreach (var regex in Regex)
+ {
+ if (regex.IsMatch(msgcontent)) return true;
+ }
+ return false;
+ }
+
+ private string SerializeEmbed(IReadOnlyCollection e)
+ {
+ var text = new StringBuilder();
+ foreach (var item in e) text.AppendLine(SerializeEmbed(item));
+ return text.ToString();
+ }
+
+ ///
+ /// Converts an embed to a plain string for easier matching.
+ ///
+ private string SerializeEmbed(Embed e)
+ {
+ StringBuilder result = new StringBuilder();
+ if (e.Author.HasValue) result.AppendLine(e.Author.Value.Name ?? "" + e.Author.Value.Url ?? "");
+
+ if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
+ if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
+
+ foreach (var f in e.Fields)
+ {
+ if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
+ if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
+ }
+
+ if (e.Footer.HasValue)
+ {
+ result.AppendLine(e.Footer.Value.Text ?? "");
+ }
+
+ return result.ToString();
+ }
+
+ public override string ToString() => $"Rule '{Label}'";
+ }
+}
diff --git a/RegexBot.cs b/RegexBot.cs
index 9ad5d9d..b093021 100644
--- a/RegexBot.cs
+++ b/RegexBot.cs
@@ -55,7 +55,8 @@ namespace Noikoio.RegexBot
arg.Message));
// With features initialized, finish loading configuration
- if (!_config.ReloadServerConfig().GetAwaiter().GetResult())
+ var conf = _config.ReloadServerConfig().Result;
+ if (conf == false)
{
Console.WriteLine("Failed to load server configuration.");
#if DEBUG