Add ModCommands module

This commit is contained in:
Noi 2022-07-18 16:44:31 -07:00
parent 681bb1c345
commit 53e0301edd
8 changed files with 482 additions and 0 deletions

View file

@ -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<bool>() ?? false;
PurgeDays = config[nameof(PurgeDays)]?.Value<int>() ?? 0;
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
_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);
}

View file

@ -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<string>()!;
Command = config[nameof(Command)]!.Value<string>()!;
}
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; }
/// <summary>
/// Sends out the default usage message (<see cref="DefaultUsageMsg"/>) within an embed.
/// An optional message can be included, for uses such as notifying users of incorrect usage.
/// </summary>
/// <param name="target">Target channel for sending the message.</param>
/// <param name="message">The message to send alongside the default usage message.</param>
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());
}
}

View file

@ -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);
}
}

View file

@ -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<string>();
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<string>();
_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);
}

View file

@ -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]);
}
}

View file

@ -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;
}
}
}

View file

@ -0,0 +1,55 @@
namespace RegexBot.Modules.ModCommands;
/// <summary>
/// Provides a way to define highly configurable text-based commands for use by moderators within a guild.
/// </summary>
[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<ModuleConfig>(channel.Guild.Id);
if (cfg != null) await CommandCheckInvoke(cfg, arg);
}
}
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
if (config == null) return Task.FromResult<object?>(null);
var conf = new ModuleConfig(this, config);
if (conf.Commands.Count > 0) {
Log($"{conf.Commands.Count} commands loaded.");
return Task.FromResult<object?>(conf);
} else {
Log("'{nameof(ModLogs)}' is defined, but no command configuration exists.");
return Task.FromResult<object?>(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.");
}
}
}
}

View file

@ -0,0 +1,66 @@
using RegexBot.Modules.ModCommands.Commands;
using System.Collections.ObjectModel;
using System.Reflection;
namespace RegexBot.Modules.ModCommands;
class ModuleConfig {
public ReadOnlyDictionary<string, CommandConfig> 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<string, CommandConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var def in conf.Children<JObject>()) {
string Label;
Label = def[nameof(Label)]?.Value<string>()
?? 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<string, CommandConfig>(commands);
}
private static readonly ReadOnlyDictionary<string, Type> _commandTypes = new(
new Dictionary<string, Type>(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<string>()!;
var command = def[nameof(CommandConfig.Command)]?.Value<string>();
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<string>();
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}");
}
}
}
}