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 { /// /// 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 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; } /// /// 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 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(); } } }