diff --git a/RegexBot-Modules/RegexModerator/ConfDefinition.cs b/RegexBot-Modules/RegexModerator/ConfDefinition.cs index 54563a0..8b8edef 100644 --- a/RegexBot-Modules/RegexModerator/ConfDefinition.cs +++ b/RegexBot-Modules/RegexModerator/ConfDefinition.cs @@ -1,231 +1,126 @@ using Discord; -using Discord.WebSocket; using RegexBot.Common; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Text.RegularExpressions; -namespace RegexBot.Modules.RegexModerator -{ +namespace RegexBot.Modules.RegexModerator; +/// +/// Representation of a single RegexModerator rule for a guild. +/// Data in this class is immutable. Contains various helper methods. +/// +[DebuggerDisplay("RM rule '{Label}'")] +class ConfDefinition { + public string Label { get; } + + // Matching settings + private IEnumerable Regex { get; } + private FilterList Filter { get; } + private bool IgnoreMods { get; } + private bool ScanEmbeds { get; } + + // Response settings + public EntityName? ReportingChannel { get; } + public IReadOnlyList Response { get; } + public int BanPurgeDays { get; } + public bool NotifyChannelOfRemoval { get; } + public bool NotifyUserOfRemoval { get; } + + public ConfDefinition(JObject def) { + Label = def[nameof(Label)]?.Value() + ?? throw new ModuleLoadException($"Encountered a rule without a defined {nameof(Label)}."); + + var errpostfx = $" in the rule definition for '{Label}'."; + + var rptch = def[nameof(ReportingChannel)]?.Value(); + if (rptch != null) { + ReportingChannel = new EntityName(rptch); + if (ReportingChannel.Type != EntityType.Channel) + throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}"); + } + + // Regex loading + 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 ErrBadRegex = "Unable to parse regular expression pattern"; + var regexRules = new List(); + List regexStrings; + try { + regexStrings = Misc.LoadStringOrStringArray(def[nameof(Regex)]); + } catch (ArgumentNullException) { + throw new ModuleLoadException($"No patterns were defined under '{nameof(Regex)}'{errpostfx}"); + } catch (ArgumentException) { + throw new ModuleLoadException($"'{nameof(Regex)}' is not properly defined{errpostfx}"); + } + foreach (var input in regexStrings) { + try { + regexRules.Add(new Regex(input, opts)); + } catch (ArgumentException) { + throw new ModuleLoadException($"{ErrBadRegex}{errpostfx}"); + } + } + Regex = regexRules.AsReadOnly(); + + // Filtering + Filter = new FilterList(def); + + // Misc options + // IgnoreMods is enabled by default; must be explicitly set to false + IgnoreMods = def[nameof(IgnoreMods)]?.Value() ?? true; + ScanEmbeds = def[nameof(ScanEmbeds)]?.Value() ?? false; // false by default + + // Load response(s) and response settings + try { + Response = Misc.LoadStringOrStringArray(def[nameof(Response)]).AsReadOnly(); + } catch (ArgumentNullException) { + throw new ModuleLoadException($"No responses were defined under '{nameof(Response)}'{errpostfx}"); + } catch (ArgumentException) { + throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}"); + } + BanPurgeDays = def[nameof(BanPurgeDays)]?.Value() ?? 0; + NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value() ?? true; + NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value() ?? true; + } + /// - /// Representation of a single RegexModerator rule for a guild. - /// Data in this class is immutable. Contains various helper methods. + /// Checks the given message to determine if it matches this definition's constraints. /// - [DebuggerDisplay("RM rule '{Label}'")] - class ConfDefinition - { - public string Label { get; } + /// True if match. + public bool IsMatch(SocketMessage m, bool senderIsModerator) { + if (Filter.IsFiltered(m, false)) return false; + if (senderIsModerator && IgnoreMods) return false; - // Matching settings - readonly IEnumerable _regex; - readonly FilterList _filter; - readonly bool _ignoreMods; - readonly bool _embedScan; - - // Response settings - public string ReplyInChannel { get; } - public string ReplyInDM { get; } - public EntityName RoleAdd { get; } // keep in mind it's possible to have both add and remove role available at once - public EntityName RoleRemove { get; } - //readonly bool _rRolePersist; // TODO use when feature exists - public EntityName ReportingChannel { get; } - public RegexBot.RemovalType RemoveAction { get; } // ban, kick? - public int BanPurgeDays { get; } - public string RemoveReason { get; } // reason to place into audit log and notification - public bool RemoveAnnounce { get; } // send success/failure message in invoking channel? default: true - public bool RemoveNotifyTarget { get; } // send ban/kick notification to user? - public bool DeleteMessage { get; } - - public ConfDefinition(JObject def) - { - Label = def["Label"].Value(); - if (string.IsNullOrWhiteSpace(Label)) - throw new ModuleLoadException("A rule does not have a label defined."); - - string errpostfx = $" in the rule definition for '{Label}'."; - - // Regex loading - var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant; - // TODO consider adding an option to specify Singleline and Multiline mode. Defaulting to Singleline. - opts |= RegexOptions.Singleline; - // IgnoreCase is enabled by default; must be explicitly set to false - bool? rxci = def["IgnoreCase"]?.Value(); - if (rxci ?? true) opts |= RegexOptions.IgnoreCase; - - const string ErrNoRegex = "Regular expression patterns are not defined"; - var rxs = new List(); - var rxconf = def["Regex"]; - if (rxconf == null) throw new ModuleLoadException(ErrNoRegex + errpostfx); - if (rxconf.Type == JTokenType.Array) - { - foreach (var input in rxconf.Values()) - { - try - { - // TODO HIGH IMPORTANCE: sanitize input regex; don't allow inline editing of options - Regex r = new Regex(input, opts); - rxs.Add(r); - } - catch (ArgumentException) - { - throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx); - } - } - } - else - { - string rxstr = rxconf.Value(); - try - { - Regex r = new Regex(rxstr, opts); - rxs.Add(r); - } - catch (Exception ex) when (ex is ArgumentException || ex is NullReferenceException) - { - throw new ModuleLoadException("Unable to parse regular expression pattern" + errpostfx); - } - } - if (rxs.Count == 0) - { - throw new ModuleLoadException(ErrNoRegex + errpostfx); - } - _regex = rxs.ToArray(); - - // Filtering - _filter = new FilterList(def); - - // Misc options - // IgnoreMods is enabled by default; must be explicitly set to false - bool? bypass = def["IgnoreMods"]?.Value(); - _ignoreMods = bypass ?? true; - - bool? embedScan = def["EmbedScanMode"]?.Value(); - _embedScan = embedScan ?? false; // false by default - - // Response options - var resp = def["Response"] as JObject; - if (resp == null) - throw new ModuleLoadException("Cannot find a valid response section" + errpostfx); - - ReplyInChannel = resp[nameof(ReplyInChannel)]?.Value(); - ReplyInDM = resp[nameof(ReplyInDM)]?.Value(); - - const string ErrRole = "The role value specified is not properly defined as a role"; - // TODO make this error message nicer - var rolestr = resp[nameof(RoleAdd)]?.Value(); - if (!string.IsNullOrWhiteSpace(rolestr)) - { - RoleAdd = new EntityName(rolestr); - if (RoleAdd.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx); - } - else RoleAdd = null; - rolestr = resp[nameof(RoleRemove)]?.Value(); - if (!string.IsNullOrWhiteSpace(rolestr)) - { - RoleRemove = new EntityName(rolestr); - if (RoleRemove.Type != EntityType.Role) throw new ModuleLoadException(ErrRole + errpostfx); - } - else RoleRemove = null; - - //_rRolePersist = resp["RolePersist"]?.Value() ?? false; - - var reportstr = resp[nameof(ReportingChannel)]?.Value(); - if (!string.IsNullOrWhiteSpace(reportstr)) - { - ReportingChannel = new EntityName(reportstr); - if (ReportingChannel.Type != EntityType.Channel) - throw new ModuleLoadException("The reporting channel specified is not properly defined as a channel" + errpostfx); - } - else ReportingChannel = null; - - var removestr = resp[nameof(RemoveAction)]?.Value(); - // accept values ban, kick, none - switch (removestr) - { - case "ban": RemoveAction = RegexBot.RemovalType.Ban; break; - case "kick": RemoveAction = RegexBot.RemovalType.Kick; break; - case "none": RemoveAction = RegexBot.RemovalType.None; break; - default: - if (removestr != null) - throw new ModuleLoadException("RemoveAction is not set to a proper value" + errpostfx); - break; - } - - int? banpurgeint; - try { banpurgeint = resp[nameof(BanPurgeDays)]?.Value(); } - catch (InvalidCastException) { throw new ModuleLoadException("BanPurgeDays must be a numeric value."); } - if (banpurgeint.HasValue) - { - if (banpurgeint > 7 || banpurgeint < 0) - throw new ModuleLoadException("BanPurgeDays must be a value between 0 and 7 inclusive."); - BanPurgeDays = banpurgeint ?? 0; - } - - RemoveReason = resp[nameof(RemoveReason)]?.Value(); - - RemoveAnnounce = resp[nameof(RemoveAnnounce)]?.Value() ?? true; - - RemoveNotifyTarget = resp[nameof(RemoveNotifyTarget)]?.Value() ?? false; - - DeleteMessage = resp[nameof(DeleteMessage)]?.Value() ?? false; + foreach (var regex in Regex) { + if (ScanEmbeds && regex.IsMatch(SerializeEmbed(m.Embeds))) return true; + if (regex.IsMatch(m.Content)) return true; } + return false; + } - /// - /// Checks the given message to determine if it matches this definition's constraints. - /// - /// True if match. - public bool IsMatch(SocketMessage m, bool senderIsModerator) - { - // TODO keep id: true or false? - if (_filter.IsFiltered(m, false)) return false; - if (senderIsModerator && _ignoreMods) return false; - - var matchText = _embedScan ? SerializeEmbed(m.Embeds) : m.Content; - - foreach (var regex in _regex) - { - // TODO enforce maximum execution time - // TODO multi-processing of multiple regexes? - // TODO metrics: temporary tracking of regex execution times - if (regex.IsMatch(matchText)) 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 ?? ""); - + private static string SerializeEmbed(IReadOnlyCollection e) { + static string serialize(Embed e) { + var 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) - { + 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) - { + if (e.Footer.HasValue) { result.AppendLine(e.Footer.Value.Text ?? ""); } return result.ToString(); } + + var text = new StringBuilder(); + foreach (var item in e) text.AppendLine(serialize(item)); + return text.ToString(); } } diff --git a/RegexBot-Modules/RegexModerator/RegexModerator.cs b/RegexBot-Modules/RegexModerator/RegexModerator.cs index 5eab668..1041e27 100644 --- a/RegexBot-Modules/RegexModerator/RegexModerator.cs +++ b/RegexBot-Modules/RegexModerator/RegexModerator.cs @@ -1,90 +1,70 @@ -using Discord.WebSocket; -using Newtonsoft.Json.Linq; -using System.Collections.Generic; -using System.Threading.Tasks; +using Discord; -namespace RegexBot.Modules.RegexModerator -{ +namespace RegexBot.Modules.RegexModerator; +/// +/// The namesake of RegexBot. This module allows users to define pattern-based rules with other constraints. +/// When triggered, one or more actions are executed as defined in its configuration. +/// +[RegexbotModule] +public class RegexModerator : RegexbotModule { + public RegexModerator(RegexbotClient bot) : base(bot) { + DiscordClient.MessageReceived += DiscordClient_MessageReceived; + DiscordClient.MessageUpdated += DiscordClient_MessageUpdated; + } + + 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 ConfDefinition(def)); + + if (defs.Count == 0) return Task.FromResult(null); + Log(DiscordClient.GetGuild(guildID), $"Loaded {defs.Count} definition(s)."); + return Task.FromResult(defs.AsReadOnly()); + } + + private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg); + private Task DiscordClient_MessageUpdated(Cacheable arg1, SocketMessage arg2, ISocketMessageChannel arg3) + => ReceiveIncomingMessage(arg2); + /// - /// The 'star' feature of Kerobot. Users define pattern-based rules with other constraints. - /// When triggered, each rule executes one or more different actions. + /// Does initial message checking before further processing. /// - [RegexbotModule] - public class RegexModerator : ModuleBase - { - public RegexModerator(RegexbotClient bot) : base(bot) - { - DiscordClient.MessageReceived += DiscordClient_MessageReceived; - DiscordClient.MessageUpdated += DiscordClient_MessageUpdated; + private async Task ReceiveIncomingMessage(SocketMessage msg) { + if (!Common.Misc.IsValidUserMessage(msg, out var ch)) return; + + // Get config? + var defs = GetGuildState>(ch.Guild.Id); + if (defs == null) return; + + // Send further processing to thread pool. + // Match checking is a CPU-intensive task, thus very little checking is done here. + var msgProcessingTasks = new List(); + foreach (var item in defs) { + // Need to check sender's moderator status here. Definition can't access mod list. + var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true); + + var match = item.IsMatch(msg, isMod); + msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod))); } + await Task.WhenAll(msgProcessingTasks); + } - public override Task CreateGuildStateAsync(ulong guildID, JToken config) - { - if (config == null) return Task.FromResult(null); - var defs = new List(); + /// + /// Does further message checking and response execution. + /// Invocations of this method are meant to be placed onto a thread separate from the caller. + /// + private async Task ProcessMessage(ConfDefinition def, SocketGuild g, SocketMessage msg, bool isMod) { + if (!def.IsMatch(msg, isMod)) return; - if (config.Type != JTokenType.Array) - throw new ModuleLoadException(this.Name + " configuration must be a JSON array."); + // TODO logging options for match result; handle here? - // TODO better error reporting during this process - foreach (var def in config.Children()) - defs.Add(new ConfDefinition(def)); - - if (defs.Count == 0) return Task.FromResult(null); - return Task.FromResult(defs.AsReadOnly()); - } - - private Task DiscordClient_MessageUpdated(Discord.Cacheable arg1, - SocketMessage arg2, ISocketMessageChannel arg3) - => ReceiveIncomingMessage(arg2); - private Task DiscordClient_MessageReceived(SocketMessage arg) => ReceiveIncomingMessage(arg); - - /// - /// Does initial message checking before further processing. - /// - private async Task ReceiveIncomingMessage(SocketMessage msg) - { - if (msg.Author.Id == 0) - { - // TODO what changed to cause this? this wasn't happening before. - System.Console.WriteLine($"Skip processing of message with empty metadata. Msg ID {msg.Id} - Msg content: {msg.Content} - Embed count: {msg.Embeds.Count}"); - return; - } - - // Ignore non-guild channels - if (!(msg.Channel is SocketGuildChannel ch)) return; - - // Get config? - var defs = GetGuildState>(ch.Guild.Id); - if (defs == null) return; - - // Send further processing to thread pool. - // Match checking is a CPU-intensive task, thus very little checking is done here. - var msgProcessingTasks = new List(); - foreach (var item in defs) - { - // Need to check sender's moderator status here. Definition can't access mod list. - var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true); - - var match = item.IsMatch(msg, isMod); - msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, msg, isMod))); - } - await Task.WhenAll(msgProcessingTasks); - } - - /// - /// Does further message checking and response execution. - /// Invocations of this method are meant to be placed onto a thread separate from the caller. - /// - private async Task ProcessMessage(ConfDefinition def, SocketMessage msg, bool isMod) - { - // Reminder: IsMatch handles matching execution time - if (!def.IsMatch(msg, isMod)) return; - - // TODO logging options for match result; handle here? - - var executor = new ResponseExecutor(def, BotClient); - await executor.Execute(msg); - } + var executor = new ResponseExecutor(def, Bot, msg, (string logLine) => Log(g, logLine)); + await executor.Execute(); } } diff --git a/RegexBot-Modules/RegexModerator/ResponseExecutor.cs b/RegexBot-Modules/RegexModerator/ResponseExecutor.cs index 7690f3d..4d045e2 100644 --- a/RegexBot-Modules/RegexModerator/ResponseExecutor.cs +++ b/RegexBot-Modules/RegexModerator/ResponseExecutor.cs @@ -1,311 +1,261 @@ using Discord; -using Discord.WebSocket; using RegexBot.Common; -using System; -using System.Collections.Generic; using System.Text; -using System.Threading.Tasks; -using static RegexBot.RegexbotClient; -namespace RegexBot.Modules.RegexModerator -{ - /// - /// Helper class to RegexModerator that executes the appropriate actions associated with a triggered rule. - /// - class ResponseExecutor - { - private readonly ConfDefinition _rule; - private readonly RegexbotClient _bot; - private List<(ResponseAction, ResponseExecutionResult)> _results; +namespace RegexBot.Modules.RegexModerator; - public ResponseExecutor(ConfDefinition rule, RegexbotClient bot) - { - _rule = rule; - _bot = bot; - } +/// +/// Transient helper class which handles response interpreting and execution. +/// +class ResponseExecutor { + delegate Task ResponseHandler(string? parameter); - public async Task Execute(SocketMessage msg) - { - var g = ((SocketGuildUser)msg.Author).Guild; - _results = new List<(ResponseAction, ResponseExecutionResult)>(); - var tasks = new List - { - ExecuteAction(DoReplyToChannel, g, msg), - ExecuteAction(DoReplyToInvokerDM, g, msg), - ExecuteAction(DoRoleAdd, g, msg), - ExecuteAction(DoRoleRemove, g, msg), - ExecuteAction(DoBan, g, msg), - ExecuteAction(DoKick, g, msg), - ExecuteAction(DoDelete, g, msg) - // TODO role add/remove: add persistence option - // TODO add note to user log - // TODO add warning to user log + const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue."; + + private readonly ConfDefinition _rule; + private readonly RegexbotClient _bot; + + private readonly SocketGuild _guild; + private readonly SocketGuildUser _user; + private readonly SocketMessage _msg; + + private readonly List<(string, ResponseResult)> _reports; + private Action Log { get; } + + public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action logger) { + _rule = rule; + _bot = bot; + + _msg = msg; + _user = (SocketGuildUser)msg.Author; + _guild = _user.Guild; + + _reports = new(); + Log = logger; + } + + public async Task Execute() { + var reportTarget = _rule.ReportingChannel?.FindChannelIn(_guild, true); + if (_rule.ReportingChannel != null && reportTarget == null) + Log("Could not find target reporting channel."); + + foreach (var line in _rule.Response) { + var item = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries & StringSplitOptions.TrimEntries); + var cmd = item[0]; + var param = item.Length >= 2 ? item[1] : null; + ResponseHandler runLine = cmd.ToLowerInvariant() switch { + "comment" => CmdComment, + "rem" => CmdComment, + "#" => CmdComment, + "ban" => CmdBan, + "delete" => CmdDelete, + "remove" => CmdDelete, + "kick" => CmdKick, + "note" => CmdNote, + "roleadd" => CmdRoleAdd, + "addrole" => CmdRoleAdd, + "roledel" => CmdRoleDel, + "delrole" => CmdRoleDel, + "say" => CmdSay, + "send" => CmdSay, + "reply" => CmdSay, + "timeout" => CmdTimeout, + "mute" => CmdTimeout, + "warn" => CmdWarn, + _ => delegate (string? p) { return Task.FromResult(FromError($"Unknown command '{cmd}'.")); } }; - await Task.WhenAll(tasks); - - // Report can only run after all previous actions have been performed. - await ExecuteAction(DoReport, g, msg); - - // TODO pass any local error messages to guild log - } - - #region Response actions - /* - * For the sake of creating reports and notifying the user of any issues, - * every response method should have a signature that conforms to that of the - * ResponseAction delegate defined here. - * Methods here should attempt to handle their own expected exceptions, and leave the - * extraordinary exceptions for the wrapper to deal with. - * - * Methods may return null, but MUST only do so if they took no action (meaning, they were - * not meant to take any action per the input configuration). Data within each - * ResponseExecutionResult is then used to build a report (if requested) and/or place - * error messages into the guild log. - */ - delegate Task ResponseAction(SocketGuild g, SocketMessage msg); - - const string ForbiddenGenericError = "Failed to perform the action due to a permissions issue."; - - private Task DoBan(SocketGuild g, SocketMessage msg) - { - if (_rule.RemoveAction != RemovalType.Ban) return Task.FromResult(null); - return DoBanOrKick(g, msg, _rule.RemoveAction); - } - private Task DoKick(SocketGuild g, SocketMessage msg) - { - if (_rule.RemoveAction != RemovalType.Kick) return Task.FromResult(null); - return DoBanOrKick(g, msg, _rule.RemoveAction); - } - private async Task DoBanOrKick(SocketGuild g, SocketMessage msg, RemovalType t) - { - var result = await _bot.BanOrKickAsync(t, g, $"Rule '{_rule.Label}'", - msg.Author.Id, _rule.BanPurgeDays, _rule.RemoveReason, _rule.RemoveNotifyTarget); - - string logAnnounce = null; - if (_rule.RemoveAnnounce) - { - try - { - await msg.Channel.SendMessageAsync(result.GetResultString(_bot, g.Id)); - } - catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - logAnnounce = "Could not send " + (t == RemovalType.Ban ? "ban" : "kick") + " announcement to channel " - + "due to a permissions issue."; - } - } - - if (result.ErrorForbidden) - { - return new ResponseExecutionResult(false, ForbiddenGenericError); - } - else if (result.ErrorNotFound) - { - return new ResponseExecutionResult(false, "The target user is no longer in the server."); - } - else return new ResponseExecutionResult(true, logAnnounce); - } - - private async Task DoDelete(SocketGuild g, SocketMessage msg) - { - if (!_rule.DeleteMessage) return null; - try - { - await msg.DeleteAsync(); - return new ResponseExecutionResult(true, null); - } - catch (Discord.Net.HttpException ex) - { - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - return new ResponseExecutionResult(false, ForbiddenGenericError); - } - else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) - { - return new ResponseExecutionResult(false, "The message has already been deleted."); - } - else throw; + + try { + var result = await runLine(param); + _reports.Add((cmd, result)); + } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { + _reports.Add((cmd, FromError(ForbiddenGenericError))); } } - private async Task DoReplyToChannel(SocketGuild g, SocketMessage msg) - { - if (string.IsNullOrWhiteSpace(_rule.ReplyInChannel)) return null; - try - { - await msg.Channel.SendMessageAsync(_rule.ReplyInChannel); - return new ResponseExecutionResult(true, null); - } - catch (Discord.Net.HttpException ex) - { - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - return new ResponseExecutionResult(false, ForbiddenGenericError); - } - else throw; - } - } - - private async Task DoReplyToInvokerDM(SocketGuild g, SocketMessage msg) - { - if (string.IsNullOrWhiteSpace(_rule.ReplyInDM)) return null; - var target = await msg.Author.GetOrCreateDMChannelAsync(); // can this throw an exception? - - try - { - await target.SendMessageAsync(_rule.ReplyInDM); - return new ResponseExecutionResult(true, null); - } - catch (Discord.Net.HttpException ex) - { - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - return new ResponseExecutionResult(false, "The target user is not accepting DMs."); - } - else throw; - } - } - - private Task DoRoleAdd(SocketGuild g, SocketMessage msg) - => RoleManipulationResponse(g, msg, true); - private Task DoRoleRemove(SocketGuild g, SocketMessage msg) - => RoleManipulationResponse(g, msg, false); - private async Task RoleManipulationResponse(SocketGuild g, SocketMessage msg, bool add) - { - EntityName ck; - if (add) - { - if (_rule.RoleAdd == null) return null; - ck = _rule.RoleAdd; - } - else - { - if (_rule.RoleRemove == null) return null; - ck = _rule.RoleRemove; - } - - SocketRole target = ck.FindRoleIn(g, false); - if (target == null) - { - return new ResponseExecutionResult(false, - $"Unable to determine the role to be {(add ? "added" : "removed")}. Does it still exist?"); - } - - try - { - if (add) await ((SocketGuildUser)msg.Author).AddRoleAsync(target); - else await ((SocketGuildUser)msg.Author).RemoveRoleAsync(target); - - return new ResponseExecutionResult(true, null); - } - catch (Discord.Net.HttpException ex) - { - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - return new ResponseExecutionResult(false, ForbiddenGenericError); - } - else throw; - } - } - #endregion - - #region Reporting - private class ResponseExecutionResult - { - public bool Success { get; } - public string Notice { get; } - - public ResponseExecutionResult(bool success, string log) - { - Success = success; - Notice = log; - } - } - - private async Task ExecuteAction(ResponseAction action, SocketGuild g, SocketMessage arg) - { - ResponseExecutionResult result; - try { result = await action(g, arg); } - catch (Exception ex) - { - result = new ResponseExecutionResult(false, - "An unknown error occurred. The bot maintainer has been notified."); - await _bot.InstanceLogAsync(true, nameof(RegexModerator), - "An unexpected error occurred while executing a response. " - + $"Guild: {g.Id} - Rule: '{_rule.Label}' - Exception detail:\n" - + ex.ToString()); - } - - if (result != null) - { - lock (_results) _results.Add((action, result)); - } - } - - private async Task DoReport(SocketGuild g, SocketMessage msg) - { - if (_rule.ReportingChannel == null) return null; - - // Determine channel before anything else - var ch = _rule.ReportingChannel.FindChannelIn(g, true); - if (ch == null) return new ResponseExecutionResult(false, "Unable to find reporting channel."); - + // Handle reporting + if (reportTarget != null) { + // Set up report var rptOutput = new StringBuilder(); - foreach (var (action, result) in _results) // Locking of _results not necessary at this point - { - if (result == null) continue; + foreach (var (action, result) in _reports) { rptOutput.Append(result.Success ? ":white_check_mark:" : ":x:"); - rptOutput.Append(" " + action.Method.Name); - if (result.Notice != null) - rptOutput.Append(" - " + result.Notice); + rptOutput.Append($" `{action}`"); + if (result.LogLine != null) { + rptOutput.Append(": "); + rptOutput.Append(result.LogLine); + } rptOutput.AppendLine(); } - // Report status goes last. It is presumed to succeed. If it fails, the message won't make it anyway. - rptOutput.Append($":white_check_mark: {nameof(DoReport)}"); // We can only afford to show a preview of the message being reported, due to embeds - // being constrained to the same 2000 character limit. + // being constrained to the same 2000 character limit as normal messages. const string TruncateWarning = "**Notice: Full message has been truncated.**\n"; const int TruncateMaxLength = 990; - var invokingLine = msg.Content; - if (invokingLine.Length > TruncateMaxLength) - { - invokingLine = TruncateWarning + invokingLine.Substring(0, TruncateMaxLength - TruncateWarning.Length); + var invokingLine = _msg.Content; + if (invokingLine.Length > TruncateMaxLength) { + invokingLine = string.Concat(TruncateWarning, invokingLine.AsSpan(0, TruncateMaxLength - TruncateWarning.Length)); } - var resultEm = new EmbedBuilder() - { - Color = new Color(0xEDCE00), // TODO configurable later? - - Author = new EmbedAuthorBuilder() - { - Name = $"{msg.Author.Username}#{msg.Author.Discriminator} said:", - IconUrl = msg.Author.GetAvatarUrl() - }, - Description = invokingLine, - - Footer = new EmbedFooterBuilder() { Text = $"Rule: {_rule.Label}" }, - Timestamp = msg.EditedTimestamp ?? msg.Timestamp - }.AddField(new EmbedFieldBuilder() - { - Name = "Actions taken:", - Value = rptOutput.ToString() - }).Build(); - - try - { - await ch.SendMessageAsync(embed: resultEm); - return new ResponseExecutionResult(true, null); - } - catch (Discord.Net.HttpException ex) - { - if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) - { - return new ResponseExecutionResult(false, ForbiddenGenericError); - } - else throw; + var resultEmbed = new EmbedBuilder() + .WithFields( + new EmbedFieldBuilder() { + Name = "Context", + Value = + $"User: {_user.Mention} `{_user.Id}`\n" + + $"Channel: <#{_msg.Channel.Id}> `#{_msg.Channel.Name}`" + }, + new EmbedFieldBuilder() { + Name = "Response status", + Value = rptOutput.ToString() + } + ) + .WithAuthor( + name: $"{_msg.Author.Username}#{_msg.Author.Discriminator} said:", + iconUrl: _msg.Author.GetAvatarUrl(), + url: _msg.GetJumpUrl() + ) + .WithDescription(invokingLine) + .WithFooter( + text: $"Rule: {_rule.Label}", + iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl() + ) + .WithCurrentTimestamp() + .Build(); + try { + await reportTarget.SendMessageAsync(embed: resultEmbed); + } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { + Log("Encountered 403 error when attempting to send report."); } } - #endregion } + + #region Response delegates + private static Task CmdComment(string? parameter) => Task.FromResult(FromSuccess(parameter)); + + private Task CmdBan(string? parameter) => CmdBanKick(RemovalType.Ban, parameter); + private Task CmdKick(string? parameter) => CmdBanKick(RemovalType.Kick, parameter); + private async Task CmdBanKick(RemovalType rt, string? parameter) { + BanKickResult result; + if (rt == RemovalType.Ban) { + result = await _bot.BanAsync(_guild, $"Rule '{_rule.Label}'", _user.Id, + _rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval); + } else { + result = await _bot.KickAsync(_guild, $"Rule '{_rule.Label}'", _user.Id, + parameter, _rule.NotifyUserOfRemoval); + } + if (result.ErrorForbidden) return FromError(ForbiddenGenericError); + if (result.ErrorNotFound) return FromError("The target user is no longer in the server."); + if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot)); + return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM."); + } + + private Task CmdRoleAdd(string? parameter) => CmdRoleManipulation(parameter, true); + private Task CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false); + private async Task CmdRoleManipulation(string? parameter, bool add) { + // parameters: @_, &, reason? + // TODO add persistence option if/when implemented + if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters."); + var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (param.Length < 2) return FromError("Incorrect number of parameters."); + + // Find targets + SocketGuildUser? tuser; + SocketRole? trole; + try { + var userName = new EntityName(param[0]); + if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value); + else { + if (userName.Name == "_") tuser = _user; + else tuser = userName.FindUserIn(_guild); + } + if (tuser == null) return FromError($"Unable to find user '{userName.Name}'."); + var roleName = new EntityName(param[1]); + if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value); + else trole = roleName.FindRoleIn(_guild); + if (trole == null) return FromError($"Unable to find role '{roleName.Name}'."); + } catch (ArgumentException) { + return FromError("User or role were not correctly set in configuration."); + } + + // Do action + var rq = new RequestOptions() { AuditLogReason = $"Rule '{_rule.Label}'" }; + if (param.Length == 3 && !string.IsNullOrWhiteSpace(param[2])) { + rq.AuditLogReason += " - " + param[2]; + } + if (add) await tuser.AddRoleAsync(trole, rq); + else await tuser.RemoveRoleAsync(trole, rq); + return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}."); + } + + private async Task CmdDelete(string? parameter) { + // TODO detailed audit log deletion reason? + if (parameter != null) return FromError("This response does not accept parameters."); + + try { + await _msg.DeleteAsync(new RequestOptions { AuditLogReason = $"Rule {_rule.Label}" }); + return FromSuccess(); + } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) { + return FromError("The message had already been deleted."); + } + } + + private async Task CmdSay(string? parameter) { + // parameters: [#_/@_] message + if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters."); + var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (param.Length != 2) return FromError("Incorrect number of parameters."); + + // Get target + IMessageChannel? targetCh; + EntityName name; + try { + name = new EntityName(param[0]); + } catch (ArgumentException) { + return FromError("Reply target was not correctly set in configuration."); + } + bool isUser; + if (name.Type == EntityType.Channel) { + if (name.Name == "_") targetCh = _msg.Channel; + else targetCh = name.FindChannelIn(_guild); + if (targetCh == null) return FromError($"Unable to find channel '{name.Name}'."); + isUser = false; + } else if (name.Type == EntityType.User) { + if (name.Name == "_") targetCh = await _msg.Author.CreateDMChannelAsync(); + else { + var searchedUser = name.FindUserIn(_guild); + if (searchedUser == null) return FromError($"Unable to find user '{name.Name}'."); + targetCh = await searchedUser.CreateDMChannelAsync(); + } + isUser = true; + } else { + return FromError("Channel or user were not correctly set in configuration."); + } + if (targetCh == null) return FromError("Could not acquire target channel."); + await targetCh.SendMessageAsync(param[1]); + return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}."); + } + + private Task CmdNote(string? parameter) { + #warning Not implemented + return Task.FromResult(FromError("not implemented")); + } + private Task CmdTimeout(string? parameter) { + #warning Not implemented + return Task.FromResult(FromError("not implemented")); + } + private Task CmdWarn(string? parameter) { + #warning Not implemented + return Task.FromResult(FromError("not implemented")); + } + #endregion + + #region Response reporting + private struct ResponseResult { + public bool Success; + public string? LogLine; + } + + private static ResponseResult FromSuccess(string? logLine = null) => new() { Success = true, LogLine = logLine }; + private static ResponseResult FromError(string? logLine = null) => new() { Success = false, LogLine = logLine }; + #endregion } diff --git a/RegexBot/Common/Misc.cs b/RegexBot/Common/Misc.cs index 293c02d..a709e75 100644 --- a/RegexBot/Common/Misc.cs +++ b/RegexBot/Common/Misc.cs @@ -1,5 +1,6 @@ using Discord; using Discord.WebSocket; +using Newtonsoft.Json.Linq; using System.Diagnostics.CodeAnalysis; namespace RegexBot.Common; @@ -20,4 +21,28 @@ public static class Misc { channel = ch; return true; } + + /// + /// Given a JToken, gets all string-based values out of it if the token may be a string + /// or an array of strings. + /// + /// The JSON token to analyze and retrieve strings from. + /// Thrown if the given token is not a string or array containing all strings. + /// Thrown if the given token is null. + public static List LoadStringOrStringArray(JToken? token) { + const string ExNotString = "This token contains a non-string element."; + if (token == null) throw new ArgumentNullException(nameof(token), "The provided token is null."); + var results = new List(); + if (token.Type == JTokenType.String) { + results.Add(token.Value()!); + } else if (token.Type == JTokenType.Array) { + foreach (var entry in token.Values()) { + if (entry.Type != JTokenType.String) throw new ArgumentException(ExNotString, nameof(token)); + results.Add(entry.Value()!); + } + } else { + throw new ArgumentException(ExNotString, nameof(token)); + } + return results; + } }