Add AutoScriptResponder
I'm not particularly proud of this.
This commit is contained in:
parent
81e9e6f959
commit
530a4ae1b6
4 changed files with 319 additions and 28 deletions
|
@ -0,0 +1,96 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Kerobot.Modules.AutoScriptResponder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Meant to be highly identical to AutoResponder, save for its differentiating feature.
|
||||||
|
/// This may not be the best approach to it, but do try and copy any relevant changes from one into
|
||||||
|
/// the other whenever they occur.
|
||||||
|
/// The feature in question: It executes external scripts and replies with their output.
|
||||||
|
/// </summary>
|
||||||
|
[KerobotModule]
|
||||||
|
class AutoScriptResponder : ModuleBase
|
||||||
|
{
|
||||||
|
public AutoScriptResponder(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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASR edit: this whole thing.
|
||||||
|
private async Task ProcessMessageAsync(SocketMessage msg, Definition def)
|
||||||
|
{
|
||||||
|
if (!def.Match(msg)) return;
|
||||||
|
|
||||||
|
var ch = (SocketGuildChannel)msg.Channel;
|
||||||
|
string[] cmdline = def.Command.Split(new char[] { ' ' }, 2);
|
||||||
|
|
||||||
|
var 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 5 seconds at most
|
||||||
|
if (p.HasExited)
|
||||||
|
{
|
||||||
|
if (p.ExitCode != 0)
|
||||||
|
{
|
||||||
|
await LogAsync(ch.Guild.Id, $"'{def.Label}': Process exited abnormally (with code {p.ExitCode}).");
|
||||||
|
}
|
||||||
|
using (var stdout = p.StandardOutput)
|
||||||
|
{
|
||||||
|
var result = await stdout.ReadToEndAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(result)) await msg.Channel.SendMessageAsync(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LogAsync(ch.Guild.Id, $"'{def.Label}': Process has not exited in 5 seconds. Killing process.");
|
||||||
|
p.Kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
169
Modules-SelfHosted/AutoScriptResponder/Definition.cs
Normal file
169
Modules-SelfHosted/AutoScriptResponder/Definition.cs
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Kerobot.Common;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Kerobot.Modules.AutoScriptResponder
|
||||||
|
{
|
||||||
|
/// <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; }
|
||||||
|
// ASR edit:
|
||||||
|
public string Command { 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(AutoScriptResponder)} 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();
|
||||||
|
|
||||||
|
// ASR edit:
|
||||||
|
// Get command to be executed
|
||||||
|
Command = data["command"]?.Value<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(Command))
|
||||||
|
throw new ModuleLoadException("Value for 'command' is missing.");
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASR edit: GetResponse() removed.
|
||||||
|
}
|
||||||
|
}
|
54
Modules-SelfHosted/AutoScriptResponder/RateLimit.cs
Normal file
54
Modules-SelfHosted/AutoScriptResponder/RateLimit.cs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Kerobot.Modules.AutoScriptResponder
|
||||||
|
{
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,28 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using Discord.WebSocket;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Kerobot.Modules
|
|
||||||
{
|
|
||||||
[KerobotModule]
|
|
||||||
class TestMod2 : ModuleBase
|
|
||||||
{
|
|
||||||
public TestMod2(Kerobot kb) : base(kb)
|
|
||||||
{
|
|
||||||
kb.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DiscordClient_MessageReceived(SocketMessage arg)
|
|
||||||
{
|
|
||||||
if (arg.Content.ToLower() == ".test2")
|
|
||||||
{
|
|
||||||
await arg.Channel.SendMessageAsync("I respond to your test too.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<object> CreateGuildStateAsync(JToken config)
|
|
||||||
=> Task.FromResult<object>(new Dictionary<ulong, string>());
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue