diff --git a/RegexBot-Modules/RegexModerator/ConfDefinition.cs b/RegexBot-Modules/RegexModerator/ConfDefinition.cs
index 54563a0..8b8edef 100644
--- a/RegexBot-Modules/RegexModerator/ConfDefinition.cs
+++ b/RegexBot-Modules/RegexModerator/ConfDefinition.cs
@@ -1,231 +1,126 @@
using Discord;
-using Discord.WebSocket;
using RegexBot.Common;
-using Newtonsoft.Json.Linq;
-using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
-namespace RegexBot.Modules.RegexModerator
-{
+namespace RegexBot.Modules.RegexModerator;
+///
+/// Representation of a single RegexModerator rule for a guild.
+/// Data in this class is immutable. Contains various helper methods.
+///
+[DebuggerDisplay("RM rule '{Label}'")]
+class ConfDefinition {
+ public string Label { get; }
+
+ // Matching settings
+ private IEnumerable Regex { get; }
+ private FilterList Filter { get; }
+ private bool IgnoreMods { get; }
+ private bool ScanEmbeds { get; }
+
+ // Response settings
+ public EntityName? ReportingChannel { get; }
+ public IReadOnlyList Response { get; }
+ public int BanPurgeDays { get; }
+ public bool NotifyChannelOfRemoval { get; }
+ public bool NotifyUserOfRemoval { get; }
+
+ public ConfDefinition(JObject def) {
+ Label = def[nameof(Label)]?.Value()
+ ?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}.");
+
+ var errpostfx = $" in the rule definition for '{Label}'.";
+
+ var rptch = def[nameof(ReportingChannel)]?.Value();
+ if (rptch != null) {
+ ReportingChannel = new EntityName(rptch);
+ if (ReportingChannel.Type != EntityType.Channel)
+ throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
+ }
+
+ // Regex loading
+ var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
+ // TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline.
+ // Reminder: in Singleline mode, all contents are subject to the same regex (useful if e.g. spammer separates words line by line)
+ opts |= RegexOptions.Singleline;
+ // IgnoreCase is enabled by default; must be explicitly set to false
+ if (def["IgnoreCase"]?.Value() ?? true) opts |= RegexOptions.IgnoreCase;
+ const string ErrBadRegex = "Unable to parse regular expression pattern";
+ var regexRules = new List();
+ List regexStrings;
+ try {
+ regexStrings = Misc.LoadStringOrStringArray(def[nameof(Regex)]);
+ } catch (ArgumentNullException) {
+ throw new ModuleLoadException($"No patterns were defined under '{nameof(Regex)}'{errpostfx}");
+ } catch (ArgumentException) {
+ throw new ModuleLoadException($"'{nameof(Regex)}' is not properly defined{errpostfx}");
+ }
+ foreach (var input in regexStrings) {
+ try {
+ regexRules.Add(new Regex(input, opts));
+ } catch (ArgumentException) {
+ throw new ModuleLoadException($"{ErrBadRegex}{errpostfx}");
+ }
+ }
+ Regex = regexRules.AsReadOnly();
+
+ // Filtering
+ Filter = new FilterList(def);
+
+ // Misc options
+ // IgnoreMods is enabled by default; must be explicitly set to false
+ IgnoreMods = def[nameof(IgnoreMods)]?.Value() ?? true;
+ ScanEmbeds = def[nameof(ScanEmbeds)]?.Value() ?? false; // false by default
+
+ // Load response(s) and response settings
+ try {
+ Response = Misc.LoadStringOrStringArray(def[nameof(Response)]).AsReadOnly();
+ } catch (ArgumentNullException) {
+ throw new ModuleLoadException($"No responses were defined under '{nameof(Response)}'{errpostfx}");
+ } catch (ArgumentException) {
+ throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
+ }
+ BanPurgeDays = def[nameof(BanPurgeDays)]?.Value() ?? 0;
+ NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value() ?? true;
+ NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value() ?? true;
+ }
+
///
- /// Representation of a single RegexModerator rule for a guild.
- /// Data in this class is immutable. Contains various helper methods.
+ /// Checks the given message to determine if it matches this definition's constraints.
///
- [DebuggerDisplay("RM rule '{Label}'")]
- class ConfDefinition
- {
- public string Label { get; }
+ /// True if match.
+ public bool IsMatch(SocketMessage m, bool senderIsModerator) {
+ if (Filter.IsFiltered(m, false)) return false;
+ if (senderIsModerator && IgnoreMods) return false;
- // Matching settings
- readonly IEnumerable _regex;
- readonly FilterList _filter;
- readonly bool _ignoreMods;
- readonly bool _embedScan;
-
- // Response settings
- public string ReplyInChannel { get; }
- public string ReplyInDM { get; }
- public EntityName RoleAdd { get; } // keep in mind it's possible to have both add and remove role available at once
- public EntityName RoleRemove { get; }
- //readonly bool _rRolePersist; // TODO use when feature exists
- public EntityName ReportingChannel { get; }
- public RegexBot.RemovalType RemoveAction { get; } // ban, kick?
- public int BanPurgeDays { get; }
- public string RemoveReason { get; } // reason to place into audit log and notification
- public bool RemoveAnnounce { get; } // send success/failure message in invoking channel? default: true
- public bool RemoveNotifyTarget { get; } // send ban/kick notification to user?
- public bool DeleteMessage { get; }
-
- public ConfDefinition(JObject def)
- {
- Label = def["Label"].Value();
- if (string.IsNullOrWhiteSpace(Label))
- throw new ModuleLoadException("A rule does not have a label defined.");
-
- string errpostfx = $" in the rule definition for '{Label}'.";
-
- // Regex loading
- var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
- // TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline.
- opts |= RegexOptions.Singleline;
- // IgnoreCase is enabled by default; must be explicitly set to false
- bool? rxci = def["IgnoreCase"]?.Value();
- if (rxci ?? true) opts |= RegexOptions.IgnoreCase;
-
- const string ErrNoRegex = "Regular expression patterns are not defined";
- var rxs = new List();
- var rxconf = def["Regex"];
- if (rxconf == null) throw new ModuleLoadException(ErrNoRegex + errpostfx);
- if (rxconf.Type == JTokenType.Array)
- {
- foreach (var input in rxconf.Values())
- {
- try
- {
- // TODO HIGH IMPORTANCE: sanitize input regex; don't allow inline editing of options
- Regex r = new Regex(input, opts);
- rxs.Add(r);
- }
- catch (ArgumentException)
- {
- throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
- }
- }
- }
- else
- {
- string rxstr = rxconf.Value();
- try
- {
- Regex r = new Regex(rxstr, opts);
- rxs.Add(r);
- }
- catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
- {
- throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx);
- }
- }
- if (rxs.Count == 0)
- {
- throw new ModuleLoadException(ErrNoRegex + errpostfx);
- }
- _regex = rxs.ToArray();
-
- // Filtering
- _filter = new FilterList(def);
-
- // Misc options
- // IgnoreMods is enabled by default; must be explicitly set to false
- bool? bypass = def["IgnoreMods"]?.Value();
- _ignoreMods = bypass ?? true;
-
- bool? embedScan = def["EmbedScanMode"]?.Value();
- _embedScan = embedScan ?? false; // false by default
-
- // Response options
- var resp = def["Response"] as JObject;
- if (resp == null)
- throw new ModuleLoadException("Cannot find a valid response section" + errpostfx);
-
- ReplyInChannel = resp[nameof(ReplyInChannel)]?.Value();
- ReplyInDM = resp[nameof(ReplyInDM)]?.Value();
-
- const string ErrRole = "The role value specified is not properly defined as a role";
- // TODO make this error message nicer
- var rolestr = resp[nameof(RoleAdd)]?.Value();
- if (!string.IsNullOrWhiteSpace(rolestr))
- {
- RoleAdd = new EntityName(rolestr);
- if (RoleAdd.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx);
- }
- else RoleAdd = null;
- rolestr = resp[nameof(RoleRemove)]?.Value();
- if (!string.IsNullOrWhiteSpace(rolestr))
- {
- RoleRemove = new EntityName(rolestr);
- if (RoleRemove.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx);
- }
- else RoleRemove = null;
-
- //_rRolePersist = resp["RolePersist"]?.Value() ?? false;
-
- var reportstr = resp[nameof(ReportingChannel)]?.Value();
- if (!string.IsNullOrWhiteSpace(reportstr))
- {
- ReportingChannel = new EntityName(reportstr);
- if (ReportingChannel.Type != EntityType.Channel)
- throw new ModuleLoadException("The reporting channel specified is not properly defined as a channel" + errpostfx);
- }
- else ReportingChannel = null;
-
- var removestr = resp[nameof(RemoveAction)]?.Value();
- // accept values ban, kick, none
- switch (removestr)
- {
- case "ban": RemoveAction = RegexBot.RemovalType.Ban; break;
- case "kick": RemoveAction = RegexBot.RemovalType.Kick; break;
- case "none": RemoveAction = RegexBot.RemovalType.None; break;
- default:
- if (removestr != null)
- throw new ModuleLoadException("RemoveAction is not set to a proper value" + errpostfx);
- break;
- }
-
- int? banpurgeint;
- try { banpurgeint = resp[nameof(BanPurgeDays)]?.Value(); }
- catch (InvalidCastException) { throw new ModuleLoadException("BanPurgeDays must be a numeric value."); }
- if (banpurgeint.HasValue)
- {
- if (banpurgeint > 7 || banpurgeint < 0)
- throw new ModuleLoadException("BanPurgeDays must be a value between 0 and 7 inclusive.");
- BanPurgeDays = banpurgeint ?? 0;
- }
-
- RemoveReason = resp[nameof(RemoveReason)]?.Value();
-
- RemoveAnnounce = resp[nameof(RemoveAnnounce)]?.Value() ?? true;
-
- RemoveNotifyTarget = resp[nameof(RemoveNotifyTarget)]?.Value() ?? false;
-
- DeleteMessage = resp[nameof(DeleteMessage)]?.Value() ?? false;
+ foreach (var regex in Regex) {
+ if (ScanEmbeds && regex.IsMatch(SerializeEmbed(m.Embeds))) return true;
+ if (regex.IsMatch(m.Content)) return true;
}
+ return false;
+ }
- ///
- /// Checks the given message to determine if it matches this definition's constraints.
- ///
- /// True if match.
- public bool IsMatch(SocketMessage m, bool senderIsModerator)
- {
- // TODO keep id: true or false?
- if (_filter.IsFiltered(m, false)) return false;
- if (senderIsModerator && _ignoreMods) return false;
-
- var matchText = _embedScan ? SerializeEmbed(m.Embeds) : m.Content;
-
- foreach (var regex in _regex)
- {
- // TODO enforce maximum execution time
- // TODO multi-processing of multiple regexes?
- // TODO metrics: temporary tracking of regex execution times
- if (regex.IsMatch(matchText)) return true;
- }
-
- return false;
- }
-
- private string SerializeEmbed(IReadOnlyCollection