Merge branch 'dev'
This commit is contained in:
commit
c344db0c92
19 changed files with 1245 additions and 767 deletions
|
@ -18,6 +18,7 @@ namespace Noikoio.RegexBot
|
|||
private readonly AsyncLogger _logger;
|
||||
|
||||
public abstract string Name { get; }
|
||||
protected DiscordSocketClient Client => _client;
|
||||
|
||||
protected BotFeature(DiscordSocketClient client)
|
||||
{
|
||||
|
@ -60,6 +61,20 @@ namespace Noikoio.RegexBot
|
|||
if (sc.FeatureConfigs.TryGetValue(this, out var item)) return item;
|
||||
else return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the given message author or channel is in the server configuration's moderator list.
|
||||
/// </summary>
|
||||
protected bool IsModerator(ulong guildId, SocketMessage m)
|
||||
{
|
||||
var sc = RegexBot.Config.Servers.FirstOrDefault(g => g.Id == guildId);
|
||||
if (sc == null)
|
||||
{
|
||||
throw new ArgumentException("There is no known configuration associated with the given Guild ID.");
|
||||
}
|
||||
|
||||
return sc.Moderators.ExistsInList(m);
|
||||
}
|
||||
|
||||
protected async Task Log(string text)
|
||||
{
|
||||
|
|
|
@ -7,8 +7,6 @@ using System.Linq;
|
|||
|
||||
namespace Noikoio.RegexBot.ConfigItem
|
||||
{
|
||||
enum FilterType { None, Whitelist, Blacklist }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a structure in bot configuration that contains a list of
|
||||
/// channels, roles, and users.
|
||||
|
@ -70,7 +68,7 @@ namespace Noikoio.RegexBot.ConfigItem
|
|||
/// </summary>
|
||||
/// <param name="msg">An incoming message.</param>
|
||||
/// <returns>
|
||||
/// True if the <see cref="SocketMessage"/> occurred within a channel specified in this list,
|
||||
/// True if '<paramref name="msg"/>' occurred within a channel specified in this list,
|
||||
/// or if the message author belongs to one or more roles in this list, or if the user itself
|
||||
/// is defined within this list.
|
||||
/// </returns>
|
||||
|
@ -125,25 +123,5 @@ namespace Noikoio.RegexBot.ConfigItem
|
|||
// No match.
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for reading whitelist and blacklist filtering lists
|
||||
/// </summary>
|
||||
public static (FilterType, EntityList) GetFilterList(JObject section)
|
||||
{
|
||||
var mode = FilterType.None;
|
||||
EntityList list;
|
||||
if (section["whitelist"] != null) mode = FilterType.Whitelist;
|
||||
if (section["blacklist"] != null)
|
||||
{
|
||||
if (mode == FilterType.Whitelist)
|
||||
throw new RuleImportException("Cannot have whitelist AND blacklist defined.");
|
||||
mode = FilterType.Blacklist;
|
||||
}
|
||||
if (mode == FilterType.None) list = new EntityList(); // might even be fine to keep it null?
|
||||
else list = new EntityList(section[mode == FilterType.Whitelist ? "whitelist" : "blacklist"]);
|
||||
|
||||
return (mode, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
91
ConfigItem/FilterList.cs
Normal file
91
ConfigItem/FilterList.cs
Normal file
|
@ -0,0 +1,91 @@
|
|||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Noikoio.RegexBot.ConfigItem
|
||||
{
|
||||
enum FilterType { None, Whitelist, Blacklist }
|
||||
|
||||
/// <summary>
|
||||
/// Represents whitelist/blacklist configuration, including exemptions.
|
||||
/// </summary>
|
||||
struct FilterList
|
||||
{
|
||||
FilterType _type;
|
||||
EntityList _filterList;
|
||||
EntityList _exemptions;
|
||||
|
||||
public FilterType FilterMode => _type;
|
||||
public EntityList FilterEntities => _filterList;
|
||||
public EntityList FilterExemptions => _exemptions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the
|
||||
/// </summary>
|
||||
/// <param name="conf">
|
||||
/// A JSON object which presumably contains an array named "whitelist" or "blacklist",
|
||||
/// and optionally one named "exempt".
|
||||
/// </param>
|
||||
/// <exception cref="RuleImportException">
|
||||
/// Thrown if both "whitelist" and "blacklist" definitions were found, if
|
||||
/// "exempt" was found without a corresponding "whitelist" or "blacklist",
|
||||
/// or if there was an issue parsing an EntityList within these definitions.
|
||||
/// </exception>
|
||||
public FilterList(JObject conf)
|
||||
{
|
||||
_type = FilterType.None;
|
||||
|
||||
if (conf["whitelist"] != null) _type = FilterType.Whitelist;
|
||||
if (conf["blacklist"] != null)
|
||||
{
|
||||
if (_type != FilterType.None)
|
||||
throw new RuleImportException("Cannot have both 'whitelist' and 'blacklist' values defined.");
|
||||
_type = FilterType.Blacklist;
|
||||
}
|
||||
if (_type == FilterType.None)
|
||||
{
|
||||
_filterList = null;
|
||||
_exemptions = null;
|
||||
if (conf["exempt"] != null)
|
||||
throw new RuleImportException("Cannot have 'exempt' defined if no corresponding " +
|
||||
"'whitelist' or 'blacklist' has been defined in the same section.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_filterList = new EntityList(conf[_type == FilterType.Whitelist ? "whitelist" : "blacklist"]);
|
||||
_exemptions = new EntityList(conf["exempt"]); // EntityList constructor checks for null value
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the parameters of '<paramref name="msg"/>' are a match with filtering
|
||||
/// rules defined in this instance.
|
||||
/// </summary>
|
||||
/// <param name="msg">An incoming message.</param>
|
||||
/// <returns>
|
||||
/// True if the user or channel specified by '<paramref name="msg"/>' is filtered by
|
||||
/// the configuration defined in this instance.
|
||||
/// </returns>
|
||||
public bool IsFiltered(SocketMessage msg)
|
||||
{
|
||||
if (FilterMode == FilterType.None) return false;
|
||||
|
||||
bool inFilter = FilterEntities.ExistsInList(msg);
|
||||
|
||||
if (FilterMode == FilterType.Whitelist)
|
||||
{
|
||||
if (!inFilter) return true;
|
||||
return FilterExemptions.ExistsInList(msg);
|
||||
}
|
||||
else if (FilterMode == FilterType.Blacklist)
|
||||
{
|
||||
if (!inFilter) return false;
|
||||
return !FilterExemptions.ExistsInList(msg);
|
||||
}
|
||||
|
||||
throw new Exception("this shouldn't happen");
|
||||
}
|
||||
}
|
||||
}
|
96
Feature/AutoMod/AutoMod.cs
Normal file
96
Feature/AutoMod/AutoMod.cs
Normal file
|
@ -0,0 +1,96 @@
|
|||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements per-message regex matching and executes customizable responses.
|
||||
/// The name RegexBot comes from the existence of this feature.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Strictly for use as a moderation tool only. Triggers that simply reply to messages
|
||||
/// should be implemented using <see cref="AutoRespond"/>.
|
||||
/// </remarks>
|
||||
class AutoMod : BotFeature
|
||||
{
|
||||
public override string Name => "AutoMod";
|
||||
|
||||
public AutoMod(DiscordSocketClient client) : base(client)
|
||||
{
|
||||
client.MessageReceived += CMessageReceived;
|
||||
client.MessageUpdated += CMessageUpdated;
|
||||
}
|
||||
|
||||
[ConfigSection("automod")]
|
||||
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||
{
|
||||
List<ConfigItem> rules = new List<ConfigItem>();
|
||||
|
||||
foreach (var def in configSection.Children<JProperty>())
|
||||
{
|
||||
string label = def.Name;
|
||||
var rule = new ConfigItem(this, def);
|
||||
rules.Add(rule);
|
||||
}
|
||||
if (rules.Count > 0)
|
||||
await Log($"Loaded {rules.Count} rule(s) from configuration.");
|
||||
return rules.AsReadOnly();
|
||||
}
|
||||
|
||||
private async Task CMessageReceived(SocketMessage arg)
|
||||
=> await ReceiveMessage(arg);
|
||||
private async Task CMessageUpdated(Discord.Cacheable<Discord.IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
||||
=> await ReceiveMessage(arg2);
|
||||
|
||||
/// <summary>
|
||||
/// Does initial message checking before sending to further processing.
|
||||
/// </summary>
|
||||
private async Task ReceiveMessage(SocketMessage m)
|
||||
{
|
||||
// Determine if incoming channel is in a guild
|
||||
var ch = m.Channel as SocketGuildChannel;
|
||||
if (ch == null) return;
|
||||
|
||||
// Get rules
|
||||
var rules = GetConfig(ch.Guild.Id) as IEnumerable<ConfigItem>;
|
||||
if (rules == null) return;
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
// Checking for mod bypass here (ConfigItem.Match isn't able to access mod list)
|
||||
bool isMod = IsModerator(ch.Guild.Id, m);
|
||||
await Task.Run(async () => await ProcessMessage(m, rule, isMod));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the incoming message matches the given rule, and executes responses if necessary.
|
||||
/// </summary>
|
||||
private async Task ProcessMessage(SocketMessage m, ConfigItem r, bool isMod)
|
||||
{
|
||||
if (!r.Match(m, isMod)) return;
|
||||
|
||||
// TODO make log optional; configurable
|
||||
await Log($"{r} triggered by {m.Author} in {((SocketGuildChannel)m.Channel).Guild.Name}/#{m.Channel.Name}");
|
||||
|
||||
foreach (ResponseBase resp in r.Response)
|
||||
{
|
||||
try
|
||||
{
|
||||
await resp.Invoke(m);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Log($"Encountered an error while processing '{resp.CmdArg0}'. Details follow:");
|
||||
await Log(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public new Task Log(string text) => base.Log(text);
|
||||
public new DiscordSocketClient Client => base.Client;
|
||||
}
|
||||
}
|
212
Feature/AutoMod/ConfigItem.cs
Normal file
212
Feature/AutoMod/ConfigItem.cs
Normal file
|
@ -0,0 +1,212 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod
|
||||
{
|
||||
/// <summary>
|
||||
/// Representation of a single AutoMod rule.
|
||||
/// Data stored within cannot be edited.
|
||||
/// </summary>
|
||||
class ConfigItem
|
||||
{
|
||||
readonly AutoMod _instance;
|
||||
readonly string _label;
|
||||
readonly IEnumerable<Regex> _regex;
|
||||
readonly ICollection<ResponseBase> _responses;
|
||||
readonly FilterList _filter;
|
||||
readonly int _msgMinLength;
|
||||
readonly int _msgMaxLength;
|
||||
readonly bool _modBypass;
|
||||
readonly bool _embedMode;
|
||||
|
||||
public string Label => _label;
|
||||
public IEnumerable<Regex> Regex => _regex;
|
||||
public ICollection<ResponseBase> Response => _responses;
|
||||
public FilterList Filter => _filter;
|
||||
public (int?, int?) MatchLengthMinMaxLimit => (_msgMinLength, _msgMaxLength);
|
||||
public bool AllowsModBypass => _modBypass;
|
||||
public bool MatchEmbed => _embedMode;
|
||||
|
||||
public DiscordSocketClient Discord => _instance.Client;
|
||||
public Func<string, Task> Logger => _instance.Log;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Rule instance to represent the given configuration.
|
||||
/// </summary>
|
||||
public ConfigItem(AutoMod instance, JProperty definition)
|
||||
{
|
||||
_instance = instance;
|
||||
|
||||
_label = definition.Name;
|
||||
var ruleconf = (JObject)definition.Value;
|
||||
// TODO validation. does the above line even throw an exception in the right cases?
|
||||
// and what about the label? does it make for a good name?
|
||||
|
||||
string errpfx = $" in definition for rule '{_label}'.";
|
||||
|
||||
// regex options
|
||||
RegexOptions opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||
// TODO consider adding an option to specify Singleline and Multiline matching
|
||||
opts |= RegexOptions.Singleline;
|
||||
// case sensitivity must be explicitly defined, else not case sensitive by default
|
||||
bool? regexci = ruleconf["ignorecase"]?.Value<bool>();
|
||||
opts |= RegexOptions.IgnoreCase;
|
||||
if (regexci.HasValue && regexci.Value == false)
|
||||
opts &= ~RegexOptions.IgnoreCase;
|
||||
|
||||
// regex
|
||||
const string NoRegexError = "No regular expression patterns are defined";
|
||||
var regexes = new List<Regex>();
|
||||
var rxconf = ruleconf["regex"];
|
||||
if (rxconf == null) throw new RuleImportException(NoRegexError + errpfx);
|
||||
if (rxconf.Type == JTokenType.Array)
|
||||
{
|
||||
foreach (var input in rxconf.Values<string>())
|
||||
{
|
||||
try
|
||||
{
|
||||
Regex r = new Regex(input, opts);
|
||||
regexes.Add(r);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new RuleImportException(
|
||||
$"Failed to parse regular expression pattern '{input}'{errpfx}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string rxstr = rxconf.Value<string>();
|
||||
try
|
||||
{
|
||||
Regex r = new Regex(rxstr, opts);
|
||||
regexes.Add(r);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
|
||||
{
|
||||
throw new RuleImportException(
|
||||
$"Failed to parse regular expression pattern '{rxstr}'{errpfx}");
|
||||
}
|
||||
}
|
||||
if (regexes.Count == 0)
|
||||
{
|
||||
throw new RuleImportException(NoRegexError + errpfx);
|
||||
}
|
||||
_regex = regexes.ToArray();
|
||||
|
||||
// min/max length
|
||||
try
|
||||
{
|
||||
_msgMinLength = ruleconf["min"]?.Value<int>() ?? -1;
|
||||
_msgMaxLength = ruleconf["max"]?.Value<int>() ?? -1;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new RuleImportException("Minimum/maximum values must be an integer.");
|
||||
}
|
||||
|
||||
// responses
|
||||
const string NoResponseError = "No responses have been defined";
|
||||
var responsestrs = new List<string>();
|
||||
var rsconf = ruleconf["response"];
|
||||
if (rsconf == null) throw new RuleImportException(NoResponseError + errpfx);
|
||||
try
|
||||
{
|
||||
if (rsconf.Type == JTokenType.Array)
|
||||
{
|
||||
_responses = ResponseBase.ReadConfiguration(this, rsconf.Values<string>());
|
||||
}
|
||||
else
|
||||
{
|
||||
_responses = ResponseBase.ReadConfiguration(this, new string[] { rsconf.Value<string>() });
|
||||
}
|
||||
}
|
||||
catch (RuleImportException ex)
|
||||
{
|
||||
throw new RuleImportException(ex.Message + errpfx);
|
||||
}
|
||||
|
||||
|
||||
// whitelist/blacklist filtering
|
||||
_filter = new FilterList(ruleconf);
|
||||
|
||||
// moderator bypass toggle - true by default, must be explicitly set to false
|
||||
bool? bypass = ruleconf["AllowModBypass"]?.Value<bool>();
|
||||
_modBypass = bypass.HasValue ? bypass.Value : true;
|
||||
|
||||
// embed matching mode
|
||||
bool? embed = ruleconf["MatchEmbeds"]?.Value<bool>();
|
||||
_embedMode = (embed.HasValue && embed == true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks given message to see if it matches this rule's constraints.
|
||||
/// </summary>
|
||||
/// <returns>If true, the rule's response(s) should be executed.</returns>
|
||||
public bool Match(SocketMessage m, bool isMod)
|
||||
{
|
||||
// Regular or embed mode?
|
||||
string msgcontent;
|
||||
if (MatchEmbed) msgcontent = SerializeEmbed(m.Embeds);
|
||||
else msgcontent = m.Content;
|
||||
|
||||
// Min/max length check
|
||||
if (_msgMinLength != -1 && msgcontent.Length <= _msgMinLength) return false;
|
||||
if (_msgMaxLength != -1 && msgcontent.Length >= _msgMaxLength) return false;
|
||||
|
||||
// Filter check
|
||||
if (Filter.IsFiltered(m)) return false;
|
||||
// Mod bypass check
|
||||
if (AllowsModBypass && isMod) return false;
|
||||
|
||||
// Finally, regex checks
|
||||
foreach (var regex in Regex)
|
||||
{
|
||||
if (regex.IsMatch(msgcontent)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private string SerializeEmbed(IReadOnlyCollection<Embed> e)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
foreach (var item in e) text.AppendLine(SerializeEmbed(item));
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an embed to a plain string for easier matching.
|
||||
/// </summary>
|
||||
private string SerializeEmbed(Embed e)
|
||||
{
|
||||
StringBuilder result = new StringBuilder();
|
||||
if (e.Author.HasValue) result.AppendLine(e.Author.Value.Name ?? "" + e.Author.Value.Url ?? "");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
|
||||
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
|
||||
|
||||
foreach (var f in e.Fields)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
|
||||
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
|
||||
}
|
||||
|
||||
if (e.Footer.HasValue)
|
||||
{
|
||||
result.AppendLine(e.Footer.Value.Text ?? "");
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
public override string ToString() => $"Rule '{Label}'";
|
||||
}
|
||||
}
|
171
Feature/AutoMod/ResponseBase.cs
Normal file
171
Feature/AutoMod/ResponseBase.cs
Normal file
|
@ -0,0 +1,171 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod
|
||||
{
|
||||
/// <summary>
|
||||
/// Base class for all Response classes.
|
||||
/// Contains helper methods for use by response code.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("Response: {_cmdline}")]
|
||||
abstract class ResponseBase
|
||||
{
|
||||
private readonly ConfigItem _rule;
|
||||
private readonly string _cmdline;
|
||||
|
||||
protected ConfigItem Rule => _rule;
|
||||
private DiscordSocketClient Client => _rule.Discord;
|
||||
public string CmdLine => _cmdline;
|
||||
public string CmdArg0 {
|
||||
get {
|
||||
int i = _cmdline.IndexOf(' ');
|
||||
if (i != -1) return _cmdline.Substring(0, i);
|
||||
return _cmdline;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deriving constructor should do validation of incoming <paramref name="cmdline"/>.
|
||||
/// </summary>
|
||||
public ResponseBase(ConfigItem rule, string cmdline)
|
||||
{
|
||||
_rule = rule;
|
||||
_cmdline = cmdline;
|
||||
}
|
||||
|
||||
public abstract Task Invoke(SocketMessage msg);
|
||||
|
||||
protected async Task Log(string text)
|
||||
{
|
||||
int dl = _cmdline.IndexOf(' ');
|
||||
var prefix = _cmdline.Substring(0, dl);
|
||||
await Rule.Logger(prefix + ": " + text);
|
||||
}
|
||||
|
||||
#region Config loading
|
||||
private static readonly ReadOnlyDictionary<string, Type> _commands =
|
||||
new ReadOnlyDictionary<string, Type>(
|
||||
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Define all accepted commands and their corresponding types here
|
||||
{ "ban", typeof(Responses.Ban) },
|
||||
{ "kick", typeof(Responses.Kick) },
|
||||
{ "say", typeof(Responses.Say) },
|
||||
{ "send", typeof(Responses.Say) },
|
||||
{ "delete", typeof(Responses.Remove) },
|
||||
{ "remove", typeof(Responses.Remove) },
|
||||
{ "report", typeof(Responses.Report) },
|
||||
{ "addrole", typeof(Responses.RoleManipulation) },
|
||||
{ "grantrole", typeof(Responses.RoleManipulation) },
|
||||
{ "delrole", typeof(Responses.RoleManipulation) },
|
||||
{ "removerole", typeof(Responses.RoleManipulation) },
|
||||
{ "revokerole", typeof(Responses.RoleManipulation) }
|
||||
});
|
||||
|
||||
public static ResponseBase[] ReadConfiguration(ConfigItem r, IEnumerable<string> responses)
|
||||
{
|
||||
var result = new List<ResponseBase>();
|
||||
foreach (var line in responses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
throw new RuleImportException("Empty response line");
|
||||
int i = line.IndexOf(' ');
|
||||
string basecmd;
|
||||
if (i != -1) basecmd = line.Substring(0, i);
|
||||
else basecmd = line;
|
||||
|
||||
Type rt;
|
||||
if (!_commands.TryGetValue(basecmd, out rt))
|
||||
throw new RuleImportException($"'{basecmd}' is not a valid response");
|
||||
|
||||
var newresponse = Activator.CreateInstance(rt, r, line) as ResponseBase;
|
||||
if (newresponse == null)
|
||||
throw new Exception("An unknown error occurred when attempting to create a new Response object.");
|
||||
result.Add(newresponse);
|
||||
}
|
||||
return result.ToArray();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper methods
|
||||
/// <summary>
|
||||
/// Receives a string (beginning with @ or #) and returns an object
|
||||
/// suitable for sending out messages
|
||||
/// </summary>
|
||||
protected async Task<IMessageChannel> GetMessageTargetAsync(string targetName, SocketMessage m)
|
||||
{
|
||||
const string AEShort = "Target name is too short.";
|
||||
|
||||
EntityType et;
|
||||
if (targetName.Length <= 1) throw new ArgumentException(AEShort);
|
||||
|
||||
if (targetName[0] == '#') et = EntityType.Channel;
|
||||
else if (targetName[0] == '@') et = EntityType.User;
|
||||
else throw new ArgumentException("Target is not specified to be either a channel or user.");
|
||||
|
||||
targetName = targetName.Substring(1);
|
||||
if (targetName == "_")
|
||||
{
|
||||
if (et == EntityType.Channel) return m.Channel;
|
||||
else return await m.Author.GetOrCreateDMChannelAsync();
|
||||
}
|
||||
|
||||
EntityName ei = new EntityName(targetName, et);
|
||||
SocketGuild g = ((SocketGuildUser)m.Author).Guild;
|
||||
|
||||
if (et == EntityType.Channel)
|
||||
{
|
||||
if (targetName.Length < 2 || targetName.Length > 100)
|
||||
throw new ArgumentException(AEShort);
|
||||
|
||||
foreach (var ch in g.TextChannels)
|
||||
{
|
||||
if (ei.Id.HasValue)
|
||||
{
|
||||
if (ei.Id.Value == ch.Id) return ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.Equals(ei.Name, ch.Name, StringComparison.OrdinalIgnoreCase)) return ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ei.Id.HasValue)
|
||||
{
|
||||
// The easy way
|
||||
return await Client.GetUser(ei.Id.Value).GetOrCreateDMChannelAsync();
|
||||
}
|
||||
|
||||
// The hard way
|
||||
foreach (var u in g.Users)
|
||||
{
|
||||
if (string.Equals(ei.Name, u.Username, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(ei.Name, u.Nickname, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await u.GetOrCreateDMChannelAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected string ProcessText(string input, SocketMessage m)
|
||||
{
|
||||
// Maybe in the future this will do more.
|
||||
// For now, replaces all instances of @_ with the message sender.
|
||||
return input
|
||||
.Replace("@_", m.Author.Mention)
|
||||
.Replace("@\\_", "@_");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
50
Feature/AutoMod/Responses/Ban.cs
Normal file
50
Feature/AutoMod/Responses/Ban.cs
Normal file
|
@ -0,0 +1,50 @@
|
|||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Bans the invoking user.
|
||||
/// Parameters: ban [days = 0]
|
||||
/// </summary>
|
||||
class Ban : ResponseBase
|
||||
{
|
||||
readonly int _purgeDays;
|
||||
|
||||
public Ban(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (line.Length == 1)
|
||||
{
|
||||
_purgeDays = 0;
|
||||
}
|
||||
else if (line.Length == 2)
|
||||
{
|
||||
if (int.TryParse(line[1], out _purgeDays))
|
||||
{
|
||||
if (_purgeDays < 0 || _purgeDays > 7)
|
||||
{
|
||||
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new RuleImportException("Parameter must be an integer between 0 and 7.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new RuleImportException("Incorrect number of parameters.");
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task Invoke(SocketMessage msg)
|
||||
{
|
||||
var target = (SocketGuildUser)msg.Author;
|
||||
await target.Guild.AddBanAsync(target, _purgeDays, Uri.EscapeDataString($"Rule '{Rule.Label}'"));
|
||||
// TODO remove string escaping when fixed in library
|
||||
}
|
||||
}
|
||||
}
|
28
Feature/AutoMod/Responses/Kick.cs
Normal file
28
Feature/AutoMod/Responses/Kick.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Kicks the invoking user.
|
||||
/// Takes no parameters.
|
||||
/// </summary>
|
||||
class Kick : ResponseBase
|
||||
{
|
||||
public Kick(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
// Throw exception if extra parameters found
|
||||
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
|
||||
throw new RuleImportException("Incorrect number of parameters.");
|
||||
}
|
||||
|
||||
public override async Task Invoke(SocketMessage msg)
|
||||
{
|
||||
var target = (SocketGuildUser)msg.Author;
|
||||
await target.KickAsync(Uri.EscapeDataString($"Rule '{Rule.Label}'"));
|
||||
// TODO remove string escaping when fixed in library
|
||||
}
|
||||
}
|
||||
}
|
23
Feature/AutoMod/Responses/Remove.cs
Normal file
23
Feature/AutoMod/Responses/Remove.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes the invoking message.
|
||||
/// Takes no parameters.
|
||||
/// </summary>
|
||||
class Remove : ResponseBase
|
||||
{
|
||||
public Remove(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
// Throw exception if extra parameters found
|
||||
if (cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
|
||||
throw new RuleImportException("Incorrect number of parameters.");
|
||||
}
|
||||
|
||||
public override Task Invoke(SocketMessage msg) => msg.DeleteAsync();
|
||||
}
|
||||
}
|
93
Feature/AutoMod/Responses/Report.cs
Normal file
93
Feature/AutoMod/Responses/Report.cs
Normal file
|
@ -0,0 +1,93 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a summary of the invoking message, along with information
|
||||
/// about the rule making use of this command, to the given target.
|
||||
/// Parameters: report (target)
|
||||
/// </summary>
|
||||
class Report : ResponseBase
|
||||
{
|
||||
readonly string _target;
|
||||
|
||||
public Report(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (line.Length != 2) throw new RuleImportException("Incorrect number of parameters");
|
||||
_target = line[1];
|
||||
}
|
||||
|
||||
public override async Task Invoke(SocketMessage msg)
|
||||
{
|
||||
var target = await GetMessageTargetAsync(_target, msg);
|
||||
if (target == null)
|
||||
{
|
||||
await Log("Error: Unable to resolve the given target.");
|
||||
}
|
||||
await target.SendMessageAsync("", embed: BuildReportEmbed(msg));
|
||||
}
|
||||
|
||||
private EmbedBuilder BuildReportEmbed(SocketMessage msg)
|
||||
{
|
||||
string invokeLine = msg.Content;
|
||||
|
||||
var responsebody = new StringBuilder();
|
||||
responsebody.AppendLine("```");
|
||||
foreach (var item in Rule.Response)
|
||||
{
|
||||
responsebody.AppendLine(item.CmdLine.Replace("\r", "").Replace("\n", "\\n"));
|
||||
}
|
||||
responsebody.Append("```");
|
||||
|
||||
// Discord has a 2000 character limit per single message.
|
||||
// Enforcing separate length limits on line and response.
|
||||
const int DescriptionLengthMax = 1600;
|
||||
const int ResponseBodyLengthMax = 200;
|
||||
if (invokeLine.Length > DescriptionLengthMax)
|
||||
{
|
||||
invokeLine = $"**Message length too long; showing first {DescriptionLengthMax} characters.**\n\n"
|
||||
+ invokeLine.Substring(0, DescriptionLengthMax);
|
||||
}
|
||||
if (responsebody.Length > ResponseBodyLengthMax)
|
||||
{
|
||||
responsebody = new StringBuilder("(Response body too large to display.)");
|
||||
}
|
||||
|
||||
return new EmbedBuilder()
|
||||
{
|
||||
Color = new Color(0xEDCE00), // configurable later?
|
||||
|
||||
Author = new EmbedAuthorBuilder()
|
||||
{
|
||||
Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:",
|
||||
IconUrl = msg.Author.GetAvatarUrl()
|
||||
},
|
||||
Description = invokeLine,
|
||||
|
||||
Footer = new EmbedFooterBuilder()
|
||||
{
|
||||
Text = $"Rule '{Rule.Label}'",
|
||||
IconUrl = Rule.Discord.CurrentUser.GetAvatarUrl()
|
||||
},
|
||||
Timestamp = msg.EditedTimestamp ?? msg.Timestamp
|
||||
}.AddField(new EmbedFieldBuilder()
|
||||
{
|
||||
Name = "Additional info",
|
||||
Value = $"Username: {msg.Author.Mention}\n"
|
||||
+ $"Channel: <#{msg.Channel.Id}> #{msg.Channel.Name} ({msg.Channel.Id})\n"
|
||||
+ $"Message ID: {msg.Id}"
|
||||
}).AddField(new EmbedFieldBuilder()
|
||||
{
|
||||
// TODO consider replacing with configurable note. this section is a bit too much
|
||||
Name = "Executing response:",
|
||||
Value = responsebody.ToString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
89
Feature/AutoMod/Responses/RoleManipulation.cs
Normal file
89
Feature/AutoMod/Responses/RoleManipulation.cs
Normal file
|
@ -0,0 +1,89 @@
|
|||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Manipulates a given user's role.
|
||||
/// Parameters: (command) (target) (role ID)
|
||||
/// </summary>
|
||||
class RoleManipulation : ResponseBase
|
||||
{
|
||||
enum ManipulationType { None, Add, Remove }
|
||||
|
||||
readonly ManipulationType _action;
|
||||
readonly string _target;
|
||||
readonly EntityName _role;
|
||||
|
||||
public RoleManipulation(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
var line = cmdline.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (line.Length != 3)
|
||||
throw new RuleImportException("Incorrect number of parameters.");
|
||||
|
||||
// Ensure the strings here match those in Response._commands
|
||||
switch (line[0].ToLowerInvariant())
|
||||
{
|
||||
case "addrole":
|
||||
case "grantrole":
|
||||
_action = ManipulationType.Add;
|
||||
break;
|
||||
case "delrole":
|
||||
case "removerole":
|
||||
case "revokerole":
|
||||
_action = ManipulationType.Remove;
|
||||
break;
|
||||
default:
|
||||
_action = ManipulationType.None;
|
||||
break;
|
||||
}
|
||||
if (_action == ManipulationType.None)
|
||||
throw new RuleImportException("Command not defined. This is a bug.");
|
||||
|
||||
_target = line[1];
|
||||
_role = new EntityName(line[2], EntityType.Role);
|
||||
}
|
||||
|
||||
public override async Task Invoke(SocketMessage msg)
|
||||
{
|
||||
// Find role
|
||||
SocketRole rtarget;
|
||||
var g = ((SocketGuildUser)msg.Author).Guild;
|
||||
if (_role.Id.HasValue) rtarget = g.GetRole(_role.Id.Value);
|
||||
else rtarget = g.Roles.FirstOrDefault(r =>
|
||||
string.Equals(r.Name, _role.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (rtarget == null)
|
||||
{
|
||||
await Log("Error: Target role not found in server.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user
|
||||
SocketGuildUser utarget;
|
||||
if (_target == "@_") utarget = (SocketGuildUser)msg.Author;
|
||||
else
|
||||
{
|
||||
utarget = g.Users.FirstOrDefault(u =>
|
||||
{
|
||||
if (string.Equals(u.Nickname, _target, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (string.Equals(u.Username, _target, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (utarget == null)
|
||||
{
|
||||
await Log("Error: Target user not found in server.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Do action
|
||||
if (_action == ManipulationType.Add)
|
||||
await utarget.AddRoleAsync(rtarget);
|
||||
else if (_action == ManipulationType.Remove)
|
||||
await utarget.RemoveRoleAsync(rtarget);
|
||||
}
|
||||
}
|
||||
}
|
45
Feature/AutoMod/Responses/Say.cs
Normal file
45
Feature/AutoMod/Responses/Say.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using Discord.WebSocket;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoMod.Responses
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a message to the given target.
|
||||
/// Parameters: say (target) (message)
|
||||
/// </summary>
|
||||
class Say : ResponseBase
|
||||
{
|
||||
private readonly string _target;
|
||||
private readonly string _payload;
|
||||
|
||||
public Say(ConfigItem rule, string cmdline) : base(rule, cmdline)
|
||||
{
|
||||
var line = cmdline.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (line.Length != 3) throw new RuleImportException("Incorrect number of parameters.");
|
||||
|
||||
// Very basic target verification. Could be improved?
|
||||
if (line[1][0] != '@' && line[1][0] != '#')
|
||||
throw new RuleImportException("The given target is not valid.");
|
||||
_target = line[1];
|
||||
|
||||
_payload = line[2];
|
||||
if (string.IsNullOrWhiteSpace(_payload))
|
||||
throw new RuleImportException("Message parameter is blank or missing.");
|
||||
}
|
||||
|
||||
public override async Task Invoke(SocketMessage msg)
|
||||
{
|
||||
// CHANGE THE SAY
|
||||
string reply = ProcessText(_payload, msg);
|
||||
|
||||
var target = await GetMessageTargetAsync(_target, msg);
|
||||
if (target == null)
|
||||
{
|
||||
await Log("Error: Unable to resolve the given target.");
|
||||
}
|
||||
await target.SendMessageAsync(reply);
|
||||
}
|
||||
}
|
||||
}
|
112
Feature/AutoRespond/AutoRespond.cs
Normal file
112
Feature/AutoRespond/AutoRespond.cs
Normal file
|
@ -0,0 +1,112 @@
|
|||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoRespond
|
||||
{
|
||||
/// <summary>
|
||||
/// Similar to <see cref="AutoMod"/>, but lightweight.
|
||||
/// Provides the capability to define autoresponses for fun or informational purposes.
|
||||
/// <para>
|
||||
/// The major differences between this and <see cref="AutoMod"/> include:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Does not listen for message edits.</description></item>
|
||||
/// <item><description>Moderators are not exempt from any defined triggers.</description></item>
|
||||
/// <item><description>Responses are limited to the invoking channel.</description></item>
|
||||
/// <item><description>Per-channel rate limiting.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
partial class AutoRespond : BotFeature
|
||||
{
|
||||
#region BotFeature implementation
|
||||
public override string Name => "AutoRespond";
|
||||
|
||||
public AutoRespond(DiscordSocketClient client) : base(client)
|
||||
{
|
||||
client.MessageReceived += Client_MessageReceived;
|
||||
}
|
||||
|
||||
private async Task Client_MessageReceived(SocketMessage arg)
|
||||
{
|
||||
// Determine channel type - if not a guild channel, stop.
|
||||
var ch = arg.Channel as SocketGuildChannel;
|
||||
if (ch == null) return;
|
||||
|
||||
// TODO either search server by name or remove server name support entirely
|
||||
var defs = GetConfig(ch.Guild.Id) as IEnumerable<ConfigItem>;
|
||||
if (defs == null) return;
|
||||
|
||||
foreach (var def in defs)
|
||||
await Task.Run(async () => await ProcessMessage(arg, def));
|
||||
}
|
||||
|
||||
[ConfigSection("autoresponses")]
|
||||
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||
{
|
||||
var responses = new List<ConfigItem>();
|
||||
foreach (var def in configSection.Children<JProperty>())
|
||||
{
|
||||
// All validation is left to the constructor
|
||||
var resp = new ConfigItem(def);
|
||||
responses.Add(resp);
|
||||
}
|
||||
|
||||
if (responses.Count > 0)
|
||||
await Log($"Loaded {responses.Count} definition(s) from configuration.");
|
||||
return responses.AsReadOnly();
|
||||
}
|
||||
#endregion
|
||||
|
||||
private async Task ProcessMessage(SocketMessage msg, ConfigItem def)
|
||||
{
|
||||
if (!def.Match(msg)) return;
|
||||
|
||||
await Log($"'{def.Label}' triggered by {msg.Author} in {((SocketGuildChannel)msg.Channel).Guild.Name}/#{msg.Channel.Name}");
|
||||
|
||||
var (type, text) = def.Response;
|
||||
if (type == ConfigItem.ResponseType.Reply) await ProcessReply(msg, text);
|
||||
else if (type == ConfigItem.ResponseType.Exec) await ProcessExec(msg, text);
|
||||
}
|
||||
|
||||
private async Task ProcessReply(SocketMessage msg, string text)
|
||||
{
|
||||
await msg.Channel.SendMessageAsync(text);
|
||||
}
|
||||
|
||||
private async Task ProcessExec(SocketMessage msg, string text)
|
||||
{
|
||||
string[] cmdline = text.Split(new char[] { ' ' }, 2);
|
||||
|
||||
ProcessStartInfo 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 at most 5 seconds
|
||||
if (p.HasExited)
|
||||
{
|
||||
if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode);
|
||||
using (var stdout = p.StandardOutput)
|
||||
{
|
||||
var result = await stdout.ReadToEndAsync();
|
||||
await msg.Channel.SendMessageAsync(result);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Log("exec: Process is taking too long to exit. Killing process.");
|
||||
p.Kill();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
153
Feature/AutoRespond/ConfigItem.cs
Normal file
153
Feature/AutoRespond/ConfigItem.cs
Normal file
|
@ -0,0 +1,153 @@
|
|||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoRespond
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single autoresponse definition.
|
||||
/// </summary>
|
||||
class ConfigItem
|
||||
{
|
||||
public enum ResponseType { None, Exec, Reply }
|
||||
|
||||
string _label;
|
||||
IEnumerable<Regex> _regex;
|
||||
ResponseType _rtype;
|
||||
string _rbody;
|
||||
private FilterList _filter;
|
||||
private RateLimitCache _limit;
|
||||
|
||||
public string Label => _label;
|
||||
public IEnumerable<Regex> Regex => _regex;
|
||||
public (ResponseType, string) Response => (_rtype, _rbody);
|
||||
public FilterList Filter => _filter;
|
||||
public RateLimitCache RateLimit => _limit;
|
||||
|
||||
public ConfigItem(JProperty definition)
|
||||
{
|
||||
_label = definition.Name;
|
||||
var data = (JObject)definition.Value;
|
||||
|
||||
// error postfix string
|
||||
string errorpfx = $" in response definition for '{_label}'.";
|
||||
|
||||
// regex trigger
|
||||
const string NoRegexError = "No regular expression patterns are defined";
|
||||
var regexes = new List<Regex>();
|
||||
const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline;
|
||||
var rxconf = data["regex"];
|
||||
if (rxconf == null) throw new RuleImportException(NoRegexError + errorpfx);
|
||||
if (rxconf.Type == JTokenType.Array)
|
||||
{
|
||||
foreach (var input in rxconf.Values<string>())
|
||||
{
|
||||
try
|
||||
{
|
||||
Regex r = new Regex(input, rxopts);
|
||||
regexes.Add(r);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new RuleImportException(
|
||||
$"Failed to parse regular expression pattern '{input}'{errorpfx}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string rxstr = rxconf.Value<string>();
|
||||
try
|
||||
{
|
||||
Regex r = new Regex(rxstr, rxopts);
|
||||
regexes.Add(r);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException)
|
||||
{
|
||||
throw new RuleImportException(
|
||||
$"Failed to parse regular expression pattern '{rxstr}'{errorpfx}");
|
||||
}
|
||||
}
|
||||
_regex = regexes.ToArray();
|
||||
|
||||
// response - defined in either "exec" or "reply", but not both
|
||||
_rbody = null;
|
||||
_rtype = ResponseType.None;
|
||||
|
||||
// exec response
|
||||
string execstr = data["exec"]?.Value<string>();
|
||||
if (!string.IsNullOrWhiteSpace(execstr))
|
||||
{
|
||||
_rbody = execstr;
|
||||
_rtype = ResponseType.Exec;
|
||||
}
|
||||
|
||||
// reply response
|
||||
string replystr = data["reply"]?.Value<string>();
|
||||
if (!string.IsNullOrWhiteSpace(replystr))
|
||||
{
|
||||
if (_rbody != null)
|
||||
throw new RuleImportException("A value for both 'exec' and 'reply' is not allowed" + errorpfx);
|
||||
_rbody = replystr;
|
||||
_rtype = ResponseType.Reply;
|
||||
}
|
||||
|
||||
if (_rbody == null)
|
||||
throw new RuleImportException("A response value of either 'exec' or 'reply' was not defined" + errorpfx);
|
||||
// ---
|
||||
|
||||
// whitelist/blacklist filtering
|
||||
_filter = new FilterList(data);
|
||||
|
||||
// rate limiting
|
||||
string rlstr = data["ratelimit"]?.Value<string>();
|
||||
if (string.IsNullOrWhiteSpace(rlstr))
|
||||
{
|
||||
_limit = new RateLimitCache(RateLimitCache.DefaultTimeout);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ushort.TryParse(rlstr, out var rlval))
|
||||
{
|
||||
_limit = new RateLimitCache(rlval);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new RuleImportException("Rate limit value is invalid" + errorpfx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks given message to see if it matches this rule's constraints.
|
||||
/// </summary>
|
||||
/// <returns>If true, the rule's response(s) should be executed.</returns>
|
||||
public bool Match(SocketMessage m)
|
||||
{
|
||||
// Filter check
|
||||
if (Filter.IsFiltered(m)) 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.AllowUsage(m.Channel.Id)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override string ToString() => $"Autoresponse definition '{Label}'";
|
||||
}
|
||||
}
|
61
Feature/AutoRespond/RateLimitCache.cs
Normal file
61
Feature/AutoRespond/RateLimitCache.cs
Normal file
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.AutoRespond
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores rate limit settings and caches.
|
||||
/// </summary>
|
||||
class RateLimitCache
|
||||
{
|
||||
public const ushort DefaultTimeout = 20; // this is Skeeter's fault
|
||||
|
||||
private readonly ushort _timeout;
|
||||
private Dictionary<ulong, DateTime> _cache;
|
||||
|
||||
public ushort Timeout => _timeout;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a new instance of <see cref="RateLimitCache"/>.
|
||||
/// </summary>
|
||||
public RateLimitCache(ushort timeout)
|
||||
{
|
||||
_timeout = timeout;
|
||||
_cache = new Dictionary<ulong, DateTime>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a "usage" is allowed for the given value.
|
||||
/// Items added to cache will be removed after the number of seconds specified in <see cref="Timeout"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to add to the cache.</param>
|
||||
/// <returns>True on success. False if the given ID already exists.</returns>
|
||||
public bool AllowUsage(ulong id)
|
||||
{
|
||||
if (_timeout == 0) return true;
|
||||
|
||||
lock (this)
|
||||
{
|
||||
Clean();
|
||||
if (_cache.ContainsKey(id)) return false;
|
||||
_cache.Add(id, DateTime.Now);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Clean()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var clean = new Dictionary<ulong, DateTime>();
|
||||
foreach (var kp in _cache)
|
||||
{
|
||||
if (kp.Value.AddSeconds(Timeout) > now)
|
||||
{
|
||||
// Copy items that have not yet timed out to the new dictionary
|
||||
clean.Add(kp.Key, kp.Value);
|
||||
}
|
||||
}
|
||||
_cache = clean;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,329 +0,0 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.RegexResponder
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements per-message regex matching and executes customizable responses.
|
||||
/// Namesake of this project.
|
||||
/// </summary>
|
||||
partial class EventProcessor : BotFeature
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public override string Name => "RegexResponder";
|
||||
|
||||
public EventProcessor(DiscordSocketClient client) : base(client)
|
||||
{
|
||||
_client = client;
|
||||
|
||||
_client.MessageReceived += OnMessageReceived;
|
||||
_client.MessageUpdated += OnMessageUpdated;
|
||||
|
||||
_commands = new ReadOnlyDictionary<string, ResponseProcessor>(
|
||||
new Dictionary<string, ResponseProcessor>() {
|
||||
#if DEBUG
|
||||
{ "crash", RP_Crash },
|
||||
{ "dumpid", RP_DumpID },
|
||||
#endif
|
||||
{ "report", RP_Report },
|
||||
{ "say", RP_Say },
|
||||
{ "remove", RP_Remove },
|
||||
{ "delete", RP_Remove },
|
||||
{ "erase", RP_Remove },
|
||||
{ "exec", RP_Exec },
|
||||
{ "ban", RP_Ban },
|
||||
{ "grantrole", RP_GrantRevokeRole },
|
||||
{ "revokerole", RP_GrantRevokeRole }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#region Event handlers
|
||||
private async Task OnMessageReceived(SocketMessage arg)
|
||||
=> await ReceiveMessage(arg);
|
||||
private async Task OnMessageUpdated(Cacheable<IMessage, ulong> arg1, SocketMessage arg2, ISocketMessageChannel arg3)
|
||||
=> await ReceiveMessage(arg2);
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Receives incoming messages and creates tasks to handle them if necessary.
|
||||
/// </summary>
|
||||
private async Task ReceiveMessage(SocketMessage arg)
|
||||
{
|
||||
// Determine channel type - if not a guild channel, stop.
|
||||
var ch = arg.Channel as SocketGuildChannel;
|
||||
if (ch == null) return;
|
||||
|
||||
if (arg.Author == _client.CurrentUser) return; // Don't ever self-trigger
|
||||
|
||||
// Looking up server information and extracting settings
|
||||
SocketGuild g = ch.Guild;
|
||||
ServerConfig sd = null;
|
||||
foreach (var item in RegexBot.Config.Servers)
|
||||
{
|
||||
if (item.Id.HasValue)
|
||||
{
|
||||
// Finding server by ID
|
||||
if (g.Id == item.Id)
|
||||
{
|
||||
sd = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Finding server by name and caching ID
|
||||
if (string.Equals(item.Name, g.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
item.Id = g.Id;
|
||||
sd = item;
|
||||
await Logger.GetLogger(Configuration.LogPrefix)
|
||||
($"Suggestion: Server \"{item.Name}\" can be defined as \"{item.Id}::{item.Name}\"");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sd == null) return; // No server configuration found
|
||||
var rules = GetConfig(ch.Guild.Id) as IEnumerable<RuleConfig>;
|
||||
if (rules == null) return;
|
||||
|
||||
// Further processing is sent to the thread pool
|
||||
foreach (var rule in rules)
|
||||
await Task.Run(async () => await ProcessMessage(sd, rule, arg));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses information from a single rule and checks if the incoming message is a match.
|
||||
/// If it matches, the rule's responses are executed. To be run in the thread pool.
|
||||
/// </summary>
|
||||
private async Task ProcessMessage(ServerConfig srv, RuleConfig rule, SocketMessage msg)
|
||||
{
|
||||
string msgcontent;
|
||||
|
||||
// Embed mode?
|
||||
if (rule.MatchEmbeds)
|
||||
{
|
||||
var embeds = new StringBuilder();
|
||||
foreach (var e in msg.Embeds) embeds.AppendLine(EmbedToString(e));
|
||||
msgcontent = embeds.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
msgcontent = msg.Content;
|
||||
}
|
||||
|
||||
// Min/max message length check
|
||||
if (rule.MinLength.HasValue && msgcontent.Length <= rule.MinLength.Value) return;
|
||||
if (rule.MaxLength.HasValue && msgcontent.Length >= rule.MaxLength.Value) return;
|
||||
|
||||
// Moderator bypass check
|
||||
if (rule.AllowModBypass == true && srv.Moderators.ExistsInList(msg)) return;
|
||||
// Individual rule filtering check
|
||||
if (IsFiltered(rule, msg)) return;
|
||||
|
||||
// And finally, pattern matching checks
|
||||
bool success = false;
|
||||
foreach (var regex in rule.Regex)
|
||||
{
|
||||
success = regex.Match(msgcontent).Success;
|
||||
if (success) break;
|
||||
}
|
||||
if (!success) return;
|
||||
|
||||
// Prepare to execute responses
|
||||
await Log($"\"{rule.DisplayName}\" triggered in {srv.Name}/#{msg.Channel} by {msg.Author.ToString()}");
|
||||
|
||||
foreach (string rcmd in rule.Responses)
|
||||
{
|
||||
string cmd = rcmd.TrimStart(' ').Split(' ')[0].ToLower();
|
||||
try
|
||||
{
|
||||
ResponseProcessor response;
|
||||
if (!_commands.TryGetValue(cmd, out response))
|
||||
{
|
||||
await Log($"Unknown command defined in response: \"{cmd}\"");
|
||||
continue;
|
||||
}
|
||||
await response.Invoke(rcmd, rule, msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Log($"Encountered an error while processing \"{cmd}\". Details follow:");
|
||||
await Log(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[ConfigSection("rules")]
|
||||
public override async Task<object> ProcessConfiguration(JToken configSection)
|
||||
{
|
||||
List<RuleConfig> rules = new List<RuleConfig>();
|
||||
foreach (JObject ruleconf in configSection)
|
||||
{
|
||||
// Try and get at least the name before passing it to RuleItem
|
||||
string name = ruleconf["name"]?.Value<string>();
|
||||
if (name == null)
|
||||
{
|
||||
await Log("Display name not defined within a rule section.");
|
||||
return false;
|
||||
}
|
||||
await Log($"Adding rule \"{name}\"");
|
||||
|
||||
RuleConfig rule;
|
||||
try
|
||||
{
|
||||
rule = new RuleConfig(ruleconf);
|
||||
}
|
||||
catch (RuleImportException ex)
|
||||
{
|
||||
await Log("-> Error: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
rules.Add(rule);
|
||||
}
|
||||
|
||||
return rules.AsReadOnly();
|
||||
}
|
||||
|
||||
// -------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Turns an embed into a single string for regex matching purposes
|
||||
/// </summary>
|
||||
private string EmbedToString(Embed e)
|
||||
{
|
||||
StringBuilder result = new StringBuilder();
|
||||
if (e.Author.HasValue) result.AppendLine(e.Author.Value.Name ?? "" + e.Author.Value.Url ?? "");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(e.Title)) result.AppendLine(e.Title);
|
||||
if (!string.IsNullOrWhiteSpace(e.Description)) result.AppendLine(e.Description);
|
||||
|
||||
foreach (var f in e.Fields)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(f.Name)) result.AppendLine(f.Name);
|
||||
if (!string.IsNullOrWhiteSpace(f.Value)) result.AppendLine(f.Value);
|
||||
}
|
||||
|
||||
if (e.Footer.HasValue)
|
||||
{
|
||||
result.AppendLine(e.Footer.Value.Text ?? "");
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private bool IsFiltered(RuleConfig r, SocketMessage m)
|
||||
{
|
||||
if (r.FilterMode == FilterType.None) return false;
|
||||
|
||||
bool inFilter = r.FilterList.ExistsInList(m);
|
||||
|
||||
if (r.FilterMode == FilterType.Whitelist)
|
||||
{
|
||||
if (!inFilter) return true;
|
||||
return r.FilterExemptions.ExistsInList(m);
|
||||
}
|
||||
else if (r.FilterMode == FilterType.Blacklist)
|
||||
{
|
||||
if (!inFilter) return false;
|
||||
return !r.FilterExemptions.ExistsInList(m);
|
||||
}
|
||||
|
||||
return false; // this shouldn't happen™
|
||||
}
|
||||
|
||||
private string[] SplitParams(string cmd, int? limit = null)
|
||||
{
|
||||
if (limit.HasValue)
|
||||
{
|
||||
return cmd.Split(new char[] { ' ' }, limit.Value, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
else
|
||||
{
|
||||
return cmd.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
}
|
||||
|
||||
private string ProcessText(string input, SocketMessage m)
|
||||
{
|
||||
// Maybe in the future this will do more.
|
||||
// For now, replaces all instances of @_ with the message sender.
|
||||
return input
|
||||
.Replace("@_", m.Author.Mention)
|
||||
.Replace("@\\_", "@_");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives a string (beginning with @ or #) and returns an object
|
||||
/// suitable for sending out messages
|
||||
/// </summary>
|
||||
private async Task<IMessageChannel> GetMessageTargetAsync(string targetName, SocketMessage m)
|
||||
{
|
||||
const string AEShort = "Target name is too short.";
|
||||
|
||||
EntityType et;
|
||||
if (targetName.Length <= 1) throw new ArgumentException(AEShort);
|
||||
|
||||
if (targetName[0] == '#') et = EntityType.Channel;
|
||||
else if (targetName[0] == '@') et = EntityType.User;
|
||||
else throw new ArgumentException("Target is not specified to be either a channel or user.");
|
||||
|
||||
targetName = targetName.Substring(1);
|
||||
if (targetName == "_")
|
||||
{
|
||||
if (et == EntityType.Channel) return m.Channel;
|
||||
else return await m.Author.GetOrCreateDMChannelAsync();
|
||||
}
|
||||
|
||||
EntityName ei = new EntityName(targetName, et);
|
||||
SocketGuild g = ((SocketGuildUser)m.Author).Guild;
|
||||
|
||||
if (et == EntityType.Channel)
|
||||
{
|
||||
if (targetName.Length < 2 || targetName.Length > 100)
|
||||
throw new ArgumentException(AEShort);
|
||||
|
||||
foreach (var ch in g.TextChannels)
|
||||
{
|
||||
if (ei.Id.HasValue)
|
||||
{
|
||||
if (ei.Id.Value == ch.Id) return ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.Equals(ei.Name, ch.Name, StringComparison.OrdinalIgnoreCase)) return ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ei.Id.HasValue)
|
||||
{
|
||||
// The easy way
|
||||
return await _client.GetUser(ei.Id.Value).GetOrCreateDMChannelAsync();
|
||||
}
|
||||
|
||||
// The hard way
|
||||
foreach (var u in g.Users)
|
||||
{
|
||||
if (string.Equals(ei.Name, u.Username, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(ei.Name, u.Nickname, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await u.GetOrCreateDMChannelAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,265 +0,0 @@
|
|||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.RegexResponder
|
||||
{
|
||||
// Contains code for handling each response in a rule.
|
||||
partial class EventProcessor
|
||||
{
|
||||
private delegate Task ResponseProcessor(string cmd, RuleConfig r, SocketMessage m);
|
||||
private readonly ReadOnlyDictionary<string, ResponseProcessor> _commands;
|
||||
|
||||
#if DEBUG
|
||||
/// <summary>
|
||||
/// Throws an exception. Meant to be a quick error handling test.
|
||||
/// No parameters.
|
||||
/// </summary>
|
||||
private Task RP_Crash(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
throw new Exception("Requested in response.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prints all guild values (IDs for users, channels, roles) to console.
|
||||
/// The guild info displayed is the one in which the command is invoked.
|
||||
/// No parameters.
|
||||
/// </summary>
|
||||
private Task RP_DumpID(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
var g = ((SocketGuildUser)m.Author).Guild;
|
||||
var result = new StringBuilder();
|
||||
|
||||
result.AppendLine("Users:");
|
||||
foreach (var item in g.Users)
|
||||
result.AppendLine($"{item.Id} {item.Username}#{item.Discriminator}");
|
||||
result.AppendLine();
|
||||
|
||||
result.AppendLine("Channels:");
|
||||
foreach (var item in g.Channels) result.AppendLine($"{item.Id} #{item.Name}");
|
||||
result.AppendLine();
|
||||
result.AppendLine("Roles:");
|
||||
foreach (var item in g.Roles) result.AppendLine($"{item.Id} {item.Name}");
|
||||
result.AppendLine();
|
||||
|
||||
Console.WriteLine(result.ToString());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
#endif
|
||||
/// <summary>
|
||||
/// Sends a message to a specified channel.
|
||||
/// Parameters: say (channel) (message)
|
||||
/// </summary>
|
||||
private async Task RP_Say(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
string[] @in = SplitParams(cmd, 3);
|
||||
if (@in.Length != 3)
|
||||
{
|
||||
await Log("Error: say: Incorrect number of parameters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var target = await GetMessageTargetAsync(@in[1], m);
|
||||
if (target == null)
|
||||
{
|
||||
await Log("Error: say: Unable to resolve given target.");
|
||||
return;
|
||||
}
|
||||
|
||||
// CHANGE THE SAY
|
||||
@in[2] = ProcessText(@in[2], m);
|
||||
await target.SendMessageAsync(@in[2]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports the incoming message to a given channel.
|
||||
/// Parameters: report (channel)
|
||||
/// </summary>
|
||||
private async Task RP_Report(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
string[] @in = SplitParams(cmd);
|
||||
if (@in.Length != 2)
|
||||
{
|
||||
await Log("Error: report: Incorrect number of parameters.");
|
||||
return;
|
||||
}
|
||||
|
||||
var target = await GetMessageTargetAsync(@in[1], m);
|
||||
if (target == null)
|
||||
{
|
||||
await Log("Error: report: Unable to resolve given target.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var responsefield = new StringBuilder();
|
||||
responsefield.AppendLine("```");
|
||||
foreach (var line in r.Responses)
|
||||
responsefield.AppendLine(line.Replace("\r", "").Replace("\n", "\\n"));
|
||||
responsefield.Append("```");
|
||||
await target.SendMessageAsync("", embed: new EmbedBuilder()
|
||||
{
|
||||
Color = new Color(0xEDCE00), // configurable later?
|
||||
|
||||
Author = new EmbedAuthorBuilder()
|
||||
{
|
||||
Name = $"{m.Author.Username}#{m.Author.Discriminator} said:",
|
||||
IconUrl = m.Author.GetAvatarUrl()
|
||||
},
|
||||
Description = m.Content,
|
||||
|
||||
Footer = new EmbedFooterBuilder()
|
||||
{
|
||||
Text = $"Rule '{r.DisplayName}'",
|
||||
IconUrl = _client.CurrentUser.GetAvatarUrl()
|
||||
},
|
||||
Timestamp = m.Timestamp
|
||||
}.AddField(new EmbedFieldBuilder()
|
||||
{
|
||||
Name = "Additional info",
|
||||
Value = $"Channel: <#{m.Channel.Id}>\n" // NOTE: manually mentioning channel here
|
||||
+ $"Username: {m.Author.Mention}\n"
|
||||
+ $"Message ID: {m.Id}"
|
||||
}).AddField(new EmbedFieldBuilder()
|
||||
{
|
||||
Name = "Executing response:",
|
||||
Value = responsefield.ToString()
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the incoming message.
|
||||
/// No parameters.
|
||||
/// </summary>
|
||||
private async Task RP_Remove(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
// Parameters are not checked
|
||||
await m.DeleteAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an external program and sends standard output to the given channel.
|
||||
/// Parameters: exec (channel) (command line)
|
||||
/// </summary>
|
||||
private async Task RP_Exec(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
var @in = SplitParams(cmd, 4);
|
||||
if (@in.Length < 3)
|
||||
{
|
||||
await Log("exec: Incorrect number of parameters.");
|
||||
}
|
||||
|
||||
string result;
|
||||
var target = await GetMessageTargetAsync(@in[1], m);
|
||||
if (target == null)
|
||||
{
|
||||
await Log("Error: exec: Unable to resolve given channel.");
|
||||
return;
|
||||
}
|
||||
|
||||
ProcessStartInfo ps = new ProcessStartInfo()
|
||||
{
|
||||
FileName = @in[2],
|
||||
Arguments = (@in.Length > 3 ? @in[3] : ""),
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
using (Process p = Process.Start(ps))
|
||||
{
|
||||
p.WaitForExit(5000); // waiting at most 5 seconds
|
||||
if (p.HasExited)
|
||||
{
|
||||
if (p.ExitCode != 0) await Log("exec: Process returned exit code " + p.ExitCode);
|
||||
using (var stdout = p.StandardOutput)
|
||||
{
|
||||
result = await stdout.ReadToEndAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Log("exec: Process is taking too long to exit. Killing process.");
|
||||
p.Kill();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
result = ProcessText(result.Trim(), m);
|
||||
await target.SendMessageAsync(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bans the sender of the incoming message.
|
||||
/// No parameters.
|
||||
/// </summary>
|
||||
// TODO add parameter for message auto-deleting
|
||||
private async Task RP_Ban(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
SocketGuild g = ((SocketGuildUser)m.Author).Guild;
|
||||
await g.AddBanAsync(m.Author);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grants or revokes a specified role to/from a given user.
|
||||
/// Parameters: grantrole/revokerole (user ID or @_) (role ID)
|
||||
/// </summary>
|
||||
private async Task RP_GrantRevokeRole(string cmd, RuleConfig r, SocketMessage m)
|
||||
{
|
||||
string[] @in = SplitParams(cmd);
|
||||
if (@in.Length != 3)
|
||||
{
|
||||
await Log($"Error: {@in[0]}: incorrect number of parameters.");
|
||||
return;
|
||||
}
|
||||
if (!ulong.TryParse(@in[2], out var roleID))
|
||||
{
|
||||
await Log($"Error: {@in[0]}: Invalid role ID specified.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Finding role
|
||||
var gu = (SocketGuildUser)m.Author;
|
||||
SocketRole rl = gu.Guild.GetRole(roleID);
|
||||
if (rl == null)
|
||||
{
|
||||
await Log($"Error: {@in[0]}: Specified role not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Finding user
|
||||
SocketGuildUser target;
|
||||
if (@in[1] == "@_")
|
||||
{
|
||||
target = gu;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!ulong.TryParse(@in[1], out var userID))
|
||||
{
|
||||
await Log($"Error: {@in[0]}: Invalid user ID specified.");
|
||||
return;
|
||||
}
|
||||
target = gu.Guild.GetUser(userID);
|
||||
if (target == null)
|
||||
{
|
||||
await Log($"Error: {@in[0]}: Given user ID does not exist in this server.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (@in[0].ToLower() == "grantrole")
|
||||
{
|
||||
await target.AddRoleAsync(rl);
|
||||
}
|
||||
else
|
||||
{
|
||||
await target.RemoveRoleAsync(rl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -1,147 +0,0 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
using Noikoio.RegexBot.ConfigItem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Noikoio.RegexBot.Feature.RegexResponder
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents configuration for a single rule.
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerDisplay("Rule: {DisplayName}")]
|
||||
internal struct RuleConfig
|
||||
{
|
||||
private string _displayName;
|
||||
private IEnumerable<Regex> _regex;
|
||||
private IEnumerable<string> _responses;
|
||||
private FilterType _filtermode;
|
||||
private EntityList _filterlist;
|
||||
private EntityList _filterexempt;
|
||||
private int? _minLength;
|
||||
private int? _maxLength;
|
||||
private bool _modBypass;
|
||||
private bool _matchEmbeds;
|
||||
|
||||
public string DisplayName => _displayName;
|
||||
public IEnumerable<Regex> Regex => _regex;
|
||||
public IEnumerable<string> Responses => _responses;
|
||||
public FilterType FilterMode => _filtermode;
|
||||
public EntityList FilterList => _filterlist;
|
||||
public EntityList FilterExemptions => _filterexempt;
|
||||
public int? MinLength => _minLength;
|
||||
public int? MaxLength => _maxLength;
|
||||
public bool AllowModBypass => _modBypass;
|
||||
public bool MatchEmbeds => _matchEmbeds;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the JObject for a single rule and retrieves all data for use as a struct.
|
||||
/// </summary>
|
||||
/// <param name="ruleconf">Rule configuration input</param>
|
||||
/// <exception cref="RuleImportException>">
|
||||
/// Thrown when encountering a missing or invalid value.
|
||||
/// </exception>
|
||||
public RuleConfig(JObject ruleconf)
|
||||
{
|
||||
// display name - validation should've been done outside this constructor already
|
||||
_displayName = ruleconf["name"]?.Value<string>();
|
||||
if (_displayName == null)
|
||||
throw new RuleImportException("Display name not defined.");
|
||||
|
||||
// regex options
|
||||
RegexOptions opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||
// TODO consider adding an option to specify Singleline and Multiline matching
|
||||
opts |= RegexOptions.Singleline;
|
||||
// case sensitivity must be explicitly defined, else not case sensitive by default
|
||||
bool? regexci = ruleconf["ignorecase"]?.Value<bool>();
|
||||
opts |= RegexOptions.IgnoreCase;
|
||||
if (regexci.HasValue && regexci.Value == false)
|
||||
opts &= ~RegexOptions.IgnoreCase;
|
||||
|
||||
// regex
|
||||
const string RegexError = "No regular expression patterns are defined.";
|
||||
var regexes = new List<Regex>();
|
||||
var rxconf = ruleconf["regex"];
|
||||
if (rxconf == null)
|
||||
{
|
||||
throw new RuleImportException(RegexError);
|
||||
}
|
||||
if (rxconf.Type == JTokenType.Array)
|
||||
{
|
||||
foreach (var input in rxconf.Values<string>())
|
||||
{
|
||||
try
|
||||
{
|
||||
Regex r = new Regex(input, opts);
|
||||
regexes.Add(r);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new RuleImportException("Failed to parse regular expression pattern: " + input);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string rxstr = rxconf.Value<string>();
|
||||
try
|
||||
{
|
||||
var rxx = new Regex(rxstr, opts);
|
||||
regexes.Add(rxx);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
throw new RuleImportException("Failed to parse regular expression pattern: " + rxstr);
|
||||
}
|
||||
}
|
||||
if (regexes.Count == 0)
|
||||
{
|
||||
throw new RuleImportException(RegexError);
|
||||
}
|
||||
_regex = regexes.ToArray();
|
||||
|
||||
// min/max length
|
||||
try
|
||||
{
|
||||
_minLength = ruleconf["min"]?.Value<int>();
|
||||
_maxLength = ruleconf["max"]?.Value<int>();
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new RuleImportException("Minimum/maximum values must be an integer.");
|
||||
}
|
||||
|
||||
// responses
|
||||
const string ResponseError = "No responses have been defined for this rule.";
|
||||
var responses = new List<string>();
|
||||
var rsconf = ruleconf["response"];
|
||||
if (rsconf == null)
|
||||
{
|
||||
throw new RuleImportException(ResponseError);
|
||||
}
|
||||
if (rsconf.Type == JTokenType.Array)
|
||||
{
|
||||
foreach (var input in rsconf.Values<string>()) responses.Add(input);
|
||||
}
|
||||
else
|
||||
{
|
||||
responses.Add(rsconf.Value<string>());
|
||||
}
|
||||
// TODO a bit of response validation here. at least check for blanks or something.
|
||||
_responses = responses.ToArray();
|
||||
|
||||
// (white|black)list filtering
|
||||
(_filtermode, _filterlist) = EntityList.GetFilterList(ruleconf);
|
||||
// filtering exemptions
|
||||
_filterexempt = new EntityList(ruleconf["exempt"]);
|
||||
|
||||
// moderator bypass toggle - true by default, must be explicitly set to false
|
||||
bool? modoverride = ruleconf["AllowModBypass"]?.Value<bool>();
|
||||
_modBypass = modoverride.HasValue ? modoverride.Value : true;
|
||||
|
||||
// embed matching mode
|
||||
bool? embedmode = ruleconf["MatchEmbeds"]?.Value<bool>();
|
||||
_matchEmbeds = (embedmode.HasValue && embedmode == true);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -44,8 +44,9 @@ namespace Noikoio.RegexBot
|
|||
// Initialize features
|
||||
_features = new BotFeature[]
|
||||
{
|
||||
new Feature.RegexResponder.EventProcessor(_client),
|
||||
new Feature.ModTools.CommandListener(_client)
|
||||
new Feature.AutoMod.AutoMod(_client),
|
||||
new Feature.ModTools.CommandListener(_client),
|
||||
new Feature.AutoRespond.AutoRespond(_client)
|
||||
};
|
||||
var dlog = Logger.GetLogger("Discord.Net");
|
||||
_client.Log += async (arg) =>
|
||||
|
@ -54,7 +55,8 @@ namespace Noikoio.RegexBot
|
|||
arg.Message));
|
||||
|
||||
// With features initialized, finish loading configuration
|
||||
if (!_config.ReloadServerConfig().GetAwaiter().GetResult())
|
||||
var conf = _config.ReloadServerConfig().Result;
|
||||
if (conf == false)
|
||||
{
|
||||
Console.WriteLine("Failed to load server configuration.");
|
||||
#if DEBUG
|
||||
|
|
Loading…
Reference in a new issue