diff --git a/BotFeature.cs b/BotFeature.cs
index e3fcf08..deb9c57 100644
--- a/BotFeature.cs
+++ b/BotFeature.cs
@@ -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;
}
+
+ ///
+ /// Determines if the given message author or channel is in the server configuration's moderator list.
+ ///
+ 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)
{
diff --git a/ConfigItem/EntityList.cs b/ConfigItem/EntityList.cs
index 76bd0a5..2a3893b 100644
--- a/ConfigItem/EntityList.cs
+++ b/ConfigItem/EntityList.cs
@@ -7,8 +7,6 @@ using System.Linq;
namespace Noikoio.RegexBot.ConfigItem
{
- enum FilterType { None, Whitelist, Blacklist }
-
///
/// Represents a structure in bot configuration that contains a list of
/// channels, roles, and users.
@@ -70,7 +68,7 @@ namespace Noikoio.RegexBot.ConfigItem
///
/// An incoming message.
///
- /// True if the occurred within a channel specified in this list,
+ /// True if ' ' 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.
///
@@ -125,25 +123,5 @@ namespace Noikoio.RegexBot.ConfigItem
// No match.
return false;
}
-
- ///
- /// Helper method for reading whitelist and blacklist filtering lists
- ///
- 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);
- }
}
}
diff --git a/ConfigItem/FilterList.cs b/ConfigItem/FilterList.cs
new file mode 100644
index 0000000..972f78e
--- /dev/null
+++ b/ConfigItem/FilterList.cs
@@ -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 }
+
+ ///
+ /// Represents whitelist/blacklist configuration, including exemptions.
+ ///
+ struct FilterList
+ {
+ FilterType _type;
+ EntityList _filterList;
+ EntityList _exemptions;
+
+ public FilterType FilterMode => _type;
+ public EntityList FilterEntities => _filterList;
+ public EntityList FilterExemptions => _exemptions;
+
+ ///
+ /// Gets the
+ ///
+ ///
+ /// A JSON object which presumably contains an array named "whitelist" or "blacklist",
+ /// and optionally one named "exempt".
+ ///
+ ///
+ /// 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.
+ ///
+ 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
+ }
+ }
+
+ ///
+ /// Determines if the parameters of ' ' are a match with filtering
+ /// rules defined in this instance.
+ ///
+ /// An incoming message.
+ ///
+ /// True if the user or channel specified by ' ' is filtered by
+ /// the configuration defined in this instance.
+ ///
+ 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");
+ }
+ }
+}
diff --git a/Feature/AutoMod/AutoMod.cs b/Feature/AutoMod/AutoMod.cs
new file mode 100644
index 0000000..733cca4
--- /dev/null
+++ b/Feature/AutoMod/AutoMod.cs
@@ -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
+{
+ ///
+ /// Implements per-message regex matching and executes customizable responses.
+ /// The name RegexBot comes from the existence of this feature.
+ ///
+ ///
+ /// Strictly for use as a moderation tool only. Triggers that simply reply to messages
+ /// should be implemented using .
+ ///
+ 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 ProcessConfiguration(JToken configSection)
+ {
+ List rules = new List();
+
+ foreach (var def in configSection.Children())
+ {
+ 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 arg1, SocketMessage arg2, ISocketMessageChannel arg3)
+ => await ReceiveMessage(arg2);
+
+ ///
+ /// Does initial message checking before sending to further processing.
+ ///
+ 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;
+ 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));
+ }
+ }
+
+ ///
+ /// Checks if the incoming message matches the given rule, and executes responses if necessary.
+ ///
+ 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;
+ }
+}
diff --git a/Feature/AutoMod/ConfigItem.cs b/Feature/AutoMod/ConfigItem.cs
new file mode 100644
index 0000000..776dc24
--- /dev/null
+++ b/Feature/AutoMod/ConfigItem.cs
@@ -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
+{
+ ///
+ /// Representation of a single AutoMod rule.
+ /// Data stored within cannot be edited.
+ ///
+ class ConfigItem
+ {
+ readonly AutoMod _instance;
+ readonly string _label;
+ readonly IEnumerable _regex;
+ readonly ICollection _responses;
+ readonly FilterList _filter;
+ readonly int _msgMinLength;
+ readonly int _msgMaxLength;
+ readonly bool _modBypass;
+ readonly bool _embedMode;
+
+ public string Label => _label;
+ public IEnumerable Regex => _regex;
+ public ICollection 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 Logger => _instance.Log;
+
+ ///
+ /// Creates a new Rule instance to represent the given configuration.
+ ///
+ 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();
+ 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();
+ var rxconf = ruleconf["regex"];
+ if (rxconf == null) throw new RuleImportException(NoRegexError + errpfx);
+ if (rxconf.Type == JTokenType.Array)
+ {
+ foreach (var input in rxconf.Values())
+ {
+ 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();
+ 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() ?? -1;
+ _msgMaxLength = ruleconf["max"]?.Value() ?? -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();
+ var rsconf = ruleconf["response"];
+ if (rsconf == null) throw new RuleImportException(NoResponseError + errpfx);
+ try
+ {
+ if (rsconf.Type == JTokenType.Array)
+ {
+ _responses = ResponseBase.ReadConfiguration(this, rsconf.Values());
+ }
+ else
+ {
+ _responses = ResponseBase.ReadConfiguration(this, new string[] { rsconf.Value() });
+ }
+ }
+ 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();
+ _modBypass = bypass.HasValue ? bypass.Value : true;
+
+ // embed matching mode
+ bool? embed = ruleconf["MatchEmbeds"]?.Value();
+ _embedMode = (embed.HasValue && embed == true);
+ }
+
+ ///
+ /// Checks given message to see if it matches this rule's constraints.
+ ///
+ /// If true, the rule's response(s) should be executed.
+ 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 e)
+ {
+ var text = new StringBuilder();
+ foreach (var item in e) text.AppendLine(SerializeEmbed(item));
+ return text.ToString();
+ }
+
+ ///
+ /// Converts an embed to a plain string for easier matching.
+ ///
+ 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}'";
+ }
+}
diff --git a/Feature/AutoMod/ResponseBase.cs b/Feature/AutoMod/ResponseBase.cs
new file mode 100644
index 0000000..67a9f20
--- /dev/null
+++ b/Feature/AutoMod/ResponseBase.cs
@@ -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
+{
+ ///
+ /// Base class for all Response classes.
+ /// Contains helper methods for use by response code.
+ ///
+ [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;
+ }
+ }
+
+ ///
+ /// Deriving constructor should do validation of incoming .
+ ///
+ 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 _commands =
+ new ReadOnlyDictionary(
+ new Dictionary(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 responses)
+ {
+ var result = new List();
+ 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
+ ///
+ /// Receives a string (beginning with @ or #) and returns an object
+ /// suitable for sending out messages
+ ///
+ protected async Task 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
+ }
+}
diff --git a/Feature/AutoMod/Responses/Ban.cs b/Feature/AutoMod/Responses/Ban.cs
new file mode 100644
index 0000000..0cdd066
--- /dev/null
+++ b/Feature/AutoMod/Responses/Ban.cs
@@ -0,0 +1,50 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Bans the invoking user.
+ /// Parameters: ban [days = 0]
+ ///
+ 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
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Kick.cs b/Feature/AutoMod/Responses/Kick.cs
new file mode 100644
index 0000000..48e311a
--- /dev/null
+++ b/Feature/AutoMod/Responses/Kick.cs
@@ -0,0 +1,28 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Kicks the invoking user.
+ /// Takes no parameters.
+ ///
+ 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
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Remove.cs b/Feature/AutoMod/Responses/Remove.cs
new file mode 100644
index 0000000..bc9af6a
--- /dev/null
+++ b/Feature/AutoMod/Responses/Remove.cs
@@ -0,0 +1,23 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Removes the invoking message.
+ /// Takes no parameters.
+ ///
+ 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();
+ }
+}
diff --git a/Feature/AutoMod/Responses/Report.cs b/Feature/AutoMod/Responses/Report.cs
new file mode 100644
index 0000000..c858899
--- /dev/null
+++ b/Feature/AutoMod/Responses/Report.cs
@@ -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
+{
+ ///
+ /// 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)
+ ///
+ 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()
+ });
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/RoleManipulation.cs b/Feature/AutoMod/Responses/RoleManipulation.cs
new file mode 100644
index 0000000..75dab33
--- /dev/null
+++ b/Feature/AutoMod/Responses/RoleManipulation.cs
@@ -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
+{
+ ///
+ /// Manipulates a given user's role.
+ /// Parameters: (command) (target) (role ID)
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Feature/AutoMod/Responses/Say.cs b/Feature/AutoMod/Responses/Say.cs
new file mode 100644
index 0000000..b793e5e
--- /dev/null
+++ b/Feature/AutoMod/Responses/Say.cs
@@ -0,0 +1,45 @@
+using Discord.WebSocket;
+using Noikoio.RegexBot.ConfigItem;
+using System;
+using System.Threading.Tasks;
+
+namespace Noikoio.RegexBot.Feature.AutoMod.Responses
+{
+ ///
+ /// Sends a message to the given target.
+ /// Parameters: say (target) (message)
+ ///
+ 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);
+ }
+ }
+}
diff --git a/Feature/AutoRespond/AutoRespond.cs b/Feature/AutoRespond/AutoRespond.cs
new file mode 100644
index 0000000..2de818c
--- /dev/null
+++ b/Feature/AutoRespond/AutoRespond.cs
@@ -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
+{
+ ///
+ /// Similar to , but lightweight.
+ /// Provides the capability to define autoresponses for fun or informational purposes.
+ ///
+ /// The major differences between this and include:
+ ///
+ /// Does not listen for message edits.
+ /// Moderators are not exempt from any defined triggers.
+ /// Responses are limited to the invoking channel.
+ /// Per-channel rate limiting.
+ ///
+ ///
+ ///
+ 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;
+ if (defs == null) return;
+
+ foreach (var def in defs)
+ await Task.Run(async () => await ProcessMessage(arg, def));
+ }
+
+ [ConfigSection("autoresponses")]
+ public override async Task ProcessConfiguration(JToken configSection)
+ {
+ var responses = new List();
+ foreach (var def in configSection.Children())
+ {
+ // 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;
+ }
+ }
+ }
+ }
+}
diff --git a/Feature/AutoRespond/ConfigItem.cs b/Feature/AutoRespond/ConfigItem.cs
new file mode 100644
index 0000000..06ebc47
--- /dev/null
+++ b/Feature/AutoRespond/ConfigItem.cs
@@ -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
+{
+ ///
+ /// Represents a single autoresponse definition.
+ ///
+ class ConfigItem
+ {
+ public enum ResponseType { None, Exec, Reply }
+
+ string _label;
+ IEnumerable _regex;
+ ResponseType _rtype;
+ string _rbody;
+ private FilterList _filter;
+ private RateLimitCache _limit;
+
+ public string Label => _label;
+ public IEnumerable 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();
+ 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())
+ {
+ 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();
+ 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();
+ if (!string.IsNullOrWhiteSpace(execstr))
+ {
+ _rbody = execstr;
+ _rtype = ResponseType.Exec;
+ }
+
+ // reply response
+ string replystr = data["reply"]?.Value();
+ 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();
+ 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);
+ }
+ }
+ }
+
+ ///
+ /// Checks given message to see if it matches this rule's constraints.
+ ///
+ /// If true, the rule's response(s) should be executed.
+ 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}'";
+ }
+}
diff --git a/Feature/AutoRespond/RateLimitCache.cs b/Feature/AutoRespond/RateLimitCache.cs
new file mode 100644
index 0000000..20e92a7
--- /dev/null
+++ b/Feature/AutoRespond/RateLimitCache.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+
+namespace Noikoio.RegexBot.Feature.AutoRespond
+{
+ ///
+ /// Stores rate limit settings and caches.
+ ///
+ class RateLimitCache
+ {
+ public const ushort DefaultTimeout = 20; // this is Skeeter's fault
+
+ private readonly ushort _timeout;
+ private Dictionary _cache;
+
+ public ushort Timeout => _timeout;
+
+ ///
+ /// Sets up a new instance of .
+ ///
+ public RateLimitCache(ushort timeout)
+ {
+ _timeout = timeout;
+ _cache = new Dictionary();
+ }
+
+ ///
+ /// Checks if a "usage" is allowed for the given value.
+ /// Items added to cache will be removed after the number of seconds specified in .
+ ///
+ /// The ID to add to the cache.
+ /// True on success. False if the given ID already exists.
+ 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();
+ 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;
+ }
+ }
+}
diff --git a/Feature/RegexResponder/EventProcessor.cs b/Feature/RegexResponder/EventProcessor.cs
deleted file mode 100644
index 75d0af2..0000000
--- a/Feature/RegexResponder/EventProcessor.cs
+++ /dev/null
@@ -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
-{
- ///
- /// Implements per-message regex matching and executes customizable responses.
- /// Namesake of this project.
- ///
- 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(
- new Dictionary() {
-#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 arg1, SocketMessage arg2, ISocketMessageChannel arg3)
- => await ReceiveMessage(arg2);
- #endregion
-
- ///
- /// Receives incoming messages and creates tasks to handle them if necessary.
- ///
- 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;
- 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));
- }
-
- ///
- /// 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.
- ///
- 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 ProcessConfiguration(JToken configSection)
- {
- List rules = new List();
- foreach (JObject ruleconf in configSection)
- {
- // Try and get at least the name before passing it to RuleItem
- string name = ruleconf["name"]?.Value();
- 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();
- }
-
- // -------------------------------------
-
- ///
- /// Turns an embed into a single string for regex matching purposes
- ///
- 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("@\\_", "@_");
- }
-
- ///
- /// Receives a string (beginning with @ or #) and returns an object
- /// suitable for sending out messages
- ///
- private async Task 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;
- }
- }
-}
diff --git a/Feature/RegexResponder/Responses.cs b/Feature/RegexResponder/Responses.cs
deleted file mode 100644
index ecd4b3e..0000000
--- a/Feature/RegexResponder/Responses.cs
+++ /dev/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 _commands;
-
-#if DEBUG
- ///
- /// Throws an exception. Meant to be a quick error handling test.
- /// No parameters.
- ///
- private Task RP_Crash(string cmd, RuleConfig r, SocketMessage m)
- {
- throw new Exception("Requested in response.");
- }
-
- ///
- /// 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.
- ///
- 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
- ///
- /// Sends a message to a specified channel.
- /// Parameters: say (channel) (message)
- ///
- 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]);
- }
-
- ///
- /// Reports the incoming message to a given channel.
- /// Parameters: report (channel)
- ///
- 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()
- }));
- }
-
- ///
- /// Deletes the incoming message.
- /// No parameters.
- ///
- private async Task RP_Remove(string cmd, RuleConfig r, SocketMessage m)
- {
- // Parameters are not checked
- await m.DeleteAsync();
- }
-
- ///
- /// Executes an external program and sends standard output to the given channel.
- /// Parameters: exec (channel) (command line)
- ///
- 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);
- }
-
- ///
- /// Bans the sender of the incoming message.
- /// No parameters.
- ///
- // 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);
- }
-
- ///
- /// Grants or revokes a specified role to/from a given user.
- /// Parameters: grantrole/revokerole (user ID or @_) (role ID)
- ///
- 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);
- }
- }
-
-
- }
-}
diff --git a/Feature/RegexResponder/RuleConfig.cs b/Feature/RegexResponder/RuleConfig.cs
deleted file mode 100644
index f263d18..0000000
--- a/Feature/RegexResponder/RuleConfig.cs
+++ /dev/null
@@ -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
-{
- ///
- /// Represents configuration for a single rule.
- ///
- [System.Diagnostics.DebuggerDisplay("Rule: {DisplayName}")]
- internal struct RuleConfig
- {
- private string _displayName;
- private IEnumerable _regex;
- private IEnumerable _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;
- public IEnumerable 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;
-
- ///
- /// Takes the JObject for a single rule and retrieves all data for use as a struct.
- ///
- /// Rule configuration input
- ///
- /// Thrown when encountering a missing or invalid value.
- ///
- public RuleConfig(JObject ruleconf)
- {
- // display name - validation should've been done outside this constructor already
- _displayName = ruleconf["name"]?.Value();
- 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();
- 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();
- var rxconf = ruleconf["regex"];
- if (rxconf == null)
- {
- throw new RuleImportException(RegexError);
- }
- if (rxconf.Type == JTokenType.Array)
- {
- foreach (var input in rxconf.Values())
- {
- 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();
- 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();
- _maxLength = ruleconf["max"]?.Value();
- }
- 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();
- var rsconf = ruleconf["response"];
- if (rsconf == null)
- {
- throw new RuleImportException(ResponseError);
- }
- if (rsconf.Type == JTokenType.Array)
- {
- foreach (var input in rsconf.Values()) responses.Add(input);
- }
- else
- {
- responses.Add(rsconf.Value());
- }
- // 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();
- _modBypass = modoverride.HasValue ? modoverride.Value : true;
-
- // embed matching mode
- bool? embedmode = ruleconf["MatchEmbeds"]?.Value();
- _matchEmbeds = (embedmode.HasValue && embedmode == true);
- }
- }
-}
diff --git a/RegexBot.cs b/RegexBot.cs
index 4efbb7f..b093021 100644
--- a/RegexBot.cs
+++ b/RegexBot.cs
@@ -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