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.
This commit is contained in:
Noikoio 2018-09-01 21:33:02 -07:00
parent 9b6104030d
commit 1fbc9bca7f
3 changed files with 284 additions and 16 deletions

View file

@ -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
{
/// <summary>
/// Represents a commonly-used configuration structure: an array of strings consisting of <see cref="EntityName"/> values.
/// </summary>
public class EntityList : IEnumerable<EntityName>
{
private readonly IReadOnlyCollection<EntityName> _innerList;
/// <summary>Gets an enumerable collection of all role names defined in this list.</summary>
public IEnumerable<EntityName> Roles
=> _innerList.Where(n => n.Type == EntityType.Role);
/// <summary>Gets an enumerable collection of all channel names defined in this list.</summary>
public IEnumerable<EntityName> Channels
=> _innerList.Where(n => n.Type == EntityType.Channel);
/// <summary>Gets an enumerable collection of all user names defined in this list.</summary>
public IEnumerable<EntityName> Users
=> _innerList.Where(n => n.Type == EntityType.User);
/// <summary>
/// Creates a new EntityList instance with no data.
/// </summary>
public EntityList() : this(null, false) { }
/// <summary>
/// Creates a new EntityList instance using the given JSON token as input.
/// </summary>
/// <param name="input">JSON array to be used for input. For ease of use, null values are also accepted.</param>
/// <param name="enforceTypes">Specifies if all entities defined in configuration must have their type specified.</param>
/// <exception cref="ArgumentException">The input is not a JSON array.</exception>
/// <exception cref="ArgumentNullException">
/// Unintiutively, this exception is thrown if a user-provided configuration value is blank.
/// </exception>
/// <exception cref="FormatException">
/// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified.
/// </exception>
public EntityList(JToken input, bool enforceTypes)
{
if (input == null)
{
_innerList = new List<EntityName>().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<EntityName>();
foreach (var item in inputArray.Values<string>())
{
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
/// <summary>
/// Checks if the parameters of the given <see cref="SocketMessage"/> matches with
/// any entity specified in this list.
/// </summary>
/// <param name="keepId">
/// Specifies if EntityName instances within this list should have their internal ID value
/// updated if found during the matching process.
/// </param>
/// <returns>
/// 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.
/// </returns>
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<EntityName> GetEnumerator() => _innerList.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

View file

@ -8,12 +8,19 @@ namespace Kerobot.Common
/// <summary>
/// The type of entity specified in an <see cref="EntityName"/>.
/// </summary>
public enum EntityType { Unspecified, Role, Channel, User }
public enum EntityType
{
/// <summary>Default value. Is never referenced in regular usage.</summary>
Unspecified,
Role,
Channel,
User
}
/// <summary>
/// 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.
/// </summary>
public class EntityName
{
@ -33,7 +40,6 @@ namespace Kerobot.Common
/// </summary>
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.
/// <summary>
@ -42,6 +48,7 @@ namespace Kerobot.Common
/// </summary>
/// <param name="input">Input string in EntityName format.</param>
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
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
/// <summary>
/// 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 <paramref name="id"/> when and if the result is known.
/// </summary>
/// <param name="searchType">The entity type to which this instance corresponds to.</param>
/// <param name="id">If known, outputs the ID of the corresponding entity.</param>
/// <param name="keepId">Specifies if the internal ID value should be stored if a match is found.</param>
/// <returns>True if the ID is known.</returns>
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
/// <summary>
/// Returns the appropriate prefix corresponding to an EntityType.
/// </summary>
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';
}
}
/// <summary>
/// Returns a string representation of this EntityName, in EntityName format.
/// Returns a string representation of this item in proper EntityName format.
/// </summary>
public override string ToString()
{
string pf = Prefix(Type);
char pf = Prefix(Type);
if (Id.HasValue && Name != null)
return $"{pf}{Id.Value}::{Name}";

View file

@ -0,0 +1,109 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
namespace Kerobot.Common
{
/// <summary>
/// Represents commonly-used configuration regarding whitelist/blacklist filtering, including exemptions.
/// </summary>
public class FilterList
{
public enum FilterMode { None, Whitelist, Blacklist }
public FilterMode Mode { get; }
public EntityList FilteredList { get; }
public EntityList FilterExemptions { get; }
/// <summary>
/// Sets up a FilterList instance with the given JSON configuration section.
/// </summary>
/// <param name="config">
/// JSON object in which to attempt to find the given whitelist, blacklist, and/or excemption keys.
/// </param>
/// <param name="whitelistKey">The key in which to search for the whitelist. Set as null to disable.</param>
/// <param name="blacklistKey">The key in which to search for the blacklist. Set as null to disable.</param>
/// <param name="exemptKey">The key in which to search for filter exemptions. Set as null to disable.</param>
/// <exception cref="FormatException">
/// Thrown if there is a problem with input. The exception message specifies the reason.
/// </exception>
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);
}
}
}
/// <summary>
/// Determines if the parameters of the given message match up against the filtering
/// rules described within this instance.
/// </summary>
/// <param name="keepId">
/// See equivalent documentation for <see cref="EntityList.IsListMatch(SocketMessage, bool)"/>.
/// </param>
/// <returns>
/// True if the author or associated channel exists in and is not exempted by this instance.
/// </returns>
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");
}
}
}