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