diff --git a/Module/ModTools/CommandBase.cs b/Module/ModTools/CommandBase.cs index d63cdc1..9542ea4 100644 --- a/Module/ModTools/CommandBase.cs +++ b/Module/ModTools/CommandBase.cs @@ -11,13 +11,18 @@ using System.Threading.Tasks; namespace Noikoio.RegexBot.Module.ModTools { + /// + /// Base class for ModTools command. + /// We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime. + /// [DebuggerDisplay("{Label}-type command")] abstract class CommandBase { private readonly ModTools _modtools; private readonly string _label; private readonly string _command; - + + protected ModTools Mt => _modtools; public string Label => _label; public string Command => _command; diff --git a/Module/ModTools/Commands/BanKick.cs b/Module/ModTools/Commands/BanKick.cs index 6e4538c..872b57b 100644 --- a/Module/ModTools/Commands/BanKick.cs +++ b/Module/ModTools/Commands/BanKick.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; namespace Noikoio.RegexBot.Module.ModTools.Commands { - class BanKick : CommandBase { // Ban and kick commands are highly similar in implementation, and thus are handled in a single class. @@ -20,6 +19,11 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands private readonly string _successMsg; private readonly string _notifyMsg; + const string DefaultMsg = "You have been {0} from $s for the following reason:\n$r"; + const string DefaultMsgBanAppend = "\n\nIf the moderators have allowed it, you may petition your ban by" + + " submitting **one** message to the moderation team. To do so, reply to this message with" + + " `!petition [Your message here]`."; + // Configuration: // "forcereason" - boolean; Force a reason to be given. Defaults to false. // "purgedays" - integer; Number of days of target's post history to delete, if banning. @@ -41,8 +45,8 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands if (conf["notifymsg"] == null) { // Message not specified - use default - string act = _mode == CommandMode.Ban ? "banned" : "kicked"; - _notifyMsg = $"You have been {act} from $s for the following reason:\n$r"; + _notifyMsg = string.Format(DefaultMsg, mode == CommandMode.Ban ? "banned" : "kicked"); + if (_mode == CommandMode.Ban) _notifyMsg += DefaultMsgBanAppend; } else { @@ -127,6 +131,9 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands } else notifyfail = true; + // Give target user ability to petition + if (_mode == CommandMode.Ban) Mt.AddPetition(g.Id, targetuid); + // Do the action try { @@ -134,8 +141,12 @@ namespace Noikoio.RegexBot.Module.ModTools.Commands if (reason != null) reasonlog += $" Reason: {reason}"; reasonlog = Uri.EscapeDataString(reasonlog); #warning Remove EscapeDataString call on next Discord.Net update +#if !DEBUG if (_mode == CommandMode.Ban) await g.AddBanAsync(targetuid, _purgeDays, reasonlog); else await targetobj.KickAsync(reason); +#else +#warning "Actual kick/ban action is DISABLED during debug." +#endif string resultmsg = BuildSuccessMessage(targetdisp); if (notifyfail) { diff --git a/Module/ModTools/ConfigItem.cs b/Module/ModTools/ConfigItem.cs new file mode 100644 index 0000000..a135a3f --- /dev/null +++ b/Module/ModTools/ConfigItem.cs @@ -0,0 +1,75 @@ +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Noikoio.RegexBot.Module.ModTools +{ + /// + /// Represents ModTools configuration within one server. + /// + class ConfigItem + { + private EntityName? _petitionReportCh; + private readonly ReadOnlyDictionary _cmdInstances; + + public EntityName? PetitionReportingChannel => _petitionReportCh; + public ReadOnlyDictionary Commands => _cmdInstances; + + public ConfigItem(ModTools instance, JToken inconf) + { + if (inconf.Type != JTokenType.Object) + { + throw new RuleImportException("Configuration for this section is invalid."); + } + var config = (JObject)inconf; + + // Ban petition reporting channel + var petitionstr = config["PetitionRelay"]?.Value(); + if (string.IsNullOrEmpty(petitionstr)) _petitionReportCh = null; + else if (petitionstr.Length > 1 && petitionstr[0] != '#') + { + // Not a channel. + throw new RuleImportException("PetitionRelay value must be set to a channel."); + } + else + { + _petitionReportCh = new EntityName(petitionstr.Substring(1), EntityType.Channel); + } + + // Command instances + var commands = new Dictionary(StringComparer.OrdinalIgnoreCase); + var commandconf = config["Commands"]; + if (commandconf != null) + { + if (commandconf.Type != JTokenType.Object) + { + throw new RuleImportException("CommandDefs is not properly defined."); + } + + foreach (var def in commandconf.Children()) + { + string label = def.Name; + var cmd = CommandBase.CreateInstance(instance, def); + if (commands.ContainsKey(cmd.Command)) + throw new RuleImportException( + $"{label}: 'command' value must not be equal to that of another definition. " + + $"Given value is being used for {commands[cmd.Command].Label}."); + + commands.Add(cmd.Command, cmd); + } + } + _cmdInstances = new ReadOnlyDictionary(commands); + } + + public void UpdatePetitionChannel(ulong id) + { + if (!PetitionReportingChannel.HasValue) return; + if (PetitionReportingChannel.Value.Id.HasValue) return; // nothing to update + + // For lack of a better option - create a new EntityName with ID already provided + _petitionReportCh = new EntityName($"{id}::{PetitionReportingChannel.Value.Name}", EntityType.Channel); + } + } +} diff --git a/Module/ModTools/ModTools.cs b/Module/ModTools/ModTools.cs index 1c1b17e..ff62bae 100644 --- a/Module/ModTools/ModTools.cs +++ b/Module/ModTools/ModTools.cs @@ -1,19 +1,18 @@ -using Discord.WebSocket; +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.Linq; using System.Threading.Tasks; namespace Noikoio.RegexBot.Module.ModTools { /// - /// ModTools module object. - /// Implements moderation commands that are individually defined and enabled in configuration. + /// ModTools module. + /// This class manages reading configuration and creating instances based on it. /// - // We are not using Discord.Net's Commands extension, as it does not allow for changes during runtime. class ModTools : BotModule { public override string Name => "ModTools"; @@ -25,12 +24,35 @@ namespace Noikoio.RegexBot.Module.ModTools private async Task Client_MessageReceived(SocketMessage arg) { - // Ignore bots + // Always ignore bots if (arg.Author.IsBot) return; - // Disregard if not in a guild - SocketGuild g = (arg.Author as SocketGuildUser)?.Guild; - if (g == null) return; + if (arg.Channel is IDMChannel) await PetitionRelayCheck(arg); + else if (arg.Channel is IGuildChannel) await CommandCheckInvoke(arg); + } + + [ConfigSection("modtools")] + public override async Task ProcessConfiguration(JToken configSection) + { + // Constructor throws exception on config errors + var conf = new ConfigItem(this, configSection); + + // Log results + if (conf.Commands.Count > 0) + await Log(conf.Commands.Count + " command definition(s) loaded."); + if (conf.PetitionReportingChannel.HasValue) + await Log("Ban petitioning has been enabled."); + + return conf; + } + + private new ConfigItem GetConfig(ulong guildId) => (ConfigItem)base.GetConfig(guildId); + + public new Task Log(string text) => base.Log(text); + + private async Task CommandCheckInvoke(SocketMessage arg) + { + SocketGuild g = ((SocketGuildUser)arg.Author).Guild; // Get guild config ServerConfig sc = RegexBot.Config.Servers.FirstOrDefault(s => s.Id == g.Id); @@ -42,50 +64,152 @@ namespace Noikoio.RegexBot.Module.ModTools // Disregard if the message contains a newline character if (arg.Content.Contains("\n")) return; - // Check for and invoke command... + // Check for and invoke command string cmdchk; int spc = arg.Content.IndexOf(' '); if (spc != -1) cmdchk = arg.Content.Substring(0, spc); else cmdchk = arg.Content; - if (((IDictionary)GetConfig(g.Id)).TryGetValue(cmdchk, out var c)) + if (GetConfig(g.Id).Commands.TryGetValue(cmdchk, out var c)) { - // ...on the thread pool. - await Task.Run(async () => + try { - try + await Log($"'{c.Label}' invoked by {arg.Author.ToString()} in {g.Name}/#{arg.Channel.Name}"); + await c.Invoke(g, arg); + } + catch (Exception ex) + { + await Log($"Encountered an error for the command '{c.Label}'. Details follow:"); + await Log(ex.ToString()); + } + } + } + + #region Ban petitions + /// + /// List of available appeals. Key is user (for quick lookup). Value is guild (for quick config resolution). + /// TODO expiration? + /// + private Dictionary _openPetitions = new Dictionary(); + public void AddPetition(ulong guild, ulong user) + { + // Do nothing if disabled + if (!GetConfig(guild).PetitionReportingChannel.HasValue) return; + lock (_openPetitions) _openPetitions[user] = guild; + } + private async Task PetitionRelayCheck(SocketMessage msg) + { + const string PetitionAccepted = "Your petition has been forwarded to the moderators for review."; + const string PetitionDenied = "You may not submit a ban petition."; + + // It's possible the sender may still block messages sent to them, + // hence the empty catch blocks you'll see up ahead. + + if (!msg.Content.StartsWith("!petition ", StringComparison.InvariantCultureIgnoreCase)) return; + + // Input validation + string ptext = msg.Content.Substring(10); + if (string.IsNullOrWhiteSpace(ptext)) + { + // Just ignore. + return; + } + if (ptext.Length > 1000) + { + // Enforce petition length limit. + try { await msg.Author.SendMessageAsync("Your petition message is too long. Try again with a shorter message."); } + catch (Discord.Net.HttpException) { } + return; + } + + ulong targetGuild = 0; + lock (_openPetitions) + { + if (_openPetitions.TryGetValue(msg.Author.Id, out targetGuild)) + { + _openPetitions.Remove(msg.Author.Id); + } + } + + if (targetGuild == 0) + { + // Not in the list. Nothing to do. + try { await msg.Author.SendMessageAsync(PetitionDenied); } + catch (Discord.Net.HttpException) { } + return; + } + var gObj = Client.GetGuild(targetGuild); + if (gObj == null) + { + // Guild is missing. No longer in guild? + try { await msg.Author.SendMessageAsync(PetitionDenied); } + catch (Discord.Net.HttpException) { } + return; + } + + // Get petition reporting target + var pcv = GetConfig(targetGuild).PetitionReportingChannel; + if (!pcv.HasValue) return; // No target. This should be logically impossible, but... just in case. + var rch = pcv.Value; + ISocketMessageChannel rchObj; + if (!rch.Id.HasValue) + { + rchObj = gObj.TextChannels + .Where(c => c.Name.Equals(rch.Name, StringComparison.InvariantCultureIgnoreCase)) + .FirstOrDefault(); + // Update value if found + if (rchObj != null) + { + GetConfig(targetGuild).UpdatePetitionChannel(rchObj.Id); + } + } + else + { + rchObj = gObj.GetChannel(rch.Id.Value) as ISocketMessageChannel; + } + + if (rchObj == null) + { + // Channel not found. + await Log("Petition reporting channel could not be resolved."); + try { await msg.Author.SendMessageAsync(PetitionDenied); } + catch (Discord.Net.HttpException) { } + return; + } + + // Ready to relay + try + { + await rchObj.SendMessageAsync("", embed: new EmbedBuilder() + { + Color = new Color(0x00FFD9), + + Author = new EmbedAuthorBuilder() { - await Log($"'{c.Label}' invoked by {arg.Author.ToString()} in {g.Name}/#{arg.Channel.Name}"); - await c.Invoke(g, arg); - } - catch (Exception ex) + Name = $"{msg.Author.ToString()} - Ban petition:", + IconUrl = msg.Author.GetAvatarUrl() + }, + Description = ptext, + Timestamp = msg.Timestamp, + + Footer = new EmbedFooterBuilder() { - await Log($"Encountered an error for the command '{c.Label}'. Details follow:"); - await Log(ex.ToString()); + Text = "User ID: " + msg.Author.Id } }); } - } - - [ConfigSection("modtools")] - public override async Task ProcessConfiguration(JToken configSection) - { - var commands = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var def in configSection.Children()) + catch (Discord.Net.HttpException ex) { - string label = def.Name; - var cmd = CommandBase.CreateInstance(this, def); - if (commands.ContainsKey(cmd.Command)) - throw new RuleImportException( - $"{label}: 'command' value must not be equal to that of another definition. " + - $"Given value is being used for {commands[cmd.Command].Label}."); - - commands.Add(cmd.Command, cmd); + await Log("Failed to relay petition message by " + msg.Author.ToString()); + await Log(ex.Message); + // For the user's point of view, fail silently. + try { await msg.Author.SendMessageAsync(PetitionDenied); } + catch (Discord.Net.HttpException) { } } - await Log($"Loaded {commands.Count} command definition(s)."); - return new ReadOnlyDictionary(commands); - } - public new Task Log(string text) => base.Log(text); + // Success. Notify user. + try { await msg.Author.SendMessageAsync(PetitionAccepted); } + catch (Discord.Net.HttpException) { } + } + #endregion } }