Ported AutoResponder from RegexBot

Changes:
-Renamed from AutoRespond
-Rate limiting class is now generic in case it may be needed
-Multiple responses can be specified per definition
--One response is randomly chosen to be sent out
This commit is contained in:
Noikoio 2018-09-21 21:21:43 -07:00
parent 7ce2788594
commit 8bee71a863
4 changed files with 298 additions and 27 deletions

View file

@ -0,0 +1,61 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Kerobot.Modules.AutoResponder
{
/// <summary>
/// Provides the capability to define text responses to pattern-based triggers for fun or informational
/// purposes. Although in essence similar to <see cref="RegexModerator.RegexModerator"/>, it is a better
/// fit for non-moderation use cases and has specific features suitable to that end.
/// </summary>
[KerobotModule]
class AutoResponder : ModuleBase
{
public AutoResponder(Kerobot kb) : base(kb)
{
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
}
private async Task DiscordClient_MessageReceived(SocketMessage arg)
{
if (!(arg.Channel is SocketGuildChannel ch)) return;
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
if (definitions == null) return; // No configuration in this guild; do no further processing
var tasks = new List<Task>();
foreach (var def in definitions)
{
tasks.Add(Task.Run(async () => await ProcessMessageAsync(arg, def)));
}
await Task.WhenAll(tasks);
}
public override Task<object> CreateGuildStateAsync(JToken config)
{
// Guild state is a read-only IEnumerable<Definition>
if (config == null) return null;
var guildDefs = new List<Definition>();
foreach (var defconf in config.Children<JProperty>())
{
// Getting all JProperties in the section.
// Validation of data is left to the Definition constructor. ModuleLoadException thrown here:
var def = new Definition(defconf);
guildDefs.Add(def);
// TODO global options
}
return Task.FromResult<object>(guildDefs.AsReadOnly());
}
private async Task ProcessMessageAsync(SocketMessage msg, Definition def)
{
if (!def.Match(msg)) return;
await msg.Channel.SendMessageAsync(def.GetResponse());
}
}
}

View file

@ -0,0 +1,183 @@
using Discord.WebSocket;
using Kerobot.Common;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace Kerobot.Modules.AutoResponder
{
/// <summary>
/// Representation of a single <see cref="AutoResponder"/> configuration definition.
/// </summary>
class Definition
{
private static readonly Random Chance = new Random();
public string Label { get; }
public IEnumerable<Regex> Regex { get; }
public IReadOnlyList<string> Response { get; }
public FilterList Filter { get; }
public RateLimit<ulong> RateLimit { get; }
public double RandomChance { get; }
/// <summary>
/// Creates an instance based on JSON configuration.
/// </summary>
/// <param name="config"></param>
public Definition(JProperty incoming)
{
Label = incoming.Name;
if (incoming.Value.Type != JTokenType.Object)
throw new ModuleLoadException($"Value of {nameof(AutoResponder)} definition must be a JSON object.");
var data = (JObject)incoming.Value;
// error message postfix
var errorpfx = $" in AutoRespond definition '{Label}'.";
// Parse regex
const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline;
var regexes = new List<Regex>();
var rxconf = data["regex"];
if (rxconf == null) throw new ModuleLoadException("No regular expression patterns defined" + errorpfx);
// Accepting either array or single string
// TODO detection of regex values that go around these options or attempt advanced functionality
// TODO repeated code! simplify this a little.
if (rxconf.Type == JTokenType.Array)
{
foreach (var input in rxconf.Values<string>())
{
try
{
var r = new Regex(input, rxopts);
regexes.Add(r);
}
catch (ArgumentException)
{
throw new ModuleLoadException($"Failed to parse regular expression pattern '{input}'{errorpfx}");
}
}
}
else if (rxconf.Type == JTokenType.String)
{
var rxstr = rxconf.Value<string>();
try
{
var r = new Regex(rxstr, rxopts);
regexes.Add(r);
}
catch (ArgumentException)
{
throw new ModuleLoadException($"Failed to parse regular expression pattern '{rxstr}'{errorpfx}");
}
}
else
{
// TODO fix up wording of error message here
throw new ModuleLoadException("Unacceptable value given as a regular expession" + errorpfx);
}
Regex = regexes.AsReadOnly();
// Get response
// TODO repeated code here also! maybe create a helper method for reading either a single string or string array.
var replyconf = data["reply"];
if (replyconf.Type == JTokenType.String)
{
var str = replyconf.Value<string>();
Response = new List<string>() { str }.AsReadOnly();
}
else if (replyconf.Type == JTokenType.Array)
{
Response = new List<string>(replyconf.Values<string>()).AsReadOnly();
}
// Filtering
Filter = new FilterList(data);
// Rate limiting
string rlstr = data["ratelimit"]?.Value<string>();
if (string.IsNullOrWhiteSpace(rlstr))
{
RateLimit = new RateLimit<ulong>();
}
else
{
if (ushort.TryParse(rlstr, out var rlval))
{
RateLimit = new RateLimit<ulong>(rlval);
}
else
{
throw new ModuleLoadException("Invalid rate limit value" + errorpfx);
}
}
// Random chance parameter
string randstr = data["RandomChance"]?.Value<string>();
double randval;
if (string.IsNullOrWhiteSpace(randstr))
{
randval = double.NaN;
}
else
{
if (!double.TryParse(randstr, out randval))
{
throw new ModuleLoadException("Random value is invalid (unable to parse)" + errorpfx);
}
if (randval > 1 || randval < 0)
{
throw new ModuleLoadException("Random value is invalid (not between 0 and 1)" + errorpfx);
}
}
RandomChance = randval;
}
/// <summary>
/// Checks the given message to determine if it matches this rule's constraints.
/// </summary>
/// <returns>True if the rule's response(s) should be executed.</returns>
public bool Match(SocketMessage m)
{
// Filter check
if (Filter.IsFiltered(m, true)) return false;
// Match check
bool matchFound = false;
foreach (var item in Regex)
{
// TODO determine maximum execution time for a regular expression match
if (item.IsMatch(m.Content))
{
matchFound = true;
break;
}
}
if (!matchFound) return false;
// Rate limit check - currently per channel
if (!RateLimit.Permit(m.Channel.Id)) return false;
// Random chance check
if (!double.IsNaN(RandomChance))
{
// Fail if randomly generated value is higher than the parameter
// Example: To fail a 75% chance, the check value must be between 0.75000...001 and 1.0.
var chk = Chance.NextDouble();
if (chk > RandomChance) return false;
}
return true;
}
/// <summary>
/// Gets a response string to display in the channel.
/// </summary>
public string GetResponse()
{
// TODO feature request: option to show responses in order instead of random
if (Response.Count == 1) return Response[0];
return Response[Chance.Next(0, Response.Count - 1)];
}
}
}

View file

@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace Kerobot.Modules.AutoResponder
{
/// <summary>
/// Helper class for managing rate limit data.
/// </summary>
class RateLimit<T>
{
public const ushort DefaultTimeout = 20; // Skeeter's a cool guy and you can't convince me otherwise.
public ushort Timeout { get; }
private readonly object _lock = new object();
private Dictionary<T, DateTime> _table = new Dictionary<T, DateTime>();
public RateLimit() : this(DefaultTimeout) { }
public RateLimit(ushort timeout) => Timeout = timeout;
/// <summary>
/// 'Permit?' - Checks if the given value is permitted through the rate limit.
/// Executing this method may create a rate limit entry for the given value.
/// </summary>
/// <param name="id"></param>
/// <returns>True if the given value is permitted by the rate limiter.</returns>
public bool Permit(T value)
{
if (Timeout == 0) return true;
lock (_lock)
{
Clean();
if (_table.ContainsKey(value)) return false;
_table.Add(value, DateTime.Now);
}
return true;
}
/// <summary>
/// Clean up expired entries.
/// </summary>
private void Clean()
{
var now = DateTime.Now;
var newTable = new Dictionary<T, DateTime>();
foreach (var item in _table)
{
// Copy items that have not yet timed out to the new dictionary. Discard the rest.
if (item.Value.AddSeconds(Timeout) > now) newTable.Add(item.Key, item.Value);
}
lock (_lock) _table = newTable;
}
}
}

View file

@ -1,27 +0,0 @@
using System.Collections.Generic;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System.Threading.Tasks;
namespace Kerobot.Modules
{
[KerobotModule]
class TestMod : ModuleBase
{
public TestMod(Kerobot kb) : base(kb)
{
kb.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
}
private async Task DiscordClient_MessageReceived(SocketMessage arg)
{
if (arg.Content.ToLower() == ".test")
{
await arg.Channel.SendMessageAsync("I respond to your test.");
}
}
public override Task<object> CreateGuildStateAsync(JToken config)
=> Task.FromResult<object>(new Dictionary<ulong, string>());
}
}