From 1fbc9bca7f1c150a0ccae1d138227b458a4a1f5f Mon Sep 17 00:00:00 2001 From: Noikoio Date: Sat, 1 Sep 2018 21:33:02 -0700 Subject: [PATCH] Reimplemented EntityList, FilterList Based on similar classes from RegexBot. -EntityList no longer separates by entity type. -Both types allow caching their respective EntityName ID values. -FilterList supports loading from custom field names. --- Kerobot/Common/EntityList.cs | 144 +++++++++++++++++++++++++++++++++++ Kerobot/Common/EntityName.cs | 47 ++++++++---- Kerobot/Common/FilterList.cs | 109 ++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 Kerobot/Common/EntityList.cs create mode 100644 Kerobot/Common/FilterList.cs diff --git a/Kerobot/Common/EntityList.cs b/Kerobot/Common/EntityList.cs new file mode 100644 index 0000000..35925fd --- /dev/null +++ b/Kerobot/Common/EntityList.cs @@ -0,0 +1,144 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Kerobot.Common +{ + /// + /// Represents a commonly-used configuration structure: an array of strings consisting of values. + /// + public class EntityList : IEnumerable + { + private readonly IReadOnlyCollection _innerList; + + /// Gets an enumerable collection of all role names defined in this list. + public IEnumerable Roles + => _innerList.Where(n => n.Type == EntityType.Role); + + /// Gets an enumerable collection of all channel names defined in this list. + public IEnumerable Channels + => _innerList.Where(n => n.Type == EntityType.Channel); + + /// Gets an enumerable collection of all user names defined in this list. + public IEnumerable Users + => _innerList.Where(n => n.Type == EntityType.User); + + /// + /// Creates a new EntityList instance with no data. + /// + public EntityList() : this(null, false) { } + + /// + /// Creates a new EntityList instance using the given JSON token as input. + /// + /// JSON array to be used for input. For ease of use, null values are also accepted. + /// Specifies if all entities defined in configuration must have their type specified. + /// The input is not a JSON array. + /// + /// Unintiutively, this exception is thrown if a user-provided configuration value is blank. + /// + /// + /// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified. + /// + public EntityList(JToken input, bool enforceTypes) + { + if (input == null) + { + _innerList = new List().AsReadOnly(); + return; + } + + if (input.Type != JTokenType.Array) + throw new ArgumentException("JToken input must be a JSON array."); + var inputArray = (JArray)input; + + var list = new List(); + foreach (var item in inputArray.Values()) + { + var itemName = new EntityName(item); + if (enforceTypes && itemName.Type == EntityType.Unspecified) + throw new FormatException($"The following value is not prefixed: {item}"); + list.Add(itemName); + } + _innerList = list.AsReadOnly(); + } + + #region Entity matching + /// + /// Checks if the parameters of the given matches with + /// any entity specified in this list. + /// + /// + /// Specifies if EntityName instances within this list should have their internal ID value + /// updated if found during the matching process. + /// + /// + /// True if the message author exists in this list, or if the message's channel exists in this list, + /// or if the message author contains a role that exists in this list. + /// + public bool IsListMatch(SocketMessage msg, bool keepId) + { + var author = msg.Author as SocketGuildUser; + var authorRoles = author.Roles; + var channel = msg.Channel; + + foreach (var entry in this) + { + if (entry.Type == EntityType.Role) + { + if (entry.Id.HasValue) + { + return authorRoles.Any(r => r.Id == entry.Id.Value); + } + else + { + foreach (var r in authorRoles) + { + if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break; + if (keepId) entry.SetId(r.Id); + return true; + } + } + } + else if (entry.Type == EntityType.Channel) + { + if (entry.Id.HasValue) + { + return entry.Id.Value == channel.Id; + } + else + { + if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break; + if (keepId) entry.SetId(channel.Id); + return true; + } + } + else // User + { + if (entry.Id.HasValue) + { + return entry.Id.Value == author.Id; + } + else + { + if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break; + if (keepId) entry.SetId(author.Id); + return true; + } + } + } + + return false; + } + #endregion + + public override string ToString() => $"Entity list contains {_innerList.Count} item(s)."; + + public IEnumerator GetEnumerator() => _innerList.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/Kerobot/Common/EntityName.cs b/Kerobot/Common/EntityName.cs index c7fbd75..b0c4e00 100644 --- a/Kerobot/Common/EntityName.cs +++ b/Kerobot/Common/EntityName.cs @@ -8,12 +8,19 @@ namespace Kerobot.Common /// /// The type of entity specified in an . /// - public enum EntityType { Unspecified, Role, Channel, User } + public enum EntityType + { + /// Default value. Is never referenced in regular usage. + Unspecified, + Role, + Channel, + User + } /// /// Helper class that holds an entity's name, ID, or both. /// Meant to be used during configuration processing in cases where the configuration expects - /// an entity name to be defined in a certain way and may or may not include its snowflake ID. + /// an entity name to be defined in a certain way which may or may not include its snowflake ID. /// public class EntityName { @@ -33,7 +40,6 @@ namespace Kerobot.Common /// public string Name { get; private set; } - // TODO elsewhere: find a way to emit a warning if the user specified a name without ID in configuration. /// @@ -42,6 +48,7 @@ namespace Kerobot.Common /// /// Input string in EntityName format. /// Input string is null or blank. + /// Input string cannot be resolved to an entity type. public EntityName(string input) { if (string.IsNullOrWhiteSpace(input)) @@ -54,9 +61,11 @@ namespace Kerobot.Common if (input[0] == '&') Type = EntityType.Role; else if (input[0] == '#') Type = EntityType.Channel; else if (input[0] == '@') Type = EntityType.User; - - if (Type != EntityType.Unspecified) input = input.Substring(1); } + if (Type == EntityType.Unspecified) + throw new ArgumentException("Entity type unable to be inferred by given input."); + + input = input.Substring(1); // Remove prefix // Input contains ID/Label separator? int separator = input.IndexOf("::"); @@ -93,16 +102,22 @@ namespace Kerobot.Common } } + internal void SetId(ulong id) + { + if (!Id.HasValue) Id = id; + } + #region Name to ID resolving /// /// Attempts to determine the corresponding ID if not already known. - /// Searches the given guild for it and stores it into this instance if found. - /// Immediately returns the ID if it is already known by this instance. + /// Searches the specified guild and stores it into this instance if found. + /// Places the ID into when and if the result is known. /// /// The entity type to which this instance corresponds to. /// If known, outputs the ID of the corresponding entity. + /// Specifies if the internal ID value should be stored if a match is found. /// True if the ID is known. - public bool TryResolve(SocketGuild searchGuild, out ulong id, EntityType searchType = EntityType.Unspecified) + public bool TryResolve(SocketGuild searchGuild, out ulong id, bool keepId, EntityType searchType) { if (Id.HasValue) { @@ -141,7 +156,7 @@ namespace Kerobot.Common { if (resolver.Invoke(item)) { - Id = item.Id; + if (keepId) Id = item.Id; id = Id.Value; return true; } @@ -175,23 +190,23 @@ namespace Kerobot.Common /// /// Returns the appropriate prefix corresponding to an EntityType. /// - public static string Prefix(EntityType t) + public static char Prefix(EntityType t) { switch (t) { - case EntityType.Role: return "&"; - case EntityType.Channel: return "#"; - case EntityType.User: return "@"; - default: return ""; + case EntityType.Role: return '&'; + case EntityType.Channel: return '#'; + case EntityType.User: return '@'; + default: return '\0'; } } /// - /// Returns a string representation of this EntityName, in EntityName format. + /// Returns a string representation of this item in proper EntityName format. /// public override string ToString() { - string pf = Prefix(Type); + char pf = Prefix(Type); if (Id.HasValue && Name != null) return $"{pf}{Id.Value}::{Name}"; diff --git a/Kerobot/Common/FilterList.cs b/Kerobot/Common/FilterList.cs new file mode 100644 index 0000000..b148692 --- /dev/null +++ b/Kerobot/Common/FilterList.cs @@ -0,0 +1,109 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using System; + +namespace Kerobot.Common +{ + /// + /// Represents commonly-used configuration regarding whitelist/blacklist filtering, including exemptions. + /// + public class FilterList + { + public enum FilterMode { None, Whitelist, Blacklist } + + public FilterMode Mode { get; } + public EntityList FilteredList { get; } + public EntityList FilterExemptions { get; } + + /// + /// Sets up a FilterList instance with the given JSON configuration section. + /// + /// + /// JSON object in which to attempt to find the given whitelist, blacklist, and/or excemption keys. + /// + /// The key in which to search for the whitelist. Set as null to disable. + /// The key in which to search for the blacklist. Set as null to disable. + /// The key in which to search for filter exemptions. Set as null to disable. + /// + /// Thrown if there is a problem with input. The exception message specifies the reason. + /// + public FilterList( + JObject config, + string whitelistKey = "whitelist", + string blacklistKey = "blacklist", + string exemptKey = "exempt") + { + if ((whitelistKey != null && config[whitelistKey] != null) && + (blacklistKey != null && config[blacklistKey] != null)) + { + throw new FormatException($"Cannot have both '{whitelistKey}' and '{blacklistKey}' defined at once."); + } + + JToken valueSrc = null; + if (whitelistKey != null) + { + // Try getting a whitelist + valueSrc = config[whitelistKey]; + Mode = FilterMode.Whitelist; + } + else if (valueSrc != null && blacklistKey != null) + { + // Try getting a blacklist + valueSrc = config[blacklistKey]; + Mode = FilterMode.Blacklist; + } + else + { + // Got neither. Have an empty list. + Mode = FilterMode.None; + FilteredList = new EntityList(); + FilterExemptions = new EntityList(); + return; + } + + // Verify that the specified array is actually an array. + if (valueSrc.Type != JTokenType.Array) throw new ArgumentException("Given list must be a JSON array."); + FilteredList = new EntityList((JArray)valueSrc, true); + + // Verify the same for the exemption list. + FilterExemptions = new EntityList(); + if (exemptKey != null) + { + var exc = config[exemptKey]; + if (exc != null && exc.Type == JTokenType.Array) + { + FilterExemptions = new EntityList(exc, true); + } + } + } + + /// + /// Determines if the parameters of the given message match up against the filtering + /// rules described within this instance. + /// + /// + /// See equivalent documentation for . + /// + /// + /// True if the author or associated channel exists in and is not exempted by this instance. + /// + public bool IsFiltered(SocketMessage msg, bool keepId) + { + if (Mode == FilterMode.None) return false; + + bool inFilter = FilteredList.IsListMatch(msg, keepId); + if (Mode == FilterMode.Whitelist) + { + if (!inFilter) return true; + return FilterExemptions.IsListMatch(msg, keepId); + } + else if (Mode == FilterMode.Blacklist) + { + if (!inFilter) return false; + return !FilterExemptions.IsListMatch(msg, keepId); + } + + throw new Exception("it is not possible for this to happen"); + } + } +}