From e020b328c626b0cc3fde5f4f57886c68dc1eb371 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Wed, 9 Aug 2017 19:29:45 -0700 Subject: [PATCH 01/20] Added new (unfinished) classes Will begin development of AutoRespond first. A (planned) feature list was added in the Feature class's documentation. --- Feature/AutoMod/AutoMod.cs | 31 ++++++++++++++++++++++++ Feature/AutoRespond/AutoRespond.cs | 39 ++++++++++++++++++++++++++++++ RegexBot.cs | 5 ++-- 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 Feature/AutoMod/AutoMod.cs create mode 100644 Feature/AutoRespond/AutoRespond.cs diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs new file mode 100644 index 0000000..bc60378 --- /dev/null +++ b/Feature/AutoMod/AutoMod.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; + +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 . + /// + class AutoMod : BotFeature + { + public override string Name => "AutoMod"; + + public AutoMod(DiscordSocketClient client) : base(client) + { + throw new NotImplementedException(); + } + + public override Task ProcessConfiguration(JToken configSection) + { + throw new NotImplementedException(); + } + } +} diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs new file mode 100644 index 0000000..0a4c8bd --- /dev/null +++ b/Feature/AutoRespond/AutoRespond.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; + +namespace Noikoio.RegexBot.Feature.AutoRespond +{ + /// + /// Similar to , but lightweight. + /// Provides the capability to define autoresponses for fun or informational purposes. + /// + /// The major differences between this and include: + /// + /// Does not listen for message edits. + /// Moderators are not exempt from any defined triggers by default. + /// Responses are limited to only two types, and only one is allowed per rule. + /// Does not support fine-grained matching options. + /// Support for rate limiting. + /// + /// + /// + class AutoRespond : BotFeature + { + public override string Name => "AutoRespond"; + + public AutoRespond(DiscordSocketClient client) : base(client) + { + throw new NotImplementedException(); + } + + [ConfigSection("autoresponses")] + public override Task ProcessConfiguration(JToken configSection) + { + throw new NotImplementedException(); + } + } +} diff --git a/RegexBot.cs b/RegexBot.cs index 4efbb7f..9ad5d9d 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -44,8 +44,9 @@ namespace Noikoio.RegexBot // Initialize features _features = new BotFeature[] { - new Feature.RegexResponder.EventProcessor(_client), - new Feature.ModTools.CommandListener(_client) + new Feature.AutoMod.AutoMod(_client), + new Feature.ModTools.CommandListener(_client), + new Feature.AutoRespond.AutoRespond(_client) }; var dlog = Logger.GetLogger("Discord.Net"); _client.Log += async (arg) => From bd46c53a02bfe6cf58061ab762e8a25e2e40bbfa Mon Sep 17 00:00:00 2001 From: Noikoio Date: Wed, 9 Aug 2017 20:16:08 -0700 Subject: [PATCH 02/20] Added AutoRespond/ResponseConfigItem --- ConfigItem/EntityList.cs | 5 +- Feature/AutoRespond/ResponseConfigItem.cs | 83 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 Feature/AutoRespond/ResponseConfigItem.cs diff --git a/ConfigItem/EntityList.cs b/ConfigItem/EntityList.cs index 76bd0a5..a448757 100644 --- a/ConfigItem/EntityList.cs +++ b/ConfigItem/EntityList.cs @@ -127,8 +127,11 @@ namespace Noikoio.RegexBot.ConfigItem } /// - /// Helper method for reading whitelist and blacklist filtering lists + /// Helper method for reading whitelist and blacklist filtering lists. /// + /// + /// A JSON object which presumably contains an array named "whitelist" or "blacklist". + /// public static (FilterType, EntityList) GetFilterList(JObject section) { var mode = FilterType.None; diff --git a/Feature/AutoRespond/ResponseConfigItem.cs b/Feature/AutoRespond/ResponseConfigItem.cs new file mode 100644 index 0000000..cdd400f --- /dev/null +++ b/Feature/AutoRespond/ResponseConfigItem.cs @@ -0,0 +1,83 @@ +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Text.RegularExpressions; + +namespace Noikoio.RegexBot.Feature.AutoRespond +{ + /// + /// Represents a single autoresponse definition. + /// + struct ResponseConfigItem + { + public enum ResponseType { None, Exec, Reply } + + string _label; + Regex _trigger; + ResponseType _rtype; + string _rbody; // response body + private FilterType _filtermode; + private EntityList _filterlist; + + public string Label => _label; + public Regex Trigger => _trigger; + public (ResponseType, string) Response => (_rtype, _rbody); + public (FilterType, EntityList) Filter => (_filtermode, _filterlist); + + public ResponseConfigItem(JObject definition) + { + // label + _label = definition["label"]?.Value(); + if (string.IsNullOrWhiteSpace(_label)) + throw new RuleImportException("Label is not defined in response definition."); + + // error postfix string + string errorpfx = $" in response definition for '{_label}'."; + + // regex trigger + const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline; + string triggerstr = definition["trigger"]?.Value(); + if (string.IsNullOrWhiteSpace(triggerstr)) + throw new RuleImportException("Regular expression trigger is not defined" + errorpfx); + try + { + _trigger = new Regex(triggerstr, rxopts); + } + catch (ArgumentException ex) + { + throw new RuleImportException + ("Failed to parse regular expression pattern" + errorpfx + + $" ({ex.GetType().Name}: {ex.Message})"); + } + + // response - defined in either "exec" or "reply", but not both + _rbody = null; + _rtype = ResponseType.None; + + // exec response --- + string execstr = definition["exec"]?.Value(); + if (!string.IsNullOrWhiteSpace(execstr)) + { + _rbody = execstr; + _rtype = ResponseType.Exec; + } + + // reply response + string replystr = definition["reply"]?.Value(); + if (!string.IsNullOrWhiteSpace(replystr)) + { + if (_rbody != null) + throw new RuleImportException("A value for both 'exec' and 'reply' is not allowed" + errorpfx); + _rbody = replystr; + _rtype = ResponseType.Reply; + } + + if (_rbody == null) + throw new RuleImportException("A response value of either 'exec' or 'reply' was not defined" + errorpfx); + // --- + + // whitelist/blacklist filtering + (_filtermode, _filterlist) = EntityList.GetFilterList(definition); + } + } +} From 8d228a4d7c9fc98581c97983873d153f9a8b2d40 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Thu, 10 Aug 2017 12:53:46 -0700 Subject: [PATCH 03/20] Added FilterList Several upcoming features will be making use of the same whitelist and blacklist filtering concept that RegexResponder currently uses. --- ConfigItem/EntityList.cs | 27 +------ ConfigItem/FilterList.cs | 91 ++++++++++++++++++++++++ Feature/RegexResponder/EventProcessor.cs | 22 +----- Feature/RegexResponder/RuleConfig.cs | 12 +--- 4 files changed, 96 insertions(+), 56 deletions(-) create mode 100644 ConfigItem/FilterList.cs diff --git a/ConfigItem/EntityList.cs b/ConfigItem/EntityList.cs index a448757..2a3893b 100644 --- a/ConfigItem/EntityList.cs +++ b/ConfigItem/EntityList.cs @@ -7,8 +7,6 @@ using System.Linq; namespace Noikoio.RegexBot.ConfigItem { - enum FilterType { None, Whitelist, Blacklist } - /// /// Represents a structure in bot configuration that contains a list of /// channels, roles, and users. @@ -70,7 +68,7 @@ namespace Noikoio.RegexBot.ConfigItem /// /// An incoming message. /// - /// True if the occurred within a channel specified in this list, + /// True if '' occurred within a channel specified in this list, /// or if the message author belongs to one or more roles in this list, or if the user itself /// is defined within this list. /// @@ -125,28 +123,5 @@ namespace Noikoio.RegexBot.ConfigItem // No match. return false; } - - /// - /// Helper method for reading whitelist and blacklist filtering lists. - /// - /// - /// A JSON object which presumably contains an array named "whitelist" or "blacklist". - /// - public static (FilterType, EntityList) GetFilterList(JObject section) - { - var mode = FilterType.None; - EntityList list; - if (section["whitelist"] != null) mode = FilterType.Whitelist; - if (section["blacklist"] != null) - { - if (mode == FilterType.Whitelist) - throw new RuleImportException("Cannot have whitelist AND blacklist defined."); - mode = FilterType.Blacklist; - } - if (mode == FilterType.None) list = new EntityList(); // might even be fine to keep it null? - else list = new EntityList(section[mode == FilterType.Whitelist ? "whitelist" : "blacklist"]); - - return (mode, list); - } } } diff --git a/ConfigItem/FilterList.cs b/ConfigItem/FilterList.cs new file mode 100644 index 0000000..972f78e --- /dev/null +++ b/ConfigItem/FilterList.cs @@ -0,0 +1,91 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Noikoio.RegexBot.ConfigItem +{ + enum FilterType { None, Whitelist, Blacklist } + + /// + /// Represents whitelist/blacklist configuration, including exemptions. + /// + struct FilterList + { + FilterType _type; + EntityList _filterList; + EntityList _exemptions; + + public FilterType FilterMode => _type; + public EntityList FilterEntities => _filterList; + public EntityList FilterExemptions => _exemptions; + + /// + /// Gets the + /// + /// + /// A JSON object which presumably contains an array named "whitelist" or "blacklist", + /// and optionally one named "exempt". + /// + /// + /// Thrown if both "whitelist" and "blacklist" definitions were found, if + /// "exempt" was found without a corresponding "whitelist" or "blacklist", + /// or if there was an issue parsing an EntityList within these definitions. + /// + public FilterList(JObject conf) + { + _type = FilterType.None; + + if (conf["whitelist"] != null) _type = FilterType.Whitelist; + if (conf["blacklist"] != null) + { + if (_type != FilterType.None) + throw new RuleImportException("Cannot have both 'whitelist' and 'blacklist' values defined."); + _type = FilterType.Blacklist; + } + if (_type == FilterType.None) + { + _filterList = null; + _exemptions = null; + if (conf["exempt"] != null) + throw new RuleImportException("Cannot have 'exempt' defined if no corresponding " + + "'whitelist' or 'blacklist' has been defined in the same section."); + } + else + { + _filterList = new EntityList(conf[_type == FilterType.Whitelist ? "whitelist" : "blacklist"]); + _exemptions = new EntityList(conf["exempt"]); // EntityList constructor checks for null value + } + } + + /// + /// Determines if the parameters of '' are a match with filtering + /// rules defined in this instance. + /// + /// An incoming message. + /// + /// True if the user or channel specified by '' is filtered by + /// the configuration defined in this instance. + /// + public bool IsFiltered(SocketMessage msg) + { + if (FilterMode == FilterType.None) return false; + + bool inFilter = FilterEntities.ExistsInList(msg); + + if (FilterMode == FilterType.Whitelist) + { + if (!inFilter) return true; + return FilterExemptions.ExistsInList(msg); + } + else if (FilterMode == FilterType.Blacklist) + { + if (!inFilter) return false; + return !FilterExemptions.ExistsInList(msg); + } + + throw new Exception("this shouldn't happen"); + } + } +} diff --git a/Feature/RegexResponder/EventProcessor.cs b/Feature/RegexResponder/EventProcessor.cs index 75d0af2..e78f50e 100644 --- a/Feature/RegexResponder/EventProcessor.cs +++ b/Feature/RegexResponder/EventProcessor.cs @@ -128,7 +128,7 @@ namespace Noikoio.RegexBot.Feature.RegexResponder // Moderator bypass check if (rule.AllowModBypass == true && srv.Moderators.ExistsInList(msg)) return; // Individual rule filtering check - if (IsFiltered(rule, msg)) return; + if (rule.Filter.IsFiltered(msg)) return; // And finally, pattern matching checks bool success = false; @@ -220,26 +220,6 @@ namespace Noikoio.RegexBot.Feature.RegexResponder return result.ToString(); } - - private bool IsFiltered(RuleConfig r, SocketMessage m) - { - if (r.FilterMode == FilterType.None) return false; - - bool inFilter = r.FilterList.ExistsInList(m); - - if (r.FilterMode == FilterType.Whitelist) - { - if (!inFilter) return true; - return r.FilterExemptions.ExistsInList(m); - } - else if (r.FilterMode == FilterType.Blacklist) - { - if (!inFilter) return false; - return !r.FilterExemptions.ExistsInList(m); - } - - return false; // this shouldn't happen™ - } private string[] SplitParams(string cmd, int? limit = null) { diff --git a/Feature/RegexResponder/RuleConfig.cs b/Feature/RegexResponder/RuleConfig.cs index f263d18..8668c9e 100644 --- a/Feature/RegexResponder/RuleConfig.cs +++ b/Feature/RegexResponder/RuleConfig.cs @@ -15,9 +15,7 @@ namespace Noikoio.RegexBot.Feature.RegexResponder private string _displayName; private IEnumerable _regex; private IEnumerable _responses; - private FilterType _filtermode; - private EntityList _filterlist; - private EntityList _filterexempt; + private FilterList _filter; private int? _minLength; private int? _maxLength; private bool _modBypass; @@ -26,9 +24,7 @@ namespace Noikoio.RegexBot.Feature.RegexResponder public string DisplayName => _displayName; public IEnumerable Regex => _regex; public IEnumerable Responses => _responses; - public FilterType FilterMode => _filtermode; - public EntityList FilterList => _filterlist; - public EntityList FilterExemptions => _filterexempt; + public FilterList Filter => _filter; public int? MinLength => _minLength; public int? MaxLength => _maxLength; public bool AllowModBypass => _modBypass; @@ -131,9 +127,7 @@ namespace Noikoio.RegexBot.Feature.RegexResponder _responses = responses.ToArray(); // (white|black)list filtering - (_filtermode, _filterlist) = EntityList.GetFilterList(ruleconf); - // filtering exemptions - _filterexempt = new EntityList(ruleconf["exempt"]); + _filter = new FilterList(ruleconf); // moderator bypass toggle - true by default, must be explicitly set to false bool? modoverride = ruleconf["AllowModBypass"]?.Value(); From 262f77d82d3acbae0b5e4236d6d24b099604faf6 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Thu, 10 Aug 2017 15:19:42 -0700 Subject: [PATCH 04/20] Added AutoRespond rate limiting --- Feature/AutoRespond/RateLimitCache.cs | 56 +++++++++++++++++++ ...nseConfigItem.cs => ResponseDefinition.cs} | 31 ++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 Feature/AutoRespond/RateLimitCache.cs rename Feature/AutoRespond/{ResponseConfigItem.cs => ResponseDefinition.cs} (75%) diff --git a/Feature/AutoRespond/RateLimitCache.cs b/Feature/AutoRespond/RateLimitCache.cs new file mode 100644 index 0000000..19e3514 --- /dev/null +++ b/Feature/AutoRespond/RateLimitCache.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace Noikoio.RegexBot.Feature.AutoRespond +{ + /// + /// Stores rate limit settings and caches. + /// + class RateLimitCache + { + public const ushort DefaultTimeout = 30; // TODO make configurable + + private readonly ushort _timeout; + private Dictionary _cache; + + public ushort Timeout => _timeout; + + /// + /// Sets up a new instance of . + /// + public RateLimitCache(ushort timeout) + { + _timeout = timeout; + _cache = new Dictionary(); + } + + /// + /// Adds a cache item corersponding to the given ID. + /// Items added to cache will be removed after the number of seconds specified in . + /// + /// The ID to add to the cache. + /// True on success. False if the given ID already exists. + public bool AddUsage(ulong id) + { + Clean(); + if (_cache.ContainsKey(id)) return false; + _cache.Add(id, DateTime.Now); + return true; + } + + private void Clean() + { + var now = DateTime.Now; + var clean = new Dictionary(); + foreach (var kp in _cache) + { + if (kp.Value.AddSeconds(Timeout) > now) + { + // Copy items that have not yet timed out to the new dictionary + clean.Add(kp.Key, kp.Value); + } + } + _cache = clean; + } + } +} diff --git a/Feature/AutoRespond/ResponseConfigItem.cs b/Feature/AutoRespond/ResponseDefinition.cs similarity index 75% rename from Feature/AutoRespond/ResponseConfigItem.cs rename to Feature/AutoRespond/ResponseDefinition.cs index cdd400f..168cbe0 100644 --- a/Feature/AutoRespond/ResponseConfigItem.cs +++ b/Feature/AutoRespond/ResponseDefinition.cs @@ -8,7 +8,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// /// Represents a single autoresponse definition. /// - struct ResponseConfigItem + struct ResponseDefinition { public enum ResponseType { None, Exec, Reply } @@ -16,15 +16,16 @@ namespace Noikoio.RegexBot.Feature.AutoRespond Regex _trigger; ResponseType _rtype; string _rbody; // response body - private FilterType _filtermode; - private EntityList _filterlist; + private FilterList _filter; + private RateLimitCache _limit; public string Label => _label; public Regex Trigger => _trigger; public (ResponseType, string) Response => (_rtype, _rbody); - public (FilterType, EntityList) Filter => (_filtermode, _filterlist); + public FilterList Filter => _filter; + public RateLimitCache RateLimit => _limit; - public ResponseConfigItem(JObject definition) + public ResponseDefinition(JObject definition) { // label _label = definition["label"]?.Value(); @@ -77,7 +78,25 @@ namespace Noikoio.RegexBot.Feature.AutoRespond // --- // whitelist/blacklist filtering - (_filtermode, _filterlist) = EntityList.GetFilterList(definition); + _filter = new FilterList(definition); + + // rate limiting + string rlstr = definition["ratelimit"].Value(); + if (string.IsNullOrWhiteSpace(rlstr)) + { + _limit = new RateLimitCache(RateLimitCache.DefaultTimeout); + } + else + { + if (ushort.TryParse(rlstr, out var rlval)) + { + _limit = new RateLimitCache(rlval); + } + else + { + throw new RuleImportException("Rate limit value is invalid" + errorpfx); + } + } } } } From f19ef5a662c0d61b7d4a057b4e6d9ee0efca8ab6 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Fri, 11 Aug 2017 08:23:20 -0700 Subject: [PATCH 05/20] Implement MessageReceived delegate --- Feature/AutoRespond/AutoRespond.cs | 18 ++++++++++++++++-- Feature/AutoRespond/AutoRespond_Process.cs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 Feature/AutoRespond/AutoRespond_Process.cs diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index 0a4c8bd..918e620 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -21,13 +21,27 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// /// /// - class AutoRespond : BotFeature + partial class AutoRespond : BotFeature { public override string Name => "AutoRespond"; public AutoRespond(DiscordSocketClient client) : base(client) { - throw new NotImplementedException(); + client.MessageReceived += Client_MessageReceived; + } + + private async Task Client_MessageReceived(SocketMessage arg) + { + // Determine channel type - if not a guild channel, stop. + var ch = arg.Channel as SocketGuildChannel; + if (ch == null) return; + + // TODO either search server by name or remove server name support entirely + var defs = GetConfig(ch.Guild.Id) as IEnumerable; + if (defs == null) return; + + foreach (var def in defs) + await Task.Run(async () => await ProcessMessage(arg, def)); } [ConfigSection("autoresponses")] diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs new file mode 100644 index 0000000..0cd5418 --- /dev/null +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -0,0 +1,19 @@ +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Feature.AutoRespond +{ + partial class AutoRespond + { + private async Task ProcessMessage(SocketMessage msg, ResponseDefinition def) + { + // Filtering checks + + // Rate limit checks + + } + } +} From 7dbab387288514f331842533388433f35abd19dd Mon Sep 17 00:00:00 2001 From: Noikoio Date: Fri, 11 Aug 2017 23:45:27 -0700 Subject: [PATCH 06/20] Fully implemented AutoRespond --- Feature/AutoRespond/AutoRespond.cs | 24 +++++----- Feature/AutoRespond/AutoRespond_Process.cs | 51 +++++++++++++++++++--- Feature/AutoRespond/RateLimitCache.cs | 4 +- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index 918e620..b550ea1 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Threading.Tasks; -using Discord.WebSocket; +using Discord.WebSocket; using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Threading.Tasks; namespace Noikoio.RegexBot.Feature.AutoRespond { @@ -14,10 +12,9 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// The major differences between this and include: /// /// Does not listen for message edits. - /// Moderators are not exempt from any defined triggers by default. - /// Responses are limited to only two types, and only one is allowed per rule. - /// Does not support fine-grained matching options. - /// Support for rate limiting. + /// Moderators are not exempt from any defined triggers. + /// Responses are limited to the invoking channel. + /// Per-channel rate limiting. /// /// /// @@ -47,7 +44,14 @@ namespace Noikoio.RegexBot.Feature.AutoRespond [ConfigSection("autoresponses")] public override Task ProcessConfiguration(JToken configSection) { - throw new NotImplementedException(); + var responses = new List(); + foreach (JObject def in configSection) + { + // Everything is left to the constructor + responses.Add(new ResponseDefinition(def)); + } + + return Task.FromResult(responses.AsReadOnly()); } } } diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs index 0cd5418..3e773de 100644 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -1,7 +1,5 @@ using Discord.WebSocket; -using System; -using System.Collections.Generic; -using System.Text; +using System.Diagnostics; using System.Threading.Tasks; namespace Noikoio.RegexBot.Feature.AutoRespond @@ -10,10 +8,53 @@ namespace Noikoio.RegexBot.Feature.AutoRespond { private async Task ProcessMessage(SocketMessage msg, ResponseDefinition def) { - // Filtering checks + // Checks before executing + if (def.Filter.IsFiltered(msg)) return; + if (!def.RateLimit.AddUsage(msg.Channel.Id)) return; - // Rate limit checks + await Log($"'{def.Label}' triggered in #{msg.Channel.Name} by {msg.Author}"); + var (type, text) = def.Response; + if (type == ResponseDefinition.ResponseType.Reply) await ProcessReply(msg, text); + else if (type == ResponseDefinition.ResponseType.Exec) await ProcessExec(msg, text); + } + + private async Task ProcessReply(SocketMessage msg, string text) + { + await msg.Channel.SendMessageAsync(text); + } + + private async Task ProcessExec(SocketMessage msg, string text) + { + string[] cmdline = text.Split(new char[] { ' ' }, 2); + + ProcessStartInfo ps = new ProcessStartInfo() + { + FileName = cmdline[0], + Arguments = (cmdline.Length == 2 ? cmdline[1] : ""), + UseShellExecute = false, // ??? + CreateNoWindow = true, + RedirectStandardOutput = true + }; + using (Process p = Process.Start(ps)) + { + p.WaitForExit(5000); // waiting at most 5 seconds + if (p.HasExited) + { + if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode); + using (var stdout = p.StandardOutput) + { + var result = await stdout.ReadToEndAsync(); + await msg.Channel.SendMessageAsync(result); + } + } + else + { + await Log("exec: Process is taking too long to exit. Killing process."); + p.Kill(); + return; + } + } } } } diff --git a/Feature/AutoRespond/RateLimitCache.cs b/Feature/AutoRespond/RateLimitCache.cs index 19e3514..cc3e4f2 100644 --- a/Feature/AutoRespond/RateLimitCache.cs +++ b/Feature/AutoRespond/RateLimitCache.cs @@ -8,7 +8,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// class RateLimitCache { - public const ushort DefaultTimeout = 30; // TODO make configurable + public const ushort DefaultTimeout = 20; // this is Skeeter's fault private readonly ushort _timeout; private Dictionary _cache; @@ -32,6 +32,8 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// True on success. False if the given ID already exists. public bool AddUsage(ulong id) { + if (_timeout == 0) return true; + Clean(); if (_cache.ContainsKey(id)) return false; _cache.Add(id, DateTime.Now); From 18e1748c4f701bbcca6b0405895329c9d7579db0 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 26 Aug 2017 10:05:29 -0700 Subject: [PATCH 07/20] Fixed all autoresponses triggering on every message --- Feature/AutoRespond/AutoRespond_Process.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs index 3e773de..8ae411a 100644 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -8,13 +8,17 @@ namespace Noikoio.RegexBot.Feature.AutoRespond { private async Task ProcessMessage(SocketMessage msg, ResponseDefinition def) { - // Checks before executing + // Check filters if (def.Filter.IsFiltered(msg)) return; + + // Check rate limit if (!def.RateLimit.AddUsage(msg.Channel.Id)) return; + + // Check if the trigger is a match, of course + if (!def.Trigger.IsMatch(msg.Content)) return; await Log($"'{def.Label}' triggered in #{msg.Channel.Name} by {msg.Author}"); var (type, text) = def.Response; - if (type == ResponseDefinition.ResponseType.Reply) await ProcessReply(msg, text); else if (type == ResponseDefinition.ResponseType.Exec) await ProcessExec(msg, text); } From 0f3fd350fa24f0723de146e0ff851f33714a3536 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 26 Aug 2017 10:23:02 -0700 Subject: [PATCH 08/20] Removed RegexResponder --- Feature/RegexResponder/EventProcessor.cs | 309 ----------------------- Feature/RegexResponder/Responses.cs | 265 ------------------- Feature/RegexResponder/RuleConfig.cs | 141 ----------- 3 files changed, 715 deletions(-) delete mode 100644 Feature/RegexResponder/EventProcessor.cs delete mode 100644 Feature/RegexResponder/Responses.cs delete mode 100644 Feature/RegexResponder/RuleConfig.cs diff --git a/Feature/RegexResponder/EventProcessor.cs b/Feature/RegexResponder/EventProcessor.cs deleted file mode 100644 index e78f50e..0000000 --- a/Feature/RegexResponder/EventProcessor.cs +++ /dev/null @@ -1,309 +0,0 @@ -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.Text; -using System.Threading.Tasks; - -namespace Noikoio.RegexBot.Feature.RegexResponder -{ - /// - /// Implements per-message regex matching and executes customizable responses. - /// Namesake of this project. - /// - partial class EventProcessor : BotFeature - { - private readonly DiscordSocketClient _client; - - public override string Name => "RegexResponder"; - - public EventProcessor(DiscordSocketClient client) : base(client) - { - _client = client; - - _client.MessageReceived += OnMessageReceived; - _client.MessageUpdated += OnMessageUpdated; - - _commands = new ReadOnlyDictionary( - new Dictionary() { -#if DEBUG - { "crash", RP_Crash }, - { "dumpid", RP_DumpID }, -#endif - { "report", RP_Report }, - { "say", RP_Say }, - { "remove", RP_Remove }, - { "delete", RP_Remove }, - { "erase", RP_Remove }, - { "exec", RP_Exec }, - { "ban", RP_Ban }, - { "grantrole", RP_GrantRevokeRole }, - { "revokerole", RP_GrantRevokeRole } - } - ); - } - - #region Event handlers - private async Task OnMessageReceived(SocketMessage arg) - => await ReceiveMessage(arg); - private async Task OnMessageUpdated(Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3) - => await ReceiveMessage(arg2); - #endregion - - /// - /// Receives incoming messages and creates tasks to handle them if necessary. - /// - private async Task ReceiveMessage(SocketMessage arg) - { - // Determine channel type - if not a guild channel, stop. - var ch = arg.Channel as SocketGuildChannel; - if (ch == null) return; - - if (arg.Author == _client.CurrentUser) return; // Don't ever self-trigger - - // Looking up server information and extracting settings - SocketGuild g = ch.Guild; - ServerConfig sd = null; - foreach (var item in RegexBot.Config.Servers) - { - if (item.Id.HasValue) - { - // Finding server by ID - if (g.Id == item.Id) - { - sd = item; - break; - } - } - else - { - // Finding server by name and caching ID - if (string.Equals(item.Name, g.Name, StringComparison.OrdinalIgnoreCase)) - { - item.Id = g.Id; - sd = item; - await Logger.GetLogger(Configuration.LogPrefix) - ($"Suggestion: Server \"{item.Name}\" can be defined as \"{item.Id}::{item.Name}\""); - break; - } - } - } - - if (sd == null) return; // No server configuration found - var rules = GetConfig(ch.Guild.Id) as IEnumerable; - if (rules == null) return; - - // Further processing is sent to the thread pool - foreach (var rule in rules) - await Task.Run(async () => await ProcessMessage(sd, rule, arg)); - } - - /// - /// Uses information from a single rule and checks if the incoming message is a match. - /// If it matches, the rule's responses are executed. To be run in the thread pool. - /// - private async Task ProcessMessage(ServerConfig srv, RuleConfig rule, SocketMessage msg) - { - string msgcontent; - - // Embed mode? - if (rule.MatchEmbeds) - { - var embeds = new StringBuilder(); - foreach (var e in msg.Embeds) embeds.AppendLine(EmbedToString(e)); - msgcontent = embeds.ToString(); - } - else - { - msgcontent = msg.Content; - } - - // Min/max message length check - if (rule.MinLength.HasValue && msgcontent.Length <= rule.MinLength.Value) return; - if (rule.MaxLength.HasValue && msgcontent.Length >= rule.MaxLength.Value) return; - - // Moderator bypass check - if (rule.AllowModBypass == true && srv.Moderators.ExistsInList(msg)) return; - // Individual rule filtering check - if (rule.Filter.IsFiltered(msg)) return; - - // And finally, pattern matching checks - bool success = false; - foreach (var regex in rule.Regex) - { - success = regex.Match(msgcontent).Success; - if (success) break; - } - if (!success) return; - - // Prepare to execute responses - await Log($"\"{rule.DisplayName}\" triggered in {srv.Name}/#{msg.Channel} by {msg.Author.ToString()}"); - - foreach (string rcmd in rule.Responses) - { - string cmd = rcmd.TrimStart(' ').Split(' ')[0].ToLower(); - try - { - ResponseProcessor response; - if (!_commands.TryGetValue(cmd, out response)) - { - await Log($"Unknown command defined in response: \"{cmd}\""); - continue; - } - await response.Invoke(rcmd, rule, msg); - } - catch (Exception ex) - { - await Log($"Encountered an error while processing \"{cmd}\". Details follow:"); - await Log(ex.ToString()); - } - } - } - - [ConfigSection("rules")] - public override async Task ProcessConfiguration(JToken configSection) - { - List rules = new List(); - foreach (JObject ruleconf in configSection) - { - // Try and get at least the name before passing it to RuleItem - string name = ruleconf["name"]?.Value(); - if (name == null) - { - await Log("Display name not defined within a rule section."); - return false; - } - await Log($"Adding rule \"{name}\""); - - RuleConfig rule; - try - { - rule = new RuleConfig(ruleconf); - } - catch (RuleImportException ex) - { - await Log("-> Error: " + ex.Message); - return false; - } - rules.Add(rule); - } - - return rules.AsReadOnly(); - } - - // ------------------------------------- - - /// - /// Turns an embed into a single string for regex matching purposes - /// - private string EmbedToString(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(); - } - - private string[] SplitParams(string cmd, int? limit = null) - { - if (limit.HasValue) - { - return cmd.Split(new char[] { ' ' }, limit.Value, StringSplitOptions.RemoveEmptyEntries); - } - else - { - return cmd.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); - } - } - - private 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("@\\_", "@_"); - } - - /// - /// Receives a string (beginning with @ or #) and returns an object - /// suitable for sending out messages - /// - private 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; - } - } -} diff --git a/Feature/RegexResponder/Responses.cs b/Feature/RegexResponder/Responses.cs deleted file mode 100644 index ecd4b3e..0000000 --- a/Feature/RegexResponder/Responses.cs +++ /dev/null @@ -1,265 +0,0 @@ -using Discord; -using Discord.WebSocket; -using System; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Text; -using System.Threading.Tasks; - -namespace Noikoio.RegexBot.Feature.RegexResponder -{ - // Contains code for handling each response in a rule. - partial class EventProcessor - { - private delegate Task ResponseProcessor(string cmd, RuleConfig r, SocketMessage m); - private readonly ReadOnlyDictionary _commands; - -#if DEBUG - /// - /// Throws an exception. Meant to be a quick error handling test. - /// No parameters. - /// - private Task RP_Crash(string cmd, RuleConfig r, SocketMessage m) - { - throw new Exception("Requested in response."); - } - - /// - /// Prints all guild values (IDs for users, channels, roles) to console. - /// The guild info displayed is the one in which the command is invoked. - /// No parameters. - /// - private Task RP_DumpID(string cmd, RuleConfig r, SocketMessage m) - { - var g = ((SocketGuildUser)m.Author).Guild; - var result = new StringBuilder(); - - result.AppendLine("Users:"); - foreach (var item in g.Users) - result.AppendLine($"{item.Id} {item.Username}#{item.Discriminator}"); - result.AppendLine(); - - result.AppendLine("Channels:"); - foreach (var item in g.Channels) result.AppendLine($"{item.Id} #{item.Name}"); - result.AppendLine(); - result.AppendLine("Roles:"); - foreach (var item in g.Roles) result.AppendLine($"{item.Id} {item.Name}"); - result.AppendLine(); - - Console.WriteLine(result.ToString()); - return Task.CompletedTask; - } -#endif - /// - /// Sends a message to a specified channel. - /// Parameters: say (channel) (message) - /// - private async Task RP_Say(string cmd, RuleConfig r, SocketMessage m) - { - string[] @in = SplitParams(cmd, 3); - if (@in.Length != 3) - { - await Log("Error: say: Incorrect number of parameters."); - return; - } - - var target = await GetMessageTargetAsync(@in[1], m); - if (target == null) - { - await Log("Error: say: Unable to resolve given target."); - return; - } - - // CHANGE THE SAY - @in[2] = ProcessText(@in[2], m); - await target.SendMessageAsync(@in[2]); - } - - /// - /// Reports the incoming message to a given channel. - /// Parameters: report (channel) - /// - private async Task RP_Report(string cmd, RuleConfig r, SocketMessage m) - { - string[] @in = SplitParams(cmd); - if (@in.Length != 2) - { - await Log("Error: report: Incorrect number of parameters."); - return; - } - - var target = await GetMessageTargetAsync(@in[1], m); - if (target == null) - { - await Log("Error: report: Unable to resolve given target."); - return; - } - - - var responsefield = new StringBuilder(); - responsefield.AppendLine("```"); - foreach (var line in r.Responses) - responsefield.AppendLine(line.Replace("\r", "").Replace("\n", "\\n")); - responsefield.Append("```"); - await target.SendMessageAsync("", embed: new EmbedBuilder() - { - Color = new Color(0xEDCE00), // configurable later? - - Author = new EmbedAuthorBuilder() - { - Name = $"{m.Author.Username}#{m.Author.Discriminator} said:", - IconUrl = m.Author.GetAvatarUrl() - }, - Description = m.Content, - - Footer = new EmbedFooterBuilder() - { - Text = $"Rule '{r.DisplayName}'", - IconUrl = _client.CurrentUser.GetAvatarUrl() - }, - Timestamp = m.Timestamp - }.AddField(new EmbedFieldBuilder() - { - Name = "Additional info", - Value = $"Channel: <#{m.Channel.Id}>\n" // NOTE: manually mentioning channel here - + $"Username: {m.Author.Mention}\n" - + $"Message ID: {m.Id}" - }).AddField(new EmbedFieldBuilder() - { - Name = "Executing response:", - Value = responsefield.ToString() - })); - } - - /// - /// Deletes the incoming message. - /// No parameters. - /// - private async Task RP_Remove(string cmd, RuleConfig r, SocketMessage m) - { - // Parameters are not checked - await m.DeleteAsync(); - } - - /// - /// Executes an external program and sends standard output to the given channel. - /// Parameters: exec (channel) (command line) - /// - private async Task RP_Exec(string cmd, RuleConfig r, SocketMessage m) - { - var @in = SplitParams(cmd, 4); - if (@in.Length < 3) - { - await Log("exec: Incorrect number of parameters."); - } - - string result; - var target = await GetMessageTargetAsync(@in[1], m); - if (target == null) - { - await Log("Error: exec: Unable to resolve given channel."); - return; - } - - ProcessStartInfo ps = new ProcessStartInfo() - { - FileName = @in[2], - Arguments = (@in.Length > 3 ? @in[3] : ""), - UseShellExecute = false, - RedirectStandardOutput = true - }; - using (Process p = Process.Start(ps)) - { - p.WaitForExit(5000); // waiting at most 5 seconds - if (p.HasExited) - { - if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode); - using (var stdout = p.StandardOutput) - { - result = await stdout.ReadToEndAsync(); - } - } - else - { - await Log("exec: Process is taking too long to exit. Killing process."); - p.Kill(); - return; - } - } - - result = ProcessText(result.Trim(), m); - await target.SendMessageAsync(result); - } - - /// - /// Bans the sender of the incoming message. - /// No parameters. - /// - // TODO add parameter for message auto-deleting - private async Task RP_Ban(string cmd, RuleConfig r, SocketMessage m) - { - SocketGuild g = ((SocketGuildUser)m.Author).Guild; - await g.AddBanAsync(m.Author); - } - - /// - /// Grants or revokes a specified role to/from a given user. - /// Parameters: grantrole/revokerole (user ID or @_) (role ID) - /// - private async Task RP_GrantRevokeRole(string cmd, RuleConfig r, SocketMessage m) - { - string[] @in = SplitParams(cmd); - if (@in.Length != 3) - { - await Log($"Error: {@in[0]}: incorrect number of parameters."); - return; - } - if (!ulong.TryParse(@in[2], out var roleID)) - { - await Log($"Error: {@in[0]}: Invalid role ID specified."); - return; - } - - // Finding role - var gu = (SocketGuildUser)m.Author; - SocketRole rl = gu.Guild.GetRole(roleID); - if (rl == null) - { - await Log($"Error: {@in[0]}: Specified role not found."); - return; - } - - // Finding user - SocketGuildUser target; - if (@in[1] == "@_") - { - target = gu; - } - else - { - if (!ulong.TryParse(@in[1], out var userID)) - { - await Log($"Error: {@in[0]}: Invalid user ID specified."); - return; - } - target = gu.Guild.GetUser(userID); - if (target == null) - { - await Log($"Error: {@in[0]}: Given user ID does not exist in this server."); - return; - } - } - - if (@in[0].ToLower() == "grantrole") - { - await target.AddRoleAsync(rl); - } - else - { - await target.RemoveRoleAsync(rl); - } - } - - - } -} diff --git a/Feature/RegexResponder/RuleConfig.cs b/Feature/RegexResponder/RuleConfig.cs deleted file mode 100644 index 8668c9e..0000000 --- a/Feature/RegexResponder/RuleConfig.cs +++ /dev/null @@ -1,141 +0,0 @@ -using Newtonsoft.Json.Linq; -using Noikoio.RegexBot.ConfigItem; -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -namespace Noikoio.RegexBot.Feature.RegexResponder -{ - /// - /// Represents configuration for a single rule. - /// - [System.Diagnostics.DebuggerDisplay("Rule: {DisplayName}")] - internal struct RuleConfig - { - private string _displayName; - private IEnumerable _regex; - private IEnumerable _responses; - private FilterList _filter; - private int? _minLength; - private int? _maxLength; - private bool _modBypass; - private bool _matchEmbeds; - - public string DisplayName => _displayName; - public IEnumerable Regex => _regex; - public IEnumerable Responses => _responses; - public FilterList Filter => _filter; - public int? MinLength => _minLength; - public int? MaxLength => _maxLength; - public bool AllowModBypass => _modBypass; - public bool MatchEmbeds => _matchEmbeds; - - /// - /// Takes the JObject for a single rule and retrieves all data for use as a struct. - /// - /// Rule configuration input - /// - /// Thrown when encountering a missing or invalid value. - /// - public RuleConfig(JObject ruleconf) - { - // display name - validation should've been done outside this constructor already - _displayName = ruleconf["name"]?.Value(); - if (_displayName == null) - throw new RuleImportException("Display name not defined."); - - // 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 RegexError = "No regular expression patterns are defined."; - var regexes = new List(); - var rxconf = ruleconf["regex"]; - if (rxconf == null) - { - throw new RuleImportException(RegexError); - } - 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); - } - } - } - else - { - string rxstr = rxconf.Value(); - try - { - var rxx = new Regex(rxstr, opts); - regexes.Add(rxx); - } - catch (ArgumentException) - { - throw new RuleImportException("Failed to parse regular expression pattern: " + rxstr); - } - } - if (regexes.Count == 0) - { - throw new RuleImportException(RegexError); - } - _regex = regexes.ToArray(); - - // min/max length - try - { - _minLength = ruleconf["min"]?.Value(); - _maxLength = ruleconf["max"]?.Value(); - } - catch (FormatException) - { - throw new RuleImportException("Minimum/maximum values must be an integer."); - } - - // responses - const string ResponseError = "No responses have been defined for this rule."; - var responses = new List(); - var rsconf = ruleconf["response"]; - if (rsconf == null) - { - throw new RuleImportException(ResponseError); - } - if (rsconf.Type == JTokenType.Array) - { - foreach (var input in rsconf.Values()) responses.Add(input); - } - else - { - responses.Add(rsconf.Value()); - } - // TODO a bit of response validation here. at least check for blanks or something. - _responses = responses.ToArray(); - - // (white|black)list filtering - _filter = new FilterList(ruleconf); - - // moderator bypass toggle - true by default, must be explicitly set to false - bool? modoverride = ruleconf["AllowModBypass"]?.Value(); - _modBypass = modoverride.HasValue ? modoverride.Value : true; - - // embed matching mode - bool? embedmode = ruleconf["MatchEmbeds"]?.Value(); - _matchEmbeds = (embedmode.HasValue && embedmode == true); - } - } -} From 3c88bce94a888c003fdd4d9ec44455b6b90d5062 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 26 Aug 2017 10:24:37 -0700 Subject: [PATCH 09/20] 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 From 85f694ba7d7c1eb5363738ed6c88ee7a46306508 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sun, 27 Aug 2017 18:34:22 -0700 Subject: [PATCH 10/20] AutoMod rule processing to the thread pool It was originally like that, but was changed to be synchronous during debugging. I may have forgotten to revert it before committing. Also, removed note for future asynchronous response processing. Synchronous response execution is expected behavior at this point. --- Feature/AutoMod/AutoMod.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs index 2b2588d..a8cf37e 100644 --- a/Feature/AutoMod/AutoMod.cs +++ b/Feature/AutoMod/AutoMod.cs @@ -58,10 +58,9 @@ namespace Noikoio.RegexBot.Feature.AutoMod foreach (var rule in rules) { - // Checking for mod bypass here (Rule doesn't have access to mod list) + // Checking for mod bypass here (Rule.Match isn't able to access mod list) bool isMod = IsModerator(ch.Guild.Id, m); - //await Task.Run(async () => await ProcessMessage(m, rule, isMod)); - await ProcessMessage(m, rule, isMod); + await Task.Run(async () => await ProcessMessage(m, rule, isMod)); } } @@ -77,7 +76,6 @@ namespace Noikoio.RegexBot.Feature.AutoMod foreach (Response resp in r.Response) { - // TODO foreach await (when that becomes available) try { await resp.Invoke(m); From 019a5bdd96c6bfc21e4e1dda393f5a303d45a3d3 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 29 Aug 2017 22:25:47 -0700 Subject: [PATCH 11/20] AutoMod and AutoRespond items no longer need label All configuration items for AutoMod and AutoRespond are now defined as JSON properties. The name of the property is used as its label. --- Feature/AutoMod/AutoMod.cs | 7 +++++-- Feature/AutoMod/Rule.cs | 9 +++++---- Feature/AutoRespond/AutoRespond.cs | 2 +- Feature/AutoRespond/ResponseDefinition.cs | 18 ++++++++---------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs index a8cf37e..affbd45 100644 --- a/Feature/AutoMod/AutoMod.cs +++ b/Feature/AutoMod/AutoMod.cs @@ -29,12 +29,15 @@ namespace Noikoio.RegexBot.Feature.AutoMod public override async Task ProcessConfiguration(JToken configSection) { List rules = new List(); - foreach (JObject ruleconf in configSection) + + foreach (var def in configSection.Children()) { - var rule = new Rule(this, ruleconf); + string label = def.Name; + var rule = new Rule(this, def); rules.Add(rule); await Log($"Added rule '{rule.Label}'"); } + return rules.AsReadOnly(); } diff --git a/Feature/AutoMod/Rule.cs b/Feature/AutoMod/Rule.cs index 3c494a5..dd767db 100644 --- a/Feature/AutoMod/Rule.cs +++ b/Feature/AutoMod/Rule.cs @@ -41,13 +41,14 @@ namespace Noikoio.RegexBot.Feature.AutoMod /// /// Creates a new Rule instance to represent the given configuration. /// - public Rule(AutoMod instance, JObject ruleconf) + public Rule(AutoMod instance, JProperty definition) { _instance = instance; - _label = ruleconf["label"]?.Value(); - if (string.IsNullOrEmpty(_label)) - throw new RuleImportException("Label not defined."); + _label = definition.Name; + var ruleconf = (JObject)definition.Value; + // TODO validation. does the above line even throw an exception in the right cases? + // and what about the label? does it make for a good name? string errpfx = $" in definition for rule '{_label}'."; diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index b550ea1..51283d3 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -45,7 +45,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond public override Task ProcessConfiguration(JToken configSection) { var responses = new List(); - foreach (JObject def in configSection) + foreach (var def in configSection.Children()) { // Everything is left to the constructor responses.Add(new ResponseDefinition(def)); diff --git a/Feature/AutoRespond/ResponseDefinition.cs b/Feature/AutoRespond/ResponseDefinition.cs index 168cbe0..5aaa268 100644 --- a/Feature/AutoRespond/ResponseDefinition.cs +++ b/Feature/AutoRespond/ResponseDefinition.cs @@ -25,19 +25,17 @@ namespace Noikoio.RegexBot.Feature.AutoRespond public FilterList Filter => _filter; public RateLimitCache RateLimit => _limit; - public ResponseDefinition(JObject definition) + public ResponseDefinition(JProperty definition) { - // label - _label = definition["label"]?.Value(); - if (string.IsNullOrWhiteSpace(_label)) - throw new RuleImportException("Label is not defined in response definition."); + _label = definition.Name; + var data = (JObject)definition.Value; // error postfix string string errorpfx = $" in response definition for '{_label}'."; // regex trigger const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline; - string triggerstr = definition["trigger"]?.Value(); + string triggerstr = data["trigger"]?.Value(); if (string.IsNullOrWhiteSpace(triggerstr)) throw new RuleImportException("Regular expression trigger is not defined" + errorpfx); try @@ -56,7 +54,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond _rtype = ResponseType.None; // exec response --- - string execstr = definition["exec"]?.Value(); + string execstr = data["exec"]?.Value(); if (!string.IsNullOrWhiteSpace(execstr)) { _rbody = execstr; @@ -64,7 +62,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond } // reply response - string replystr = definition["reply"]?.Value(); + string replystr = data["reply"]?.Value(); if (!string.IsNullOrWhiteSpace(replystr)) { if (_rbody != null) @@ -78,10 +76,10 @@ namespace Noikoio.RegexBot.Feature.AutoRespond // --- // whitelist/blacklist filtering - _filter = new FilterList(definition); + _filter = new FilterList(data); // rate limiting - string rlstr = definition["ratelimit"].Value(); + string rlstr = data["ratelimit"].Value(); if (string.IsNullOrWhiteSpace(rlstr)) { _limit = new RateLimitCache(RateLimitCache.DefaultTimeout); From 2e9036b26aa7923031cb5d23649ff397dc59e58d Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 29 Aug 2017 22:39:59 -0700 Subject: [PATCH 12/20] Log autoresponse configuration load --- Feature/AutoRespond/AutoRespond.cs | 8 +++++--- Feature/AutoRespond/ResponseDefinition.cs | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index 51283d3..623a091 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -42,13 +42,15 @@ namespace Noikoio.RegexBot.Feature.AutoRespond } [ConfigSection("autoresponses")] - public override Task ProcessConfiguration(JToken configSection) + public override async Task ProcessConfiguration(JToken configSection) { var responses = new List(); foreach (var def in configSection.Children()) { - // Everything is left to the constructor - responses.Add(new ResponseDefinition(def)); + // All validation is left to the constructor + var resp = new ResponseDefinition(def); + responses.Add(resp); + await Log($"Added definition '{resp.Label}'"); } return Task.FromResult(responses.AsReadOnly()); diff --git a/Feature/AutoRespond/ResponseDefinition.cs b/Feature/AutoRespond/ResponseDefinition.cs index 5aaa268..2e66502 100644 --- a/Feature/AutoRespond/ResponseDefinition.cs +++ b/Feature/AutoRespond/ResponseDefinition.cs @@ -8,7 +8,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// /// Represents a single autoresponse definition. /// - struct ResponseDefinition + class ResponseDefinition { public enum ResponseType { None, Exec, Reply } @@ -96,5 +96,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond } } } + + public override string ToString() => $"Autoresponse definition '{Label}'"; } } From d560ca5c9425e4ebd478decf64f5b5d4ccae9d3f Mon Sep 17 00:00:00 2001 From: Noikoio Date: Wed, 30 Aug 2017 00:28:34 -0700 Subject: [PATCH 13/20] Fix exception when rate limit not defined --- Feature/AutoRespond/ResponseDefinition.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Feature/AutoRespond/ResponseDefinition.cs b/Feature/AutoRespond/ResponseDefinition.cs index 2e66502..9b0f05e 100644 --- a/Feature/AutoRespond/ResponseDefinition.cs +++ b/Feature/AutoRespond/ResponseDefinition.cs @@ -79,7 +79,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond _filter = new FilterList(data); // rate limiting - string rlstr = data["ratelimit"].Value(); + string rlstr = data["ratelimit"]?.Value(); if (string.IsNullOrWhiteSpace(rlstr)) { _limit = new RateLimitCache(RateLimitCache.DefaultTimeout); From 042e98a022a9aa46595e7f9e2bcfc93244114afb Mon Sep 17 00:00:00 2001 From: Noikoio Date: Wed, 30 Aug 2017 00:28:51 -0700 Subject: [PATCH 14/20] Fix AutoRespond configuration not able to be retrieved --- Feature/AutoRespond/AutoRespond.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index 623a091..a367669 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -53,7 +53,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond await Log($"Added definition '{resp.Label}'"); } - return Task.FromResult(responses.AsReadOnly()); + return responses.AsReadOnly(); } } } From a8b4dfd4c3044acc50577de166cdcb93c7ad8159 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Fri, 1 Sep 2017 00:53:40 -0700 Subject: [PATCH 15/20] Fix AutoRespond rate limit being applied incorrectly And renamed RateLimitCache method to something that makes more sense. --- Feature/AutoRespond/AutoRespond_Process.cs | 8 ++++---- Feature/AutoRespond/RateLimitCache.cs | 13 ++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs index 8ae411a..0620349 100644 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -10,12 +10,12 @@ namespace Noikoio.RegexBot.Feature.AutoRespond { // Check filters if (def.Filter.IsFiltered(msg)) return; + + // Check if the trigger is a match + if (!def.Trigger.IsMatch(msg.Content)) return; // Check rate limit - if (!def.RateLimit.AddUsage(msg.Channel.Id)) return; - - // Check if the trigger is a match, of course - if (!def.Trigger.IsMatch(msg.Content)) return; + if (!def.RateLimit.AllowUsage(msg.Channel.Id)) return; await Log($"'{def.Label}' triggered in #{msg.Channel.Name} by {msg.Author}"); var (type, text) = def.Response; diff --git a/Feature/AutoRespond/RateLimitCache.cs b/Feature/AutoRespond/RateLimitCache.cs index cc3e4f2..20e92a7 100644 --- a/Feature/AutoRespond/RateLimitCache.cs +++ b/Feature/AutoRespond/RateLimitCache.cs @@ -25,18 +25,21 @@ namespace Noikoio.RegexBot.Feature.AutoRespond } /// - /// Adds a cache item corersponding to the given ID. + /// Checks if a "usage" is allowed for the given value. /// Items added to cache will be removed after the number of seconds specified in . /// /// The ID to add to the cache. /// True on success. False if the given ID already exists. - public bool AddUsage(ulong id) + public bool AllowUsage(ulong id) { if (_timeout == 0) return true; - Clean(); - if (_cache.ContainsKey(id)) return false; - _cache.Add(id, DateTime.Now); + lock (this) + { + Clean(); + if (_cache.ContainsKey(id)) return false; + _cache.Add(id, DateTime.Now); + } return true; } From 01b2ef1f1b7d92ab724f532321164e03d2497948 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Mon, 4 Sep 2017 18:55:03 -0700 Subject: [PATCH 16/20] Log guild name along with channel on rule match --- Feature/AutoMod/AutoMod.cs | 2 +- Feature/AutoRespond/AutoRespond_Process.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs index affbd45..b8f955e 100644 --- a/Feature/AutoMod/AutoMod.cs +++ b/Feature/AutoMod/AutoMod.cs @@ -75,7 +75,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod if (!r.Match(m, isMod)) return; // TODO make log optional; configurable - await Log($"{r} triggered by {m.Author.ToString()}"); + await Log($"{r} triggered by {m.Author} in {((SocketGuildChannel)m.Channel).Guild.Name}/#{m.Channel.Name}"); foreach (Response resp in r.Response) { diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs index 0620349..0275b98 100644 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -16,8 +16,8 @@ namespace Noikoio.RegexBot.Feature.AutoRespond // Check rate limit if (!def.RateLimit.AllowUsage(msg.Channel.Id)) return; - - await Log($"'{def.Label}' triggered in #{msg.Channel.Name} by {msg.Author}"); + + await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}"); var (type, text) = def.Response; if (type == ResponseDefinition.ResponseType.Reply) await ProcessReply(msg, text); else if (type == ResponseDefinition.ResponseType.Exec) await ProcessExec(msg, text); From 120f31a88b49b32cd7465a128a6dff22dd25bee4 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 5 Sep 2017 09:10:46 -0700 Subject: [PATCH 17/20] Display a count of total rules added on load --- Feature/AutoMod/AutoMod.cs | 4 ++-- Feature/AutoRespond/AutoRespond.cs | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs index b8f955e..75776bc 100644 --- a/Feature/AutoMod/AutoMod.cs +++ b/Feature/AutoMod/AutoMod.cs @@ -35,9 +35,9 @@ namespace Noikoio.RegexBot.Feature.AutoMod string label = def.Name; var rule = new Rule(this, def); rules.Add(rule); - await Log($"Added rule '{rule.Label}'"); } - + if (rules.Count > 0) + await Log($"Loaded {rules.Count} rule(s) from configuration."); return rules.AsReadOnly(); } diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index a367669..eabf587 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -50,9 +50,10 @@ namespace Noikoio.RegexBot.Feature.AutoRespond // All validation is left to the constructor var resp = new ResponseDefinition(def); responses.Add(resp); - await Log($"Added definition '{resp.Label}'"); } + if (responses.Count > 0) + await Log($"Loaded {responses.Count} definition(s) from configuration."); return responses.AsReadOnly(); } } From 3ff2ef28f0d9504c8f13402075d1b99efd520f32 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 5 Sep 2017 10:18:07 -0700 Subject: [PATCH 18/20] Organized class files, names --- Feature/AutoMod/AutoMod.cs | 13 +++--- Feature/AutoMod/{Rule.cs => ConfigItem.cs} | 13 +++--- .../Response.cs => ResponseBase.cs} | 40 +++++++++---------- Feature/AutoMod/Responses/Ban.cs | 4 +- Feature/AutoMod/Responses/Kick.cs | 4 +- Feature/AutoMod/Responses/Remove.cs | 4 +- Feature/AutoMod/Responses/Report.cs | 4 +- Feature/AutoMod/Responses/RoleManipulation.cs | 4 +- Feature/AutoMod/Responses/Say.cs | 4 +- Feature/AutoRespond/AutoRespond.cs | 6 +-- Feature/AutoRespond/AutoRespond_Process.cs | 6 +-- .../{ResponseDefinition.cs => ConfigItem.cs} | 4 +- 12 files changed, 52 insertions(+), 54 deletions(-) rename Feature/AutoMod/{Rule.cs => ConfigItem.cs} (94%) rename Feature/AutoMod/{Responses/Response.cs => ResponseBase.cs} (81%) rename Feature/AutoRespond/{ResponseDefinition.cs => ConfigItem.cs} (97%) diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs index 75776bc..733cca4 100644 --- a/Feature/AutoMod/AutoMod.cs +++ b/Feature/AutoMod/AutoMod.cs @@ -1,6 +1,5 @@ using Discord.WebSocket; using Newtonsoft.Json.Linq; -using Noikoio.RegexBot.Feature.AutoMod.Responses; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -28,12 +27,12 @@ namespace Noikoio.RegexBot.Feature.AutoMod [ConfigSection("automod")] public override async Task ProcessConfiguration(JToken configSection) { - List rules = new List(); + List rules = new List(); foreach (var def in configSection.Children()) { string label = def.Name; - var rule = new Rule(this, def); + var rule = new ConfigItem(this, def); rules.Add(rule); } if (rules.Count > 0) @@ -56,12 +55,12 @@ namespace Noikoio.RegexBot.Feature.AutoMod if (ch == null) return; // Get rules - var rules = GetConfig(ch.Guild.Id) as IEnumerable; + var rules = GetConfig(ch.Guild.Id) as IEnumerable; if (rules == null) return; foreach (var rule in rules) { - // Checking for mod bypass here (Rule.Match isn't able to access mod list) + // Checking for mod bypass here (ConfigItem.Match isn't able to access mod list) bool isMod = IsModerator(ch.Guild.Id, m); await Task.Run(async () => await ProcessMessage(m, rule, isMod)); } @@ -70,14 +69,14 @@ namespace Noikoio.RegexBot.Feature.AutoMod /// /// Checks if the incoming message matches the given rule, and executes responses if necessary. /// - private async Task ProcessMessage(SocketMessage m, Rule r, bool isMod) + private async Task ProcessMessage(SocketMessage m, ConfigItem r, bool isMod) { if (!r.Match(m, isMod)) return; // TODO make log optional; configurable await Log($"{r} triggered by {m.Author} in {((SocketGuildChannel)m.Channel).Guild.Name}/#{m.Channel.Name}"); - foreach (Response resp in r.Response) + foreach (ResponseBase resp in r.Response) { try { diff --git a/Feature/AutoMod/Rule.cs b/Feature/AutoMod/ConfigItem.cs similarity index 94% rename from Feature/AutoMod/Rule.cs rename to Feature/AutoMod/ConfigItem.cs index dd767db..776dc24 100644 --- a/Feature/AutoMod/Rule.cs +++ b/Feature/AutoMod/ConfigItem.cs @@ -2,7 +2,6 @@ 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; @@ -15,12 +14,12 @@ namespace Noikoio.RegexBot.Feature.AutoMod /// Representation of a single AutoMod rule. /// Data stored within cannot be edited. /// - class Rule + class ConfigItem { readonly AutoMod _instance; readonly string _label; readonly IEnumerable _regex; - readonly ICollection _responses; + readonly ICollection _responses; readonly FilterList _filter; readonly int _msgMinLength; readonly int _msgMaxLength; @@ -29,7 +28,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod public string Label => _label; public IEnumerable Regex => _regex; - public ICollection Response => _responses; + public ICollection Response => _responses; public FilterList Filter => _filter; public (int?, int?) MatchLengthMinMaxLimit => (_msgMinLength, _msgMaxLength); public bool AllowsModBypass => _modBypass; @@ -41,7 +40,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod /// /// Creates a new Rule instance to represent the given configuration. /// - public Rule(AutoMod instance, JProperty definition) + public ConfigItem(AutoMod instance, JProperty definition) { _instance = instance; @@ -123,11 +122,11 @@ namespace Noikoio.RegexBot.Feature.AutoMod { if (rsconf.Type == JTokenType.Array) { - _responses = Responses.Response.ReadConfiguration(this, rsconf.Values()); + _responses = ResponseBase.ReadConfiguration(this, rsconf.Values()); } else { - _responses = Responses.Response.ReadConfiguration(this, new string[] { rsconf.Value() }); + _responses = ResponseBase.ReadConfiguration(this, new string[] { rsconf.Value() }); } } catch (RuleImportException ex) diff --git a/Feature/AutoMod/Responses/Response.cs b/Feature/AutoMod/ResponseBase.cs similarity index 81% rename from Feature/AutoMod/Responses/Response.cs rename to Feature/AutoMod/ResponseBase.cs index 5149aed..67a9f20 100644 --- a/Feature/AutoMod/Responses/Response.cs +++ b/Feature/AutoMod/ResponseBase.cs @@ -7,19 +7,19 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Threading.Tasks; -namespace Noikoio.RegexBot.Feature.AutoMod.Responses +namespace Noikoio.RegexBot.Feature.AutoMod { /// /// Base class for all Response classes. /// Contains helper methods for use by response code. /// [DebuggerDisplay("Response: {_cmdline}")] - abstract class Response + abstract class ResponseBase { - private readonly Rule _rule; + private readonly ConfigItem _rule; private readonly string _cmdline; - protected Rule Rule => _rule; + protected ConfigItem Rule => _rule; private DiscordSocketClient Client => _rule.Discord; public string CmdLine => _cmdline; public string CmdArg0 { @@ -33,7 +33,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// /// Deriving constructor should do validation of incoming . /// - public Response(Rule rule, string cmdline) + public ResponseBase(ConfigItem rule, string cmdline) { _rule = rule; _cmdline = cmdline; @@ -54,23 +54,23 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses 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) } + { "ban", typeof(Responses.Ban) }, + { "kick", typeof(Responses.Kick) }, + { "say", typeof(Responses.Say) }, + { "send", typeof(Responses.Say) }, + { "delete", typeof(Responses.Remove) }, + { "remove", typeof(Responses.Remove) }, + { "report", typeof(Responses.Report) }, + { "addrole", typeof(Responses.RoleManipulation) }, + { "grantrole", typeof(Responses.RoleManipulation) }, + { "delrole", typeof(Responses.RoleManipulation) }, + { "removerole", typeof(Responses.RoleManipulation) }, + { "revokerole", typeof(Responses.RoleManipulation) } }); - public static Response[] ReadConfiguration(Rule r, IEnumerable responses) + public static ResponseBase[] ReadConfiguration(ConfigItem r, IEnumerable responses) { - var result = new List(); + var result = new List(); foreach (var line in responses) { if (string.IsNullOrWhiteSpace(line)) @@ -84,7 +84,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses if (!_commands.TryGetValue(basecmd, out rt)) throw new RuleImportException($"'{basecmd}' is not a valid response"); - var newresponse = Activator.CreateInstance(rt, r, line) as Response; + var newresponse = Activator.CreateInstance(rt, r, line) as ResponseBase; if (newresponse == null) throw new Exception("An unknown error occurred when attempting to create a new Response object."); result.Add(newresponse); diff --git a/Feature/AutoMod/Responses/Ban.cs b/Feature/AutoMod/Responses/Ban.cs index 41b7139..0cdd066 100644 --- a/Feature/AutoMod/Responses/Ban.cs +++ b/Feature/AutoMod/Responses/Ban.cs @@ -9,11 +9,11 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// Bans the invoking user. /// Parameters: ban [days = 0] /// - class Ban : Response + class Ban : ResponseBase { readonly int _purgeDays; - public Ban(Rule rule, string cmdline) : base(rule, cmdline) + public Ban(ConfigItem rule, string cmdline) : base(rule, cmdline) { var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (line.Length == 1) diff --git a/Feature/AutoMod/Responses/Kick.cs b/Feature/AutoMod/Responses/Kick.cs index df43cfe..48e311a 100644 --- a/Feature/AutoMod/Responses/Kick.cs +++ b/Feature/AutoMod/Responses/Kick.cs @@ -9,9 +9,9 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// Kicks the invoking user. /// Takes no parameters. /// - class Kick : Response + class Kick : ResponseBase { - public Kick(Rule rule, string cmdline) : base(rule, cmdline) + public Kick(ConfigItem rule, string cmdline) : base(rule, cmdline) { // Throw exception if extra parameters found if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1) diff --git a/Feature/AutoMod/Responses/Remove.cs b/Feature/AutoMod/Responses/Remove.cs index 7f4d949..bc9af6a 100644 --- a/Feature/AutoMod/Responses/Remove.cs +++ b/Feature/AutoMod/Responses/Remove.cs @@ -9,9 +9,9 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// Removes the invoking message. /// Takes no parameters. /// - class Remove : Response + class Remove : ResponseBase { - public Remove(Rule rule, string cmdline) : base(rule, cmdline) + public Remove(ConfigItem rule, string cmdline) : base(rule, cmdline) { // Throw exception if extra parameters found if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1) diff --git a/Feature/AutoMod/Responses/Report.cs b/Feature/AutoMod/Responses/Report.cs index 00efe50..c858899 100644 --- a/Feature/AutoMod/Responses/Report.cs +++ b/Feature/AutoMod/Responses/Report.cs @@ -12,11 +12,11 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// about the rule making use of this command, to the given target. /// Parameters: report (target) /// - class Report : Response + class Report : ResponseBase { readonly string _target; - public Report(Rule rule, string cmdline) : base(rule, cmdline) + public Report(ConfigItem 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"); diff --git a/Feature/AutoMod/Responses/RoleManipulation.cs b/Feature/AutoMod/Responses/RoleManipulation.cs index 3fd57ba..75dab33 100644 --- a/Feature/AutoMod/Responses/RoleManipulation.cs +++ b/Feature/AutoMod/Responses/RoleManipulation.cs @@ -10,7 +10,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// Manipulates a given user's role. /// Parameters: (command) (target) (role ID) /// - class RoleManipulation : Response + class RoleManipulation : ResponseBase { enum ManipulationType { None, Add, Remove } @@ -18,7 +18,7 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses readonly string _target; readonly EntityName _role; - public RoleManipulation(Rule rule, string cmdline) : base(rule, cmdline) + public RoleManipulation(ConfigItem rule, string cmdline) : base(rule, cmdline) { var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (line.Length != 3) diff --git a/Feature/AutoMod/Responses/Say.cs b/Feature/AutoMod/Responses/Say.cs index 3dcbc8e..b793e5e 100644 --- a/Feature/AutoMod/Responses/Say.cs +++ b/Feature/AutoMod/Responses/Say.cs @@ -9,12 +9,12 @@ namespace Noikoio.RegexBot.Feature.AutoMod.Responses /// Sends a message to the given target. /// Parameters: say (target) (message) /// - class Say : Response + class Say : ResponseBase { private readonly string _target; private readonly string _payload; - public Say(Rule rule, string cmdline) : base(rule, cmdline) + public Say(ConfigItem 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."); diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index eabf587..9897c9b 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -34,7 +34,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond if (ch == null) return; // TODO either search server by name or remove server name support entirely - var defs = GetConfig(ch.Guild.Id) as IEnumerable; + var defs = GetConfig(ch.Guild.Id) as IEnumerable; if (defs == null) return; foreach (var def in defs) @@ -44,11 +44,11 @@ namespace Noikoio.RegexBot.Feature.AutoRespond [ConfigSection("autoresponses")] public override async Task ProcessConfiguration(JToken configSection) { - var responses = new List(); + var responses = new List(); foreach (var def in configSection.Children()) { // All validation is left to the constructor - var resp = new ResponseDefinition(def); + var resp = new ConfigItem(def); responses.Add(resp); } diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs index 0275b98..aca8689 100644 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ b/Feature/AutoRespond/AutoRespond_Process.cs @@ -6,7 +6,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond { partial class AutoRespond { - private async Task ProcessMessage(SocketMessage msg, ResponseDefinition def) + private async Task ProcessMessage(SocketMessage msg, ConfigItem def) { // Check filters if (def.Filter.IsFiltered(msg)) return; @@ -19,8 +19,8 @@ namespace Noikoio.RegexBot.Feature.AutoRespond await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}"); var (type, text) = def.Response; - if (type == ResponseDefinition.ResponseType.Reply) await ProcessReply(msg, text); - else if (type == ResponseDefinition.ResponseType.Exec) await ProcessExec(msg, text); + if (type == ConfigItem.ResponseType.Reply) await ProcessReply(msg, text); + else if (type == ConfigItem.ResponseType.Exec) await ProcessExec(msg, text); } private async Task ProcessReply(SocketMessage msg, string text) diff --git a/Feature/AutoRespond/ResponseDefinition.cs b/Feature/AutoRespond/ConfigItem.cs similarity index 97% rename from Feature/AutoRespond/ResponseDefinition.cs rename to Feature/AutoRespond/ConfigItem.cs index 9b0f05e..c489ca5 100644 --- a/Feature/AutoRespond/ResponseDefinition.cs +++ b/Feature/AutoRespond/ConfigItem.cs @@ -8,7 +8,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// /// Represents a single autoresponse definition. /// - class ResponseDefinition + class ConfigItem { public enum ResponseType { None, Exec, Reply } @@ -25,7 +25,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond public FilterList Filter => _filter; public RateLimitCache RateLimit => _limit; - public ResponseDefinition(JProperty definition) + public ConfigItem(JProperty definition) { _label = definition.Name; var data = (JObject)definition.Value; From a40c115d87eb2f258a389ea1d29b46d6439ea359 Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 5 Sep 2017 10:26:13 -0700 Subject: [PATCH 19/20] Organized AutoRespond code --- Feature/AutoRespond/AutoRespond.cs | 52 ++++++++++++++++++ Feature/AutoRespond/AutoRespond_Process.cs | 64 ---------------------- Feature/AutoRespond/ConfigItem.cs | 21 ++++++- 3 files changed, 72 insertions(+), 65 deletions(-) delete mode 100644 Feature/AutoRespond/AutoRespond_Process.cs diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs index 9897c9b..2de818c 100644 --- a/Feature/AutoRespond/AutoRespond.cs +++ b/Feature/AutoRespond/AutoRespond.cs @@ -1,6 +1,7 @@ using Discord.WebSocket; using Newtonsoft.Json.Linq; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; namespace Noikoio.RegexBot.Feature.AutoRespond @@ -20,6 +21,7 @@ namespace Noikoio.RegexBot.Feature.AutoRespond /// partial class AutoRespond : BotFeature { + #region BotFeature implementation public override string Name => "AutoRespond"; public AutoRespond(DiscordSocketClient client) : base(client) @@ -56,5 +58,55 @@ namespace Noikoio.RegexBot.Feature.AutoRespond await Log($"Loaded {responses.Count} definition(s) from configuration."); return responses.AsReadOnly(); } + #endregion + + private async Task ProcessMessage(SocketMessage msg, ConfigItem def) + { + if (!def.Match(msg)) return; + + await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}"); + + var (type, text) = def.Response; + if (type == ConfigItem.ResponseType.Reply) await ProcessReply(msg, text); + else if (type == ConfigItem.ResponseType.Exec) await ProcessExec(msg, text); + } + + private async Task ProcessReply(SocketMessage msg, string text) + { + await msg.Channel.SendMessageAsync(text); + } + + private async Task ProcessExec(SocketMessage msg, string text) + { + string[] cmdline = text.Split(new char[] { ' ' }, 2); + + ProcessStartInfo ps = new ProcessStartInfo() + { + FileName = cmdline[0], + Arguments = (cmdline.Length == 2 ? cmdline[1] : ""), + UseShellExecute = false, // ??? + CreateNoWindow = true, + RedirectStandardOutput = true + }; + using (Process p = Process.Start(ps)) + { + p.WaitForExit(5000); // waiting at most 5 seconds + if (p.HasExited) + { + if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode); + using (var stdout = p.StandardOutput) + { + var result = await stdout.ReadToEndAsync(); + await msg.Channel.SendMessageAsync(result); + } + } + else + { + await Log("exec: Process is taking too long to exit. Killing process."); + p.Kill(); + return; + } + } + } } } diff --git a/Feature/AutoRespond/AutoRespond_Process.cs b/Feature/AutoRespond/AutoRespond_Process.cs deleted file mode 100644 index aca8689..0000000 --- a/Feature/AutoRespond/AutoRespond_Process.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Discord.WebSocket; -using System.Diagnostics; -using System.Threading.Tasks; - -namespace Noikoio.RegexBot.Feature.AutoRespond -{ - partial class AutoRespond - { - private async Task ProcessMessage(SocketMessage msg, ConfigItem def) - { - // Check filters - if (def.Filter.IsFiltered(msg)) return; - - // Check if the trigger is a match - if (!def.Trigger.IsMatch(msg.Content)) return; - - // Check rate limit - if (!def.RateLimit.AllowUsage(msg.Channel.Id)) return; - - await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}"); - var (type, text) = def.Response; - if (type == ConfigItem.ResponseType.Reply) await ProcessReply(msg, text); - else if (type == ConfigItem.ResponseType.Exec) await ProcessExec(msg, text); - } - - private async Task ProcessReply(SocketMessage msg, string text) - { - await msg.Channel.SendMessageAsync(text); - } - - private async Task ProcessExec(SocketMessage msg, string text) - { - string[] cmdline = text.Split(new char[] { ' ' }, 2); - - ProcessStartInfo ps = new ProcessStartInfo() - { - FileName = cmdline[0], - Arguments = (cmdline.Length == 2 ? cmdline[1] : ""), - UseShellExecute = false, // ??? - CreateNoWindow = true, - RedirectStandardOutput = true - }; - using (Process p = Process.Start(ps)) - { - p.WaitForExit(5000); // waiting at most 5 seconds - if (p.HasExited) - { - if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode); - using (var stdout = p.StandardOutput) - { - var result = await stdout.ReadToEndAsync(); - await msg.Channel.SendMessageAsync(result); - } - } - else - { - await Log("exec: Process is taking too long to exit. Killing process."); - p.Kill(); - return; - } - } - } - } -} diff --git a/Feature/AutoRespond/ConfigItem.cs b/Feature/AutoRespond/ConfigItem.cs index c489ca5..e48d9a2 100644 --- a/Feature/AutoRespond/ConfigItem.cs +++ b/Feature/AutoRespond/ConfigItem.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json.Linq; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; using Noikoio.RegexBot.ConfigItem; using System; using System.Text.RegularExpressions; @@ -97,6 +98,24 @@ namespace Noikoio.RegexBot.Feature.AutoRespond } } + /// + /// 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) + { + // Filter check + if (Filter.IsFiltered(m)) return false; + + // Match check + if (!Trigger.IsMatch(m.Content)) return false; + + // Rate limit check - currently per channel + if (!RateLimit.AllowUsage(m.Channel.Id)) return false; + + return true; + } + public override string ToString() => $"Autoresponse definition '{Label}'"; } } From 14a811062e7bf4ee5d98591806f11e91e7b8f54a Mon Sep 17 00:00:00 2001 From: Noikoio Date: Tue, 5 Sep 2017 10:53:11 -0700 Subject: [PATCH 20/20] Renamed 'trigger' to 'regex', arrays now allowed To have regex config be consistent with AutoMod. --- Feature/AutoRespond/ConfigItem.cs | 60 +++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/Feature/AutoRespond/ConfigItem.cs b/Feature/AutoRespond/ConfigItem.cs index e48d9a2..06ebc47 100644 --- a/Feature/AutoRespond/ConfigItem.cs +++ b/Feature/AutoRespond/ConfigItem.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json.Linq; using Noikoio.RegexBot.ConfigItem; using System; +using System.Collections.Generic; using System.Text.RegularExpressions; namespace Noikoio.RegexBot.Feature.AutoRespond @@ -14,14 +15,14 @@ namespace Noikoio.RegexBot.Feature.AutoRespond public enum ResponseType { None, Exec, Reply } string _label; - Regex _trigger; + IEnumerable _regex; ResponseType _rtype; - string _rbody; // response body + string _rbody; private FilterList _filter; private RateLimitCache _limit; public string Label => _label; - public Regex Trigger => _trigger; + public IEnumerable Regex => _regex; public (ResponseType, string) Response => (_rtype, _rbody); public FilterList Filter => _filter; public RateLimitCache RateLimit => _limit; @@ -35,26 +36,48 @@ namespace Noikoio.RegexBot.Feature.AutoRespond string errorpfx = $" in response definition for '{_label}'."; // regex trigger + const string NoRegexError = "No regular expression patterns are defined"; + var regexes = new List(); const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline; - string triggerstr = data["trigger"]?.Value(); - if (string.IsNullOrWhiteSpace(triggerstr)) - throw new RuleImportException("Regular expression trigger is not defined" + errorpfx); - try + var rxconf = data["regex"]; + if (rxconf == null) throw new RuleImportException(NoRegexError + errorpfx); + if (rxconf.Type == JTokenType.Array) { - _trigger = new Regex(triggerstr, rxopts); + foreach (var input in rxconf.Values()) + { + try + { + Regex r = new Regex(input, rxopts); + regexes.Add(r); + } + catch (ArgumentException) + { + throw new RuleImportException( + $"Failed to parse regular expression pattern '{input}'{errorpfx}"); + } + } } - catch (ArgumentException ex) + else { - throw new RuleImportException - ("Failed to parse regular expression pattern" + errorpfx + - $" ({ex.GetType().Name}: {ex.Message})"); + string rxstr = rxconf.Value(); + try + { + Regex r = new Regex(rxstr, rxopts); + regexes.Add(r); + } + catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException) + { + throw new RuleImportException( + $"Failed to parse regular expression pattern '{rxstr}'{errorpfx}"); + } } + _regex = regexes.ToArray(); // response - defined in either "exec" or "reply", but not both _rbody = null; _rtype = ResponseType.None; - // exec response --- + // exec response string execstr = data["exec"]?.Value(); if (!string.IsNullOrWhiteSpace(execstr)) { @@ -108,7 +131,16 @@ namespace Noikoio.RegexBot.Feature.AutoRespond if (Filter.IsFiltered(m)) return false; // Match check - if (!Trigger.IsMatch(m.Content)) return false; + bool matchFound = false; + foreach (var item in Regex) + { + if (item.IsMatch(m.Content)) + { + matchFound = true; + break; + } + } + if (!matchFound) return false; // Rate limit check - currently per channel if (!RateLimit.AllowUsage(m.Channel.Id)) return false;