Begin module rewrites and ports
This commit is contained in:
parent
02f91947f3
commit
4f773e2573
14 changed files with 514 additions and 812 deletions
|
@ -1,61 +1,77 @@
|
||||||
using Discord.WebSocket;
|
using System.Diagnostics;
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace RegexBot.Modules.AutoResponder
|
namespace RegexBot.Modules.AutoResponder;
|
||||||
{
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides the capability to define text responses to pattern-based triggers for fun or informational
|
/// 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
|
/// 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.
|
/// fit for non-moderation use cases and has specific features suitable to that end.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RegexbotModule]
|
[RegexbotModule]
|
||||||
class AutoResponder : ModuleBase
|
public class AutoResponder : RegexbotModule {
|
||||||
{
|
public AutoResponder(RegexbotClient bot) : base(bot) {
|
||||||
public AutoResponder(RegexbotClient bot) : base(bot)
|
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
||||||
{
|
}
|
||||||
DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
|
||||||
|
private async Task DiscordClient_MessageReceived(SocketMessage arg) {
|
||||||
|
if (arg.Channel is not 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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DiscordClient_MessageReceived(SocketMessage arg)
|
await Task.WhenAll(tasks);
|
||||||
{
|
}
|
||||||
if (!(arg.Channel is SocketGuildChannel ch)) return;
|
|
||||||
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
|
|
||||||
|
|
||||||
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
|
public override Task<object?> CreateGuildStateAsync(ulong guild, JToken config) {
|
||||||
if (definitions == null) return; // No configuration in this guild; do no further processing
|
// Guild state is a read-only IEnumerable<Definition>
|
||||||
|
if (config == null) return Task.FromResult<object?>(null);
|
||||||
var tasks = new List<Task>();
|
var guildDefs = new List<Definition>();
|
||||||
foreach (var def in definitions)
|
foreach (var defconf in config.Children<JProperty>()) {
|
||||||
{
|
// Validation of data is left to the Definition constructor
|
||||||
tasks.Add(Task.Run(async () => await ProcessMessageAsync(arg, def)));
|
var def = new Definition(defconf); // ModuleLoadException may be thrown here
|
||||||
}
|
guildDefs.Add(def);
|
||||||
|
// TODO global options
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<object> CreateGuildStateAsync(ulong guild, JToken config)
|
return Task.FromResult<object?>(guildDefs.AsReadOnly());
|
||||||
{
|
}
|
||||||
// Guild state is a read-only IEnumerable<Definition>
|
|
||||||
if (config == null) return Task.FromResult<object>(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;
|
||||||
|
|
||||||
private async Task ProcessMessageAsync(SocketMessage msg, Definition def)
|
if (def.Command == null) {
|
||||||
{
|
|
||||||
if (!def.Match(msg)) return;
|
|
||||||
await msg.Channel.SendMessageAsync(def.GetResponse());
|
await msg.Channel.SendMessageAsync(def.GetResponse());
|
||||||
|
} else {
|
||||||
|
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 var p = Process.Start(ps)!;
|
||||||
|
|
||||||
|
p.WaitForExit(5000); // waiting 5 seconds at most
|
||||||
|
if (p.HasExited) {
|
||||||
|
if (p.ExitCode != 0) {
|
||||||
|
PLog($"Command execution in {ch.Guild.Id}: 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 {
|
||||||
|
PLog($"Command execution in {ch.Guild.Id}: Process has not exited in 5 seconds. Killing process.");
|
||||||
|
p.Kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,183 +1,165 @@
|
||||||
using Discord.WebSocket;
|
using RegexBot.Common;
|
||||||
using RegexBot.Common;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace RegexBot.Modules.AutoResponder
|
namespace RegexBot.Modules.AutoResponder;
|
||||||
{
|
|
||||||
|
/// <summary>
|
||||||
|
/// Representation of a single <see cref="AutoResponder"/> configuration definition.
|
||||||
|
/// </summary>
|
||||||
|
class Definition {
|
||||||
|
private static readonly Random Chance = new();
|
||||||
|
|
||||||
|
public string Label { get; }
|
||||||
|
public IEnumerable<Regex> Regex { get; }
|
||||||
|
public IReadOnlyList<string> Response { get; }
|
||||||
|
public string? Command { get; }
|
||||||
|
public FilterList Filter { get; }
|
||||||
|
public RateLimit<ulong> RateLimit { get; }
|
||||||
|
public double RandomChance { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Representation of a single <see cref="AutoResponder"/> configuration definition.
|
/// Creates an instance based on JSON configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class Definition
|
public Definition(JProperty incoming) {
|
||||||
{
|
Label = incoming.Name;
|
||||||
private static readonly Random Chance = new Random();
|
if (incoming.Value.Type != JTokenType.Object)
|
||||||
|
throw new ModuleLoadException($"Value of {nameof(AutoResponder)} definition must be a JSON object.");
|
||||||
|
var data = (JObject)incoming.Value;
|
||||||
|
|
||||||
public string Label { get; }
|
// error message postfix
|
||||||
public IEnumerable<Regex> Regex { get; }
|
var errpofx = $" in AutoRespond definition '{Label}'.";
|
||||||
public IReadOnlyList<string> Response { get; }
|
|
||||||
public FilterList Filter { get; }
|
|
||||||
public RateLimit<ulong> RateLimit { get; }
|
|
||||||
public double RandomChance { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
// Parse regex
|
||||||
/// Creates an instance based on JSON configuration.
|
const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline;
|
||||||
/// </summary>
|
var regexes = new List<Regex>();
|
||||||
/// <param name="config"></param>
|
var rxconf = data[nameof(Regex)];
|
||||||
public Definition(JProperty incoming)
|
// Accepting either array or single string
|
||||||
{
|
// TODO this code could be moved into a helper method somehow
|
||||||
Label = incoming.Name;
|
if (rxconf?.Type == JTokenType.Array) {
|
||||||
if (incoming.Value.Type != JTokenType.Object)
|
foreach (var input in rxconf.Values<string>()) {
|
||||||
throw new ModuleLoadException($"Value of {nameof(AutoResponder)} definition must be a JSON object.");
|
try {
|
||||||
var data = (JObject)incoming.Value;
|
var r = new Regex(input!, rxopts);
|
||||||
|
|
||||||
// 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);
|
regexes.Add(r);
|
||||||
}
|
} catch (ArgumentException) {
|
||||||
catch (ArgumentException)
|
throw new ModuleLoadException($"Failed to parse regular expression pattern '{input}'{errpofx}");
|
||||||
{
|
|
||||||
throw new ModuleLoadException($"Failed to parse regular expression pattern '{rxstr}'{errorpfx}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
} else if (rxconf?.Type == JTokenType.String) {
|
||||||
{
|
var rxstr = rxconf.Value<string>()!;
|
||||||
// TODO fix up wording of error message here
|
try {
|
||||||
throw new ModuleLoadException("Unacceptable value given as a regular expession" + errorpfx);
|
var r = new Regex(rxstr, rxopts);
|
||||||
|
regexes.Add(r);
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
throw new ModuleLoadException($"Failed to parse regular expression pattern '{rxstr}'{errpofx}");
|
||||||
}
|
}
|
||||||
Regex = regexes.AsReadOnly();
|
} else {
|
||||||
|
throw new ModuleLoadException("'Regex' not defined" + errpofx);
|
||||||
|
}
|
||||||
|
Regex = regexes.AsReadOnly();
|
||||||
|
|
||||||
// Get response
|
bool responseDefined;
|
||||||
// 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
|
// Get response
|
||||||
Filter = new FilterList(data);
|
// TODO this bit could also go into the same aforementioned helper method
|
||||||
|
var replyconf = data["reply"];
|
||||||
|
if (replyconf?.Type == JTokenType.String) {
|
||||||
|
var str = replyconf.Value<string>()!;
|
||||||
|
Response = new List<string>() { str }.AsReadOnly();
|
||||||
|
responseDefined = true;
|
||||||
|
} else if (replyconf?.Type == JTokenType.Array) {
|
||||||
|
Response = new List<string>(replyconf.Values<string>()!).AsReadOnly();
|
||||||
|
responseDefined = true;
|
||||||
|
} else {
|
||||||
|
Response = Array.Empty<string>();
|
||||||
|
responseDefined = false;
|
||||||
|
}
|
||||||
|
// Get command
|
||||||
|
var commconf = data[nameof(Command)];
|
||||||
|
if (commconf != null && responseDefined) {
|
||||||
|
throw new ModuleLoadException("Cannot have 'Response' and 'Command' defined at the same time" + errpofx);
|
||||||
|
}
|
||||||
|
if (!responseDefined) {
|
||||||
|
if (commconf != null) {
|
||||||
|
var commstr = commconf.Value<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(commstr))
|
||||||
|
throw new ModuleLoadException("'Command' is defined, but value is blank" + errpofx);
|
||||||
|
Command = commstr;
|
||||||
|
responseDefined = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Got neither...
|
||||||
|
if (!responseDefined) throw new ModuleLoadException("Neither 'Response' nor 'Command' were defined" + errpofx);
|
||||||
|
|
||||||
// Rate limiting
|
// Filtering
|
||||||
string rlstr = data["ratelimit"]?.Value<string>();
|
Filter = new FilterList(data);
|
||||||
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
|
// Rate limiting
|
||||||
string randstr = data["RandomChance"]?.Value<string>();
|
var rlconf = data[nameof(RateLimit)];
|
||||||
double randval;
|
if (rlconf?.Type == JTokenType.Integer) {
|
||||||
if (string.IsNullOrWhiteSpace(randstr))
|
var rlval = rlconf.Value<uint>();
|
||||||
{
|
RateLimit = new RateLimit<ulong>(rlval);
|
||||||
randval = double.NaN;
|
} else if (rlconf != null) {
|
||||||
|
throw new ModuleLoadException("'RateLimit' must be a non-negative integer" + errpofx);
|
||||||
|
} else {
|
||||||
|
RateLimit = new(0);
|
||||||
|
}
|
||||||
|
var rlstr = data[nameof(RateLimit)]?.Value<ushort>();
|
||||||
|
|
||||||
|
// Random chance parameter
|
||||||
|
var randstr = data[nameof(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)" + errpofx);
|
||||||
}
|
}
|
||||||
else
|
if (randval is > 1 or < 0) {
|
||||||
{
|
throw new ModuleLoadException("Random value is invalid (not between 0 and 1)" + errpofx);
|
||||||
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;
|
}
|
||||||
|
RandomChance = randval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the given message to determine if it matches this rule's constraints.
|
||||||
|
/// This method also maintains rate limiting and performs random number generation.
|
||||||
|
/// </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) {
|
||||||
|
if (item.IsMatch(m.Content)) {
|
||||||
|
matchFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matchFound) return false;
|
||||||
|
|
||||||
|
// Rate limit check - currently per channel
|
||||||
|
if (!RateLimit.IsPermitted(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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
return true;
|
||||||
/// 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
|
/// <summary>
|
||||||
bool matchFound = false;
|
/// Gets a response string to display in the channel.
|
||||||
foreach (var item in Regex)
|
/// </summary>
|
||||||
{
|
public string GetResponse() {
|
||||||
// TODO determine maximum execution time for a regular expression match
|
// TODO feature request: option to show responses in order instead of random
|
||||||
if (item.IsMatch(m.Content))
|
if (Response.Count == 1) return Response[0];
|
||||||
{
|
return Response[Chance.Next(0, Response.Count - 1)];
|
||||||
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)];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace RegexBot.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,96 +0,0 @@
|
||||||
using Discord.WebSocket;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace RegexBot.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>
|
|
||||||
[RegexbotModule]
|
|
||||||
class AutoScriptResponder : ModuleBase
|
|
||||||
{
|
|
||||||
public AutoScriptResponder(RegexbotClient bot) : base(bot)
|
|
||||||
{
|
|
||||||
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(ulong guild, JToken config)
|
|
||||||
{
|
|
||||||
// Guild state is a read-only IEnumerable<Definition>
|
|
||||||
if (config == null) return Task.FromResult<object>(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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,169 +0,0 @@
|
||||||
using Discord.WebSocket;
|
|
||||||
using RegexBot.Common;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace RegexBot.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.
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,54 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace RegexBot.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,147 +0,0 @@
|
||||||
using Discord.WebSocket;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace RegexBot.Modules.EntryRole
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Automatically sets a role onto users entering the guild.
|
|
||||||
/// </summary>
|
|
||||||
// TODO add persistent role support, make it an option
|
|
||||||
[RegexbotModule]
|
|
||||||
public class EntryRole : ModuleBase
|
|
||||||
{
|
|
||||||
readonly Task _workerTask;
|
|
||||||
readonly CancellationTokenSource _workerTaskToken; // TODO make use of this when possible
|
|
||||||
|
|
||||||
public EntryRole(RegexbotClient bot) : base(bot)
|
|
||||||
{
|
|
||||||
DiscordClient.UserJoined += DiscordClient_UserJoined;
|
|
||||||
DiscordClient.UserLeft += DiscordClient_UserLeft;
|
|
||||||
|
|
||||||
_workerTaskToken = new CancellationTokenSource();
|
|
||||||
_workerTask = Task.Factory.StartNew(RoleApplyWorker, _workerTaskToken.Token,
|
|
||||||
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task DiscordClient_UserJoined(SocketGuildUser arg)
|
|
||||||
{
|
|
||||||
GetGuildState<GuildData>(arg.Guild.Id)?.WaitlistAdd(arg.Id);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task DiscordClient_UserLeft(SocketGuildUser arg)
|
|
||||||
{
|
|
||||||
GetGuildState<GuildData>(arg.Guild.Id)?.WaitlistRemove(arg.Id);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<object> CreateGuildStateAsync(ulong guildID, JToken config)
|
|
||||||
{
|
|
||||||
if (config == null) return Task.FromResult<object>(null);
|
|
||||||
|
|
||||||
if (config.Type != JTokenType.Object)
|
|
||||||
throw new ModuleLoadException("Configuration is not properly defined.");
|
|
||||||
|
|
||||||
// Attempt to preserve existing timer list on reload
|
|
||||||
var oldconf = GetGuildState<GuildData>(guildID);
|
|
||||||
if (oldconf == null) return Task.FromResult<object>(new GuildData((JObject)config));
|
|
||||||
else return Task.FromResult<object>(new GuildData((JObject)config, oldconf.WaitingList));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Main worker task.
|
|
||||||
/// </summary>
|
|
||||||
private async Task RoleApplyWorker()
|
|
||||||
{
|
|
||||||
while (!_workerTaskToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
await Task.Delay(5000);
|
|
||||||
|
|
||||||
var subworkers = new List<Task>();
|
|
||||||
foreach (var g in DiscordClient.Guilds)
|
|
||||||
{
|
|
||||||
subworkers.Add(RoleApplyGuildSubWorker(g));
|
|
||||||
}
|
|
||||||
Task.WaitAll(subworkers.ToArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Guild-specific processing by worker task.
|
|
||||||
/// </summary>
|
|
||||||
internal async Task RoleApplyGuildSubWorker(SocketGuild g)
|
|
||||||
{
|
|
||||||
var gconf = GetGuildState<GuildData>(g.Id);
|
|
||||||
if (gconf == null) return;
|
|
||||||
|
|
||||||
// Get list of users to be affected
|
|
||||||
ulong[] userIds;
|
|
||||||
lock (gconf.WaitingList)
|
|
||||||
{
|
|
||||||
if (gconf.WaitingList.Count == 0) return;
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var queryIds = from item in gconf.WaitingList
|
|
||||||
where item.Value > now
|
|
||||||
select item.Key;
|
|
||||||
userIds = queryIds.ToArray();
|
|
||||||
|
|
||||||
foreach (var item in userIds) gconf.WaitingList.Remove(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
var gusers = new List<SocketGuildUser>();
|
|
||||||
foreach (var item in userIds)
|
|
||||||
{
|
|
||||||
var gu = g.GetUser(item);
|
|
||||||
if (gu == null) continue; // silently drop unknown users (is this fine?)
|
|
||||||
gusers.Add(gu);
|
|
||||||
}
|
|
||||||
if (gusers.Count == 0) return;
|
|
||||||
|
|
||||||
// Attempt to get role.
|
|
||||||
var targetRole = gconf.TargetRole.FindRoleIn(g, true);
|
|
||||||
if (targetRole == null)
|
|
||||||
{
|
|
||||||
await ReportFailure(g.Id, "Unable to determine role to be applied. Does it still exist?", gusers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply roles
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var item in gusers)
|
|
||||||
{
|
|
||||||
if (item.Roles.Contains(targetRole)) continue;
|
|
||||||
await item.AddRoleAsync(targetRole);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden)
|
|
||||||
{
|
|
||||||
await ReportFailure(g.Id, "Unable to set role due to a permissions issue.", gusers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReportFailure(ulong gid, string message, IEnumerable<SocketGuildUser> failedUserList)
|
|
||||||
{
|
|
||||||
var failList = new StringBuilder();
|
|
||||||
var count = 0;
|
|
||||||
foreach (var item in failedUserList) {
|
|
||||||
failList.Append($", {item.Username}#{item.Discriminator}");
|
|
||||||
count++;
|
|
||||||
if (count > 5)
|
|
||||||
{
|
|
||||||
failList.Append($"and {count} other(s).");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failList.Remove(0, 2);
|
|
||||||
await LogAsync(gid, message + " Failed while attempting to set role on the following users: " + failList.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,81 +0,0 @@
|
||||||
using RegexBot.Common;
|
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace RegexBot.Modules.EntryRole
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
|
|
||||||
/// </summary>
|
|
||||||
class GuildData
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Lock on self.
|
|
||||||
/// </summary>
|
|
||||||
public Dictionary<ulong, DateTimeOffset> WaitingList { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Role to apply.
|
|
||||||
/// </summary>
|
|
||||||
public EntityName TargetRole { get; }
|
|
||||||
/// <summary>
|
|
||||||
/// Time to wait until applying the role, in seconds.
|
|
||||||
/// </summary>
|
|
||||||
public int WaitTime { get; }
|
|
||||||
|
|
||||||
const int WaitTimeMax = 600; // 10 minutes
|
|
||||||
|
|
||||||
public GuildData(JObject conf) : this(conf, new Dictionary<ulong, DateTimeOffset>()) { }
|
|
||||||
|
|
||||||
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList)
|
|
||||||
{
|
|
||||||
WaitingList = _waitingList;
|
|
||||||
|
|
||||||
var cfgRole = conf["Role"]?.Value<string>();
|
|
||||||
if (string.IsNullOrWhiteSpace(cfgRole))
|
|
||||||
throw new ModuleLoadException("Role value not specified.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
TargetRole = new EntityName(cfgRole);
|
|
||||||
}
|
|
||||||
catch (ArgumentException)
|
|
||||||
{
|
|
||||||
throw new ModuleLoadException("Role config value was not properly specified to be a role.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
WaitTime = conf["WaitTime"].Value<int>();
|
|
||||||
}
|
|
||||||
catch (NullReferenceException)
|
|
||||||
{
|
|
||||||
throw new ModuleLoadException("WaitTime value not specified.");
|
|
||||||
}
|
|
||||||
catch (InvalidCastException)
|
|
||||||
{
|
|
||||||
throw new ModuleLoadException("WaitTime value must be a number.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (WaitTime > WaitTimeMax)
|
|
||||||
{
|
|
||||||
// don't silently correct it
|
|
||||||
throw new ModuleLoadException($"WaitTime value may not exceed {WaitTimeMax} seconds.");
|
|
||||||
}
|
|
||||||
if (WaitTime < 0)
|
|
||||||
{
|
|
||||||
throw new ModuleLoadException("WaitTime value may not be negative.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WaitlistAdd(ulong userId)
|
|
||||||
{
|
|
||||||
lock (WaitingList) WaitingList.Add(userId, DateTimeOffset.UtcNow.AddSeconds(WaitTime));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void WaitlistRemove(ulong userId)
|
|
||||||
{
|
|
||||||
lock (WaitingList) WaitingList.Remove(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
120
RegexBot-Modules/EntryTimeRole/EntryTimeRole.cs
Normal file
120
RegexBot-Modules/EntryTimeRole/EntryTimeRole.cs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.EntryTimeRole;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automatically sets a role onto users entering the guild after a predefined amount of time.
|
||||||
|
/// </summary>
|
||||||
|
[RegexbotModule]
|
||||||
|
public class EntryTimeRole : RegexbotModule {
|
||||||
|
readonly Task _workerTask;
|
||||||
|
readonly CancellationTokenSource _workerTaskToken; // TODO make use of this when possible
|
||||||
|
|
||||||
|
public EntryTimeRole(RegexbotClient bot) : base(bot) {
|
||||||
|
DiscordClient.UserJoined += DiscordClient_UserJoined;
|
||||||
|
DiscordClient.UserLeft += DiscordClient_UserLeft;
|
||||||
|
|
||||||
|
_workerTaskToken = new CancellationTokenSource();
|
||||||
|
_workerTask = Task.Factory.StartNew(RoleApplyWorker, _workerTaskToken.Token,
|
||||||
|
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task DiscordClient_UserJoined(SocketGuildUser arg) {
|
||||||
|
GetGuildState<GuildData>(arg.Guild.Id)?.WaitlistAdd(arg.Id);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task DiscordClient_UserLeft(SocketGuild guild, SocketUser user) {
|
||||||
|
GetGuildState<GuildData>(guild.Id)?.WaitlistRemove(user.Id);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
|
||||||
|
if (config == null) return Task.FromResult<object?>(null);
|
||||||
|
|
||||||
|
if (config.Type != JTokenType.Object)
|
||||||
|
throw new ModuleLoadException("Configuration is not properly defined.");
|
||||||
|
|
||||||
|
// Attempt to preserve existing timer list on reload
|
||||||
|
var oldconf = GetGuildState<GuildData>(guildID);
|
||||||
|
if (oldconf == null) return Task.FromResult<object?>(new GuildData((JObject)config));
|
||||||
|
else return Task.FromResult<object?>(new GuildData((JObject)config, oldconf.WaitingList));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Main worker task.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RoleApplyWorker() {
|
||||||
|
while (!_workerTaskToken.IsCancellationRequested) {
|
||||||
|
await Task.Delay(5000);
|
||||||
|
|
||||||
|
var subworkers = new List<Task>();
|
||||||
|
foreach (var g in DiscordClient.Guilds) {
|
||||||
|
subworkers.Add(RoleApplyGuildSubWorker(g));
|
||||||
|
}
|
||||||
|
Task.WaitAll(subworkers.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guild-specific processing by worker task.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task RoleApplyGuildSubWorker(SocketGuild g) {
|
||||||
|
var gconf = GetGuildState<GuildData>(g.Id);
|
||||||
|
if (gconf == null) return;
|
||||||
|
|
||||||
|
// Get list of users to be affected
|
||||||
|
ulong[] userIds;
|
||||||
|
lock (gconf.WaitingList) {
|
||||||
|
if (gconf.WaitingList.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var queryIds = from item in gconf.WaitingList
|
||||||
|
where item.Value > now
|
||||||
|
select item.Key;
|
||||||
|
userIds = queryIds.ToArray();
|
||||||
|
|
||||||
|
foreach (var item in userIds) gconf.WaitingList.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
var gusers = new List<SocketGuildUser>();
|
||||||
|
foreach (var item in userIds) {
|
||||||
|
var gu = g.GetUser(item);
|
||||||
|
if (gu == null) continue; // silently drop unknown users (is this fine?)
|
||||||
|
gusers.Add(gu);
|
||||||
|
}
|
||||||
|
if (gusers.Count == 0) return;
|
||||||
|
|
||||||
|
// Attempt to get role.
|
||||||
|
var targetRole = gconf.TargetRole.FindRoleIn(g, true);
|
||||||
|
if (targetRole == null) {
|
||||||
|
ReportFailure(g.Id, "Unable to determine role to be applied. Does it still exist?", gusers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply roles
|
||||||
|
try {
|
||||||
|
foreach (var item in gusers) {
|
||||||
|
if (item.Roles.Contains(targetRole)) continue;
|
||||||
|
await item.AddRoleAsync(targetRole);
|
||||||
|
}
|
||||||
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
|
||||||
|
ReportFailure(g.Id, "Unable to set role due to a permissions issue.", gusers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportFailure(ulong gid, string message, IEnumerable<SocketGuildUser> failedUserList) {
|
||||||
|
var failList = new StringBuilder();
|
||||||
|
var count = 0;
|
||||||
|
foreach (var item in failedUserList) {
|
||||||
|
failList.Append($", {item.Username}#{item.Discriminator}");
|
||||||
|
count++;
|
||||||
|
if (count > 5) {
|
||||||
|
failList.Append($"and {count} other(s).");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failList.Remove(0, 2);
|
||||||
|
Log(gid, message + " Failed while attempting to set role on the following users: " + failList.ToString());
|
||||||
|
}
|
||||||
|
}
|
63
RegexBot-Modules/EntryTimeRole/GuildData.cs
Normal file
63
RegexBot-Modules/EntryTimeRole/GuildData.cs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.EntryTimeRole;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
|
||||||
|
/// </summary>
|
||||||
|
class GuildData {
|
||||||
|
/// <summary>
|
||||||
|
/// Lock on self.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<ulong, DateTimeOffset> WaitingList { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Role to apply.
|
||||||
|
/// </summary>
|
||||||
|
public EntityName TargetRole { get; }
|
||||||
|
/// <summary>
|
||||||
|
/// Time to wait until applying the role, in seconds.
|
||||||
|
/// </summary>
|
||||||
|
public int WaitTime { get; }
|
||||||
|
|
||||||
|
const int WaitTimeMax = 600; // 10 minutes
|
||||||
|
|
||||||
|
public GuildData(JObject conf) : this(conf, new Dictionary<ulong, DateTimeOffset>()) { }
|
||||||
|
|
||||||
|
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList) {
|
||||||
|
WaitingList = _waitingList;
|
||||||
|
|
||||||
|
var cfgRole = conf["Role"]?.Value<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfgRole))
|
||||||
|
throw new ModuleLoadException("Role value not specified.");
|
||||||
|
try {
|
||||||
|
TargetRole = new EntityName(cfgRole);
|
||||||
|
} catch (ArgumentException) {
|
||||||
|
throw new ModuleLoadException("Role config value was not properly specified to be a role.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
WaitTime = conf["WaitTime"].Value<int>();
|
||||||
|
} catch (NullReferenceException) {
|
||||||
|
throw new ModuleLoadException("WaitTime value not specified.");
|
||||||
|
} catch (InvalidCastException) {
|
||||||
|
throw new ModuleLoadException("WaitTime value must be a number.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WaitTime > WaitTimeMax) {
|
||||||
|
// don't silently correct it
|
||||||
|
throw new ModuleLoadException($"WaitTime value may not exceed {WaitTimeMax} seconds.");
|
||||||
|
}
|
||||||
|
if (WaitTime < 0) {
|
||||||
|
throw new ModuleLoadException("WaitTime value may not be negative.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WaitlistAdd(ulong userId) {
|
||||||
|
lock (WaitingList) WaitingList.Add(userId, DateTimeOffset.UtcNow.AddSeconds(WaitTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void WaitlistRemove(ulong userId) {
|
||||||
|
lock (WaitingList) WaitingList.Remove(userId);
|
||||||
|
}
|
||||||
|
}
|
2
RegexBot-Modules/Include.cs
Normal file
2
RegexBot-Modules/Include.cs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
global using Discord.WebSocket;
|
||||||
|
global using Newtonsoft.Json.Linq;
|
15
RegexBot-Modules/PendingOutRole/ModuleConfig.cs
Normal file
15
RegexBot-Modules/PendingOutRole/ModuleConfig.cs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.PendingOutRole;
|
||||||
|
class ModuleConfig {
|
||||||
|
public EntityName Role { get; }
|
||||||
|
|
||||||
|
public ModuleConfig(JObject conf) {
|
||||||
|
var cfgRole = conf["Role"]?.Value<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfgRole))
|
||||||
|
throw new ModuleLoadException("Role was not specified.");
|
||||||
|
Role = new EntityName(cfgRole);
|
||||||
|
if (Role.Type != EntityType.Role)
|
||||||
|
throw new ModuleLoadException("Name specified in configuration is not a role.");
|
||||||
|
}
|
||||||
|
}
|
66
RegexBot-Modules/PendingOutRole/PendingOutRole.cs
Normal file
66
RegexBot-Modules/PendingOutRole/PendingOutRole.cs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
namespace RegexBot.Modules.PendingOutRole;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Automatically sets a specified role when a user is no longer in (gets out of) pending status -
|
||||||
|
/// that is, the user has passed the requirements needed to fully access the guild such as welcome messages, etc.
|
||||||
|
/// </summary>
|
||||||
|
[RegexbotModule]
|
||||||
|
public class PendingOutRole : RegexbotModule {
|
||||||
|
public PendingOutRole(RegexbotClient bot) : base(bot) {
|
||||||
|
DiscordClient.GuildAvailable += DiscordClient_GuildAvailable;
|
||||||
|
DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscordClient_GuildAvailable(SocketGuild arg) {
|
||||||
|
var conf = GetGuildState<ModuleConfig>(arg.Id);
|
||||||
|
if (conf == null) return;
|
||||||
|
var trole = GetRole(arg);
|
||||||
|
if (trole == null) {
|
||||||
|
Log(arg.Id, "Unable to find target role to be applied. Was it renamed or deleted?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var user in arg.Users.Where(u => u.IsPending.HasValue && u.IsPending.Value == false)) {
|
||||||
|
if (user.Roles.Contains(trole)) continue;
|
||||||
|
await user.AddRoleAsync(trole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DiscordClient_GuildMemberUpdated(Discord.Cacheable<SocketGuildUser, ulong> previous, SocketGuildUser current) {
|
||||||
|
var conf = GetGuildState<ModuleConfig>(current.Guild.Id);
|
||||||
|
if (conf == null) return;
|
||||||
|
|
||||||
|
if (!(previous.Value.IsPending.HasValue && current.IsPending.HasValue)) return;
|
||||||
|
if (previous.Value.IsPending == true && current.IsPending == false) {
|
||||||
|
var r = GetRole(current.Guild);
|
||||||
|
if (r == null) {
|
||||||
|
Log(current.Guild.Id, $"Failed to update {current} - was the role renamed or deleted?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await current.AddRoleAsync(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
|
||||||
|
if (config == null) return Task.FromResult<object?>(null);
|
||||||
|
if (config.Type != JTokenType.Object)
|
||||||
|
throw new ModuleLoadException("Configuration for this section is invalid.");
|
||||||
|
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SocketRole? GetRole(SocketGuild g) {
|
||||||
|
var conf = GetGuildState<ModuleConfig>(g.Id);
|
||||||
|
if (conf == null) return null;
|
||||||
|
|
||||||
|
if (conf.Role.Id.HasValue) {
|
||||||
|
var result = g.GetRole(conf.Role.Id.Value);
|
||||||
|
if (result != null) return result;
|
||||||
|
} else {
|
||||||
|
foreach (var role in g.Roles) {
|
||||||
|
if (string.Equals(conf.Role.Name, role.Name, StringComparison.OrdinalIgnoreCase)) return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log(g.Id, "Unable to find role in " + g.Name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
39
RegexBot-Modules/RateLimit.cs
Normal file
39
RegexBot-Modules/RateLimit.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
namespace RegexBot.Modules;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper class for managing rate limit data.
|
||||||
|
/// More accurately, this class holds entries, not allowing the same entry to be held more than once until a specified
|
||||||
|
/// amount of time has paspsed since the entry was originally tracked; useful for a rate limit system.
|
||||||
|
/// </summary>
|
||||||
|
class RateLimit<T> {
|
||||||
|
public const ushort DefaultTimeout = 20; // Skeeter's a cool guy and you can't convince me otherwise.
|
||||||
|
|
||||||
|
public uint Timeout { get; }
|
||||||
|
#pragma warning disable CS8714
|
||||||
|
private Dictionary<T, DateTime> Entries { get; } = new Dictionary<T, DateTime>();
|
||||||
|
#pragma warning restore CS8714
|
||||||
|
|
||||||
|
public RateLimit() : this(DefaultTimeout) { }
|
||||||
|
public RateLimit(uint timeout) => Timeout = timeout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
||||||
|
/// <returns>True if the given value is permitted by the rate limiter.</returns>
|
||||||
|
public bool IsPermitted(T value) {
|
||||||
|
if (Timeout == 0) return true;
|
||||||
|
|
||||||
|
// Take a moment to clean out expired entries
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var expired = Entries.Where(x => x.Value.AddSeconds(Timeout) <= now).Select(x => x.Key).ToList();
|
||||||
|
foreach (var item in expired) Entries.Remove(item);
|
||||||
|
|
||||||
|
if (Entries.ContainsKey(value)) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
Entries.Add(value, DateTime.Now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue