From 3c88bce94a888c003fdd4d9ec44455b6b90d5062 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 26 Aug 2017 10:24:37 -0700 Subject: [PATCH] Fully implemented AutoMod --- BotFeature.cs | 15 ++ Feature/AutoMod/AutoMod.cs | 87 ++++++- Feature/AutoMod/Responses/Ban.cs | 50 +++++ Feature/AutoMod/Responses/Kick.cs | 28 +++ Feature/AutoMod/Responses/Remove.cs | 23 ++ Feature/AutoMod/Responses/Report.cs | 93 ++++++++ Feature/AutoMod/Responses/Response.cs | 171 ++++++++++++++ Feature/AutoMod/Responses/RoleManipulation.cs | 89 ++++++++ Feature/AutoMod/Responses/Say.cs | 45 ++++ Feature/AutoMod/Rule.cs | 212 ++++++++++++++++++ RegexBot.cs | 3 +- 11 files changed, 804 insertions(+), 12 deletions(-) create mode 100644 Feature/AutoMod/Responses/Ban.cs create mode 100644 Feature/AutoMod/Responses/Kick.cs create mode 100644 Feature/AutoMod/Responses/Remove.cs create mode 100644 Feature/AutoMod/Responses/Report.cs create mode 100644 Feature/AutoMod/Responses/Response.cs create mode 100644 Feature/AutoMod/Responses/RoleManipulation.cs create mode 100644 Feature/AutoMod/Responses/Say.cs create mode 100644 Feature/AutoMod/Rule.cs 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