diff --git a/RegexBot-Modules/ModCommands/Commands/BanKick.cs b/RegexBot-Modules/ModCommands/Commands/BanKick.cs new file mode 100644 index 0000000..e8a126f --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/BanKick.cs @@ -0,0 +1,144 @@ +using RegexBot.Data; + +namespace RegexBot.Modules.ModCommands.Commands; +// Ban and kick commands are highly similar in implementation, and thus are handled in a single class. +class Ban : BanKick { + public Ban(ModCommands module, JObject config) : base(module, config, true) { + if (PurgeDays is > 7 or < 0) + throw new ModuleLoadException($"The value of '{nameof(PurgeDays)}' must be between 0 and 7."); + } + + protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason, + ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) { + // Ban: Unlike kick, the minimum required is just the target ID + var result = await Module.Bot.BanAsync(g, msg.Author.ToString(), targetId, PurgeDays, reason, SendNotify); + if (result.OperationSuccess) { + if (SuccessMessage != null) { + // TODO customization + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); + } else { + // TODO custom fail message? + await msg.Channel.SendMessageAsync(SuccessMessage); + } + } else { + await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); + } + } +} + +class Kick : BanKick { + public Kick(ModCommands module, JObject config) : base(module, config, false) { } + + protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason, + ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) { + // Kick: Unlike ban, must find the guild user in order to proceed + if (targetUser == null) { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + + var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify); + if (result.OperationSuccess) { + if (SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); + } + } + await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); + } +} + +abstract class BanKick : CommandConfig { + protected bool ForceReason { get; } + protected int PurgeDays { get; } + protected bool SendNotify { get; } + protected string? SuccessMessage { get; } + + // 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. + // Must be between 0-7 inclusive. Defaults to 0. + // "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true. + // "SuccessMessage" - string; Message to display on command success. Overrides default. + protected BanKick(ModCommands module, JObject config, bool ban) : base(module, config) { + ForceReason = config[nameof(ForceReason)]?.Value() ?? false; + PurgeDays = config[nameof(PurgeDays)]?.Value() ?? 0; + + SendNotify = config[nameof(SendNotify)]?.Value() ?? true; + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); + + _usage = $"{Command} `user or user ID` `" + (ForceReason ? "reason" : "[reason]") + "`\n" + + "Removes the given user from this server" + + (ban ? " and prevents the user from rejoining" : "") + ". " + + (ForceReason ? "L" : "Optionally l") + "ogs the reason for the " + + (ban ? "ban" : "kick") + " to the Audit Log."; + if (PurgeDays > 0) + _usage += $"\nAdditionally removes the user's post history for the last {PurgeDays} day(s)."; + } + + private readonly string _usage; + protected override string DefaultUsageMsg => _usage; + + // Usage: (command) (mention) (reason) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + string? reason; + if (line.Length < 2) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + if (line.Length == 3) reason = line[2]; // Reason given - keep it + else { + // No reason given + if (ForceReason) { + await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**"); + return; + } + reason = null; + } + + // Gather info to send to specific handlers + var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr); + ulong targetId; + if (targetQuery != null) targetId = (ulong)targetQuery.UserId; + else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed; + else targetId = default; + var targetUser = targetId != default ? g.GetUser(targetId) : null; + + if (targetId == default) { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + + if (targetUser != null) { + // Bot check + if (targetUser.IsBot) { + await SendUsageMessageAsync(msg.Channel, ":x: I will not do that. Please remove bots manually."); + return; + } + // Hierarchy check + if (((SocketGuildUser)msg.Author).Hierarchy <= targetUser.Hierarchy) { + // Block kick attempts if the invoking user is at or above the target in role hierarchy + await SendUsageMessageAsync(msg.Channel, ":x: You do not have sufficient permissions to do that."); + return; + } + } + + // Preparation complete, go to specific actions + try { + await ContinueInvoke(g, msg, reason, targetId, targetQuery, targetUser); + } catch (Discord.Net.HttpException ex) { + if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { + await msg.Channel.SendMessageAsync(":x: " + Strings.ForbiddenGenericError); + } else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) { + await msg.Channel.SendMessageAsync(":x: Encountered a 404 error when processing the request."); + } + } + } + + protected abstract Task ContinueInvoke(SocketGuild g, SocketMessage msg, string? reason, + ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser); +} \ No newline at end of file diff --git a/RegexBot-Modules/ModCommands/Commands/CommandConfig.cs b/RegexBot-Modules/ModCommands/Commands/CommandConfig.cs new file mode 100644 index 0000000..bda304c --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/CommandConfig.cs @@ -0,0 +1,39 @@ +using Discord; +using System.Diagnostics; + +namespace RegexBot.Modules.ModCommands.Commands; +[DebuggerDisplay("Command definition '{Label}'")] +abstract class CommandConfig { + public string Label { get; } + public string Command { get; } + protected ModCommands Module { get; } + + protected CommandConfig(ModCommands module, JObject config) { + Module = module; + Label = config[nameof(Label)]!.Value()!; + Command = config[nameof(Command)]!.Value()!; + } + + public abstract Task Invoke(SocketGuild g, SocketMessage msg); + + protected const string FailDefault = "An unknown error occurred. Notify the bot operator."; + protected const string TargetNotFound = ":x: **Unable to find the given user.**"; + + protected abstract string DefaultUsageMsg { get; } + /// + /// Sends out the default usage message () within an embed. + /// An optional message can be included, for uses such as notifying users of incorrect usage. + /// + /// Target channel for sending the message. + /// The message to send alongside the default usage message. + protected async Task SendUsageMessageAsync(ISocketMessageChannel target, string? message = null) { + if (DefaultUsageMsg == null) + throw new InvalidOperationException("DefaultUsage was not defined."); + + var usageEmbed = new EmbedBuilder() { + Title = "Usage", + Description = DefaultUsageMsg + }; + await target.SendMessageAsync(message ?? "", embed: usageEmbed.Build()); + } +} diff --git a/RegexBot-Modules/ModCommands/Commands/ConfReload.cs b/RegexBot-Modules/ModCommands/Commands/ConfReload.cs new file mode 100644 index 0000000..fadbd06 --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/ConfReload.cs @@ -0,0 +1,17 @@ +namespace RegexBot.Modules.ModCommands.Commands; +class ConfReload : CommandConfig { + protected override string DefaultUsageMsg => null!; + + // No configuration. + public ConfReload(ModCommands module, JObject config) : base(module, config) { } + + // Usage: (command) + public override Task Invoke(SocketGuild g, SocketMessage msg) { + throw new NotImplementedException(); + // bool status = await RegexBot.Config.ReloadServerConfig(); + // string res; + // if (status) res = ":white_check_mark: Configuration reloaded with no issues. Check the console to verify."; + // else res = ":x: Reload failed. Check the console."; + // await msg.Channel.SendMessageAsync(res); + } +} \ No newline at end of file diff --git a/RegexBot-Modules/ModCommands/Commands/RoleManipulation.cs b/RegexBot-Modules/ModCommands/Commands/RoleManipulation.cs new file mode 100644 index 0000000..efc75e3 --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/RoleManipulation.cs @@ -0,0 +1,75 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +class RoleAdd : RoleManipulation { + protected override (string, string) String1 => ("Adds", "to"); + protected override string String2 => "set"; + public RoleAdd(ModCommands module, JObject config) : base(module, config) { } + protected override async Task ContinueInvoke(SocketGuildUser target, SocketRole role) => await target.AddRoleAsync(role); +} + +class RoleDel : RoleManipulation { + protected override (string, string) String1 => ("Removes", "from"); + protected override string String2 => "unset"; + public RoleDel(ModCommands module, JObject config) : base(module, config) { } + protected override async Task ContinueInvoke(SocketGuildUser target, SocketRole role) => await target.RemoveRoleAsync(role); +} + +// Role adding and removing is largely the same, and thus are handled in a single class. +abstract class RoleManipulation : CommandConfig { + private readonly string _usage; + + protected EntityName Role { get; } + protected string? SuccessMessage { get; } + protected override string DefaultUsageMsg => _usage; + protected abstract (string, string) String1 { get; } + protected abstract string String2 { get; } + + // Configuration: + // "role" - string; The given role that applies to this command. + // "successmsg" - string; Messages to display on command success. Overrides default. + protected RoleManipulation(ModCommands module, JObject config) : base(module, config) { + var rolestr = config[nameof(Role)]?.Value(); + if (string.IsNullOrWhiteSpace(rolestr)) throw new ModuleLoadException($"'{nameof(Role)}' must be provided."); + Role = new EntityName(rolestr); + if (Role.Type != EntityType.Role) throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role."); + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); + + _usage = $"{Command} `user or user ID`\n" + + string.Format("{0} the '{1}' role {2} the specified user.", + String1.Item1, Role.Name ?? Role.Id.ToString(), String1.Item2); + } + + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + // TODO reason in further parameters? + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + if (line.Length < 2) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + // Retrieve targets + var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr); + var targetUser = targetQuery != null ? g.GetUser((ulong)targetQuery.UserId) : null; + if (targetUser == null) { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + var targetRole = Role.FindRoleIn(g, true); + if (targetRole == null) { + await SendUsageMessageAsync(msg.Channel, ":x: **Failed to determine the specified role for this command.**"); + return; + } + + // Do the specific thing and report back + await ContinueInvoke(targetUser, targetRole); + const string defaultmsg = ":white_check_mark: Successfully {0} role for **$target**."; + var success = SuccessMessage ?? string.Format(defaultmsg, String2); + success = success.Replace("$target", targetUser.Nickname ?? targetUser.Username); + await msg.Channel.SendMessageAsync(success); + } + + protected abstract Task ContinueInvoke(SocketGuildUser target, SocketRole role); +} \ No newline at end of file diff --git a/RegexBot-Modules/ModCommands/Commands/Say.cs b/RegexBot-Modules/ModCommands/Commands/Say.cs new file mode 100644 index 0000000..f35de3d --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/Say.cs @@ -0,0 +1,34 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +class Say : CommandConfig { + private readonly string _usage; + protected override string DefaultUsageMsg => _usage; + + // No configuration at the moment. + // TODO: Whitelist/blacklist - to limit which channels it can "say" into + public Say(ModCommands module, JObject config) : base(module, config) { + _usage = $"{Command} `channel` `message`\n" + + "Displays the given message exactly as specified to the given channel."; + } + + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + if (line.Length <= 1) { + await SendUsageMessageAsync(msg.Channel, ":x: You must specify a channel."); + return; + } + if (line.Length <= 2 || string.IsNullOrWhiteSpace(line[2])) { + await SendUsageMessageAsync(msg.Channel, ":x: You must specify a message."); + return; + } + + var getCh = Utilities.ChannelMention.Match(line[1]); + if (!getCh.Success) { + await SendUsageMessageAsync(msg.Channel, ":x: Unable to find given channel."); + return; + } + var ch = g.GetTextChannel(ulong.Parse(getCh.Groups["snowflake"].Value)); + await ch.SendMessageAsync(line[2]); + } +} diff --git a/RegexBot-Modules/ModCommands/Commands/Unban.cs b/RegexBot-Modules/ModCommands/Commands/Unban.cs new file mode 100644 index 0000000..5fa2568 --- /dev/null +++ b/RegexBot-Modules/ModCommands/Commands/Unban.cs @@ -0,0 +1,52 @@ +namespace RegexBot.Modules.ModCommands.Commands; +class Unban : CommandConfig { + private readonly string _usage; + + protected override string DefaultUsageMsg => _usage; + + // No configuration. + // TODO bring in some options from BanKick. Particularly custom success msg. + // TODO when ModLogs fully implemented, add a reason? + public Unban(ModCommands module, JObject config) : base(module, config) { + _usage = $"{Command} `user or user ID`\n" + + "Unbans the given user, allowing them to rejoin the server."; + } + + // Usage: (command) (user query) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + if (line.Length < 2) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + ulong targetId; + string targetDisplay; + var query = Module.Bot.EcQueryUser(targetstr); + if (query != null) { + targetId = (ulong)query.UserId; + targetDisplay = $"{query.Username}#{query.Discriminator}"; + } else { + if (!ulong.TryParse(targetstr, out targetId)) { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + targetDisplay = $"with ID {targetId}"; + } + + // Do the action + try { + await g.RemoveBanAsync(targetId); + await msg.Channel.SendMessageAsync($":white_check_mark: Unbanned user **{targetDisplay}**."); + } catch (Discord.Net.HttpException ex) { + const string FailPrefix = ":x: **Could not unban:** "; + if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) + await msg.Channel.SendMessageAsync(FailPrefix + Strings.ForbiddenGenericError); + else if (ex.HttpCode == System.Net.HttpStatusCode.NotFound) + await msg.Channel.SendMessageAsync(FailPrefix + "The specified user does not exist or is not in the ban list."); + else throw; + } + } +} \ No newline at end of file diff --git a/RegexBot-Modules/ModCommands/ModCommands.cs b/RegexBot-Modules/ModCommands/ModCommands.cs new file mode 100644 index 0000000..65fbffc --- /dev/null +++ b/RegexBot-Modules/ModCommands/ModCommands.cs @@ -0,0 +1,55 @@ +namespace RegexBot.Modules.ModCommands; +/// +/// Provides a way to define highly configurable text-based commands for use by moderators within a guild. +/// +[RegexbotModule] +public class ModCommands : RegexbotModule { + public ModCommands(RegexbotClient bot) : base(bot) { + DiscordClient.MessageReceived += Client_MessageReceived; + } + + private async Task Client_MessageReceived(SocketMessage arg) { + if (Common.Utilities.IsValidUserMessage(arg, out var channel)) { + var cfg = GetGuildState(channel.Guild.Id); + if (cfg != null) await CommandCheckInvoke(cfg, arg); + } + } + + public override Task CreateGuildStateAsync(ulong guildID, JToken config) { + if (config == null) return Task.FromResult(null); + + var conf = new ModuleConfig(this, config); + if (conf.Commands.Count > 0) { + Log($"{conf.Commands.Count} commands loaded."); + return Task.FromResult(conf); + } else { + Log("'{nameof(ModLogs)}' is defined, but no command configuration exists."); + return Task.FromResult(null); + } + } + + private async Task CommandCheckInvoke(ModuleConfig cfg, SocketMessage arg) { + SocketGuild g = ((SocketGuildUser)arg.Author).Guild; + + if (!GetModerators(g.Id).IsListMatch(arg, true)) return; // Mods only + // Disregard if the message contains a newline character + if (arg.Content.Contains('\n')) return; // TODO remove? + + // Check for and invoke command + string cmdchk; + var space = arg.Content.IndexOf(' '); + if (space != -1) cmdchk = arg.Content[..space]; + else cmdchk = arg.Content; + if (cfg.Commands.TryGetValue(cmdchk, out var c)) { + try { + await c.Invoke(g, arg); + // TODO Custom post-invocation log messages? Not by the user, but by the command. + Log($"[{g.Name}] {c.Command} invoked by {arg.Author} in #{arg.Channel.Name}."); + } catch (Exception ex) { + Log($"Unhandled exception while processing '{c.Label}':\n" + ex.ToString()); + await arg.Channel.SendMessageAsync($":x: An error occurred during processing ({ex.GetType().FullName}). " + + "Check the console for details."); + } + } + } +} diff --git a/RegexBot-Modules/ModCommands/ModuleConfig.cs b/RegexBot-Modules/ModCommands/ModuleConfig.cs new file mode 100644 index 0000000..ca0ac3f --- /dev/null +++ b/RegexBot-Modules/ModCommands/ModuleConfig.cs @@ -0,0 +1,66 @@ +using RegexBot.Modules.ModCommands.Commands; +using System.Collections.ObjectModel; +using System.Reflection; + +namespace RegexBot.Modules.ModCommands; +class ModuleConfig { + public ReadOnlyDictionary Commands { get; } + + public ModuleConfig(ModCommands instance, JToken conf) { + if (conf.Type != JTokenType.Array) + throw new ModuleLoadException("Command definitions must be defined as objects in a JSON array."); + + // Command instance creation + var commands = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var def in conf.Children()) { + string Label; + Label = def[nameof(Label)]?.Value() + ?? throw new ModuleLoadException($"'{nameof(Label)}' was not defined in a command definition."); + var cmd = CreateCommandInstance(instance, def); + if (commands.ContainsKey(cmd.Command)) { + throw new ModuleLoadException( + $"{Label}: The command name '{cmd.Command}' is already in use by '{commands[cmd.Command].Label}'."); + } + commands.Add(cmd.Command, cmd); + } + Commands = new ReadOnlyDictionary(commands); + } + + private static readonly ReadOnlyDictionary _commandTypes = new( + new Dictionary(StringComparer.OrdinalIgnoreCase) { + { "ban", typeof(Ban) }, + { "confreload", typeof(ConfReload) }, + { "kick", typeof(Kick) }, + { "say", typeof(Say) }, + { "unban", typeof(Unban) }, + { "addrole", typeof(RoleAdd) }, + { "roleadd", typeof(RoleAdd) }, + { "delrole", typeof(RoleDel) }, + { "roledel", typeof(RoleDel) } + } + ); + + private static CommandConfig CreateCommandInstance(ModCommands instance, JObject def) { + var label = def[nameof(CommandConfig.Label)]?.Value()!; + + var command = def[nameof(CommandConfig.Command)]?.Value(); + if (string.IsNullOrWhiteSpace(command)) + throw new ModuleLoadException($"{label}: '{nameof(CommandConfig.Command)}' was not specified."); + if (command.Contains(' ')) + throw new ModuleLoadException($"{label}: '{nameof(CommandConfig.Command)}' must not contain spaces."); + + string? Type; + Type = def[nameof(Type)]?.Value(); + if (string.IsNullOrWhiteSpace(Type)) + throw new ModuleLoadException($"'{nameof(Type)}' must be specified within definition for '{label}'."); + if (!_commandTypes.TryGetValue(Type, out Type? cmdType)) { + throw new ModuleLoadException($"{label}: '{nameof(Type)}' does not have a valid value."); + } else { + try { + return (CommandConfig)Activator.CreateInstance(cmdType, instance, def)!; + } catch (TargetInvocationException ex) when (ex.InnerException is ModuleLoadException) { + throw new ModuleLoadException($"{label}: {ex.InnerException.Message}"); + } + } + } +}