diff --git a/RegexBot-Modules/AutoResponder/AutoResponder.cs b/RegexBot-Modules/AutoResponder/AutoResponder.cs index 42ad5a2..c5993d6 100644 --- a/RegexBot-Modules/AutoResponder/AutoResponder.cs +++ b/RegexBot-Modules/AutoResponder/AutoResponder.cs @@ -13,6 +13,22 @@ public class AutoResponder : RegexbotModule { DiscordClient.MessageReceived += DiscordClient_MessageReceived; } + public override Task CreateGuildStateAsync(ulong guildID, JToken config) { + if (config == null) return Task.FromResult(null); + var defs = new List(); + + if (config.Type != JTokenType.Array) + throw new ModuleLoadException(Name + " configuration must be a JSON array."); + + // TODO better error reporting during this process + foreach (var def in config.Children()) + defs.Add(new Definition(def)); + + if (defs.Count == 0) return Task.FromResult(null); + Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s)."); + return Task.FromResult(defs.AsReadOnly()); + } + private async Task DiscordClient_MessageReceived(SocketMessage arg) { if (!Common.Misc.IsValidUserMessage(arg, out var ch)) return; @@ -27,20 +43,6 @@ public class AutoResponder : RegexbotModule { await Task.WhenAll(tasks); } - public override Task CreateGuildStateAsync(ulong guild, JToken config) { - // Guild state is a read-only IEnumerable - if (config == null) return Task.FromResult(null); - var guildDefs = new List(); - foreach (var defconf in config.Children()) { - // Validation of data is left to the Definition constructor - var def = new Definition(defconf); // ModuleLoadException may be thrown here - guildDefs.Add(def); - // TODO global options - } - - return Task.FromResult(guildDefs.AsReadOnly()); - } - private async Task ProcessMessageAsync(SocketMessage msg, Definition def) { if (!def.Match(msg)) return; diff --git a/RegexBot-Modules/AutoResponder/Definition.cs b/RegexBot-Modules/AutoResponder/Definition.cs index 5fd91f0..30959e7 100644 --- a/RegexBot-Modules/AutoResponder/Definition.cs +++ b/RegexBot-Modules/AutoResponder/Definition.cs @@ -11,7 +11,7 @@ class Definition { public string Label { get; } public IEnumerable Regex { get; } - public IReadOnlyList Response { get; } + public IReadOnlyList Reply { get; } public string? Command { get; } public FilterList Filter { get; } public RateLimit RateLimit { get; } @@ -20,105 +20,102 @@ class Definition { /// /// Creates an instance based on JSON configuration. /// - public Definition(JProperty incoming) { - Label = incoming.Name; - if (incoming.Value.Type != JTokenType.Object) - throw new ModuleLoadException($"Value of {nameof(AutoResponder)} definition must be a JSON object."); - var data = (JObject)incoming.Value; + public Definition(JObject def) { + Label = def[nameof(Label)]?.Value() + ?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}."); - // error message postfix - var errpofx = $" in AutoRespond definition '{Label}'."; + var errpostfx = $" in the rule definition for '{Label}'."; - // Parse regex - const RegexOptions rxopts = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline; - var regexes = new List(); - var rxconf = data[nameof(Regex)]; - // Accepting either array or single string - // TODO this code could be moved into a helper method somehow - if (rxconf?.Type == JTokenType.Array) { + // Regex + var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant; + // TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline. + // Reminder: in Singleline mode, all contents are subject to the same regex (useful if e.g. spammer separates words line by line) + opts |= RegexOptions.Singleline; + // IgnoreCase is enabled by default; must be explicitly set to false + if (def["IgnoreCase"]?.Value() ?? true) opts |= RegexOptions.IgnoreCase; + + const string ErrNoRegex = $"No patterns were defined under {nameof(Regex)}"; + var regexRules = new List(); + var rxconf = def[nameof(Regex)]; + if (rxconf == null) throw new ModuleLoadException(ErrNoRegex + errpostfx); + if (rxconf.Type == JTokenType.Array) { foreach (var input in rxconf.Values()) { try { - var r = new Regex(input!, rxopts); - regexes.Add(r); - } catch (ArgumentException) { - throw new ModuleLoadException($"Failed to parse regular expression pattern '{input}'{errpofx}"); + regexRules.Add(new Regex(input!, opts)); + } catch (Exception ex) when (ex is ArgumentException or NullReferenceException) { + throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx); } } - } else if (rxconf?.Type == JTokenType.String) { - var rxstr = rxconf.Value()!; + } else { + var rxstr = rxconf.Value(); try { - var r = new Regex(rxstr, rxopts); - regexes.Add(r); - } catch (ArgumentException) { - throw new ModuleLoadException($"Failed to parse regular expression pattern '{rxstr}'{errpofx}"); - } - } else { - throw new ModuleLoadException("'Regex' not defined" + errpofx); - } - Regex = regexes.AsReadOnly(); - - bool responseDefined; - - // Get response - // TODO this bit could also go into the same aforementioned helper method - var replyconf = data["reply"]; - if (replyconf?.Type == JTokenType.String) { - var str = replyconf.Value()!; - Response = new List() { str }.AsReadOnly(); - responseDefined = true; - } else if (replyconf?.Type == JTokenType.Array) { - Response = new List(replyconf.Values()!).AsReadOnly(); - responseDefined = true; - } else { - Response = Array.Empty(); - responseDefined = false; - } - // Get command - var commconf = data[nameof(Command)]; - if (commconf != null && responseDefined) { - throw new ModuleLoadException("Cannot have 'Response' and 'Command' defined at the same time" + errpofx); - } - if (!responseDefined) { - if (commconf != null) { - var commstr = commconf.Value(); - if (string.IsNullOrWhiteSpace(commstr)) - throw new ModuleLoadException("'Command' is defined, but value is blank" + errpofx); - Command = commstr; - responseDefined = true; + regexRules.Add(new Regex(rxstr!, opts)); + } catch (Exception ex) when (ex is ArgumentException or NullReferenceException) { + throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx); } } - // Got neither... - if (!responseDefined) throw new ModuleLoadException("Neither 'Response' nor 'Command' were defined" + errpofx); + if (regexRules.Count == 0) { + throw new ModuleLoadException(ErrNoRegex + errpostfx); + } + Regex = regexRules.AsReadOnly(); // Filtering - Filter = new FilterList(data); + Filter = new FilterList(def); + + bool haveResponse; + + // Reply options + var replyConf = def[nameof(Reply)]; + if (replyConf?.Type == JTokenType.String) { + // Single string response + Reply = new List() { replyConf.Value()! }.AsReadOnly(); + haveResponse = true; + } else if (replyConf?.Type == JTokenType.Array) { + // Have multiple responses + Reply = new List(replyConf.Values()!).AsReadOnly(); + haveResponse= true; + } else { + // Have no response + Reply = Array.Empty(); + haveResponse = false; + } + + // Command options + Command = def[nameof(Command)]?.Value()!; + if (Command != null && haveResponse) + throw new ModuleLoadException($"Only one of either '{nameof(Reply)}' or '{nameof(Command)}' may be defined{errpostfx}"); + if (Command != null) { + if (string.IsNullOrWhiteSpace(Command)) + throw new ModuleLoadException($"'{nameof(Command)}' must have a non-blank value{errpostfx}"); + haveResponse = true; + } + + if (!haveResponse) throw new ModuleLoadException($"Neither '{nameof(Reply)}' nor '{nameof(Command)}' were defined{errpostfx}"); // Rate limiting - var rlconf = data[nameof(RateLimit)]; + var rlconf = def[nameof(RateLimit)]; if (rlconf?.Type == JTokenType.Integer) { var rlval = rlconf.Value(); RateLimit = new RateLimit(rlval); } else if (rlconf != null) { - throw new ModuleLoadException("'RateLimit' must be a non-negative integer" + errpofx); + throw new ModuleLoadException($"'{nameof(RateLimit)}' must be a non-negative integer{errpostfx}"); } else { RateLimit = new(0); } - var rlstr = data[nameof(RateLimit)]?.Value(); // Random chance parameter - var randstr = data[nameof(RandomChance)]?.Value(); - double randval; - if (string.IsNullOrWhiteSpace(randstr)) { - randval = double.NaN; + var randconf = def[nameof(RandomChance)]; + if (randconf?.Type == JTokenType.Float) { + RandomChance = randconf.Value(); + if (RandomChance is > 1 or < 0) { + throw new ModuleLoadException($"Random value is invalid (not between 0 and 1){errpostfx}"); + } + } else if (randconf != null) { + throw new ModuleLoadException($"{nameof(RandomChance)} is not correctly defined{errpostfx}"); } else { - if (!double.TryParse(randstr, out randval)) { - throw new ModuleLoadException("Random value is invalid (unable to parse)" + errpofx); - } - if (randval is > 1 or < 0) { - throw new ModuleLoadException("Random value is invalid (not between 0 and 1)" + errpofx); - } + // Default to none if undefined + RandomChance = double.NaN; } - RandomChance = randval; } /// @@ -131,7 +128,7 @@ class Definition { if (Filter.IsFiltered(m, true)) return false; // Match check - bool matchFound = false; + var matchFound = false; foreach (var item in Regex) { if (item.IsMatch(m.Content)) { matchFound = true; @@ -159,7 +156,7 @@ class Definition { /// public string GetResponse() { // TODO feature request: option to show responses in order instead of random - if (Response.Count == 1) return Response[0]; - return Response[Chance.Next(0, Response.Count - 1)]; + if (Reply.Count == 1) return Reply[0]; + return Reply[Chance.Next(0, Reply.Count - 1)]; } }