From c73bfabc19ca43feba0707d6e75faddfa2ad709c Mon Sep 17 00:00:00 2001 From: Noi Date: Tue, 20 Sep 2022 21:50:33 -0700 Subject: [PATCH] Implement timeouts With use of this feature available within ModCommands and RegexModerator --- Common/Utilities.cs | 29 ++++---- Modules/ModCommands/Commands/BanKick.cs | 22 +++--- Modules/ModCommands/Commands/Timeout.cs | 73 ++++++++++++++++++++ Modules/ModCommands/ModuleConfig.cs | 1 + Modules/RegexModerator/ConfDefinition.cs | 8 +-- Modules/RegexModerator/ResponseExecutor.cs | 37 +++++++--- Services/CommonFunctions/CF_Timeout.cs | 3 +- Services/CommonFunctions/TimeoutSetResult.cs | 6 +- 8 files changed, 135 insertions(+), 44 deletions(-) create mode 100644 Modules/ModCommands/Commands/Timeout.cs diff --git a/Common/Utilities.cs b/Common/Utilities.cs index 012841c..3c17d64 100644 --- a/Common/Utilities.cs +++ b/Common/Utilities.cs @@ -70,11 +70,6 @@ public static class Utilities { /// Builds and returns an embed which displays this log entry. /// public static Embed BuildEmbed(this Data.ModLogEntry entry, RegexbotClient bot) { - var logEmbed = new EmbedBuilder() - .WithTitle("Moderation log entry") - .WithTimestamp(entry.Timestamp) - .WithFooter($"Log ID {entry.LogId}"); - string? issuedDisplay = null; try { var entityTry = new EntityName(entry.IssuedBy, EntityType.User); @@ -86,24 +81,30 @@ public static class Utilities { string targetDisplay; var targetq = bot.EcQueryUser(entry.UserId.ToString()); if (targetq != null) targetDisplay = $"<@{targetq.UserId}> - {targetq.Username}#{targetq.Discriminator} `{targetq.UserId}`"; - else targetDisplay = $"Unknown user with ID `{entry.UserId}`"; + else targetDisplay = $"User with ID `{entry.UserId}`"; + + var logEmbed = new EmbedBuilder() + .WithTitle(Enum.GetName(typeof(ModLogType), entry.LogType) + " logged:") + .WithTimestamp(entry.Timestamp) + .WithFooter($"Log #{entry.LogId}", bot.DiscordClient.CurrentUser.GetAvatarUrl()); // Escaping '#' not necessary here + if (entry.Message != null) { + logEmbed.Description = entry.Message; + } var contextStr = new StringBuilder(); - contextStr.AppendLine($"Log type: {Enum.GetName(typeof(ModLogType), entry.LogType)}"); - contextStr.AppendLine($"Regarding user: {targetDisplay}"); + contextStr.AppendLine($"User: {targetDisplay}"); contextStr.AppendLine($"Logged by: {issuedDisplay}"); logEmbed.AddField(new EmbedFieldBuilder() { Name = "Context", Value = contextStr.ToString() }); - if (entry.Message != null) { - logEmbed.AddField(new EmbedFieldBuilder() { - Name = "Message", - Value = entry.Message - }); - } return logEmbed.Build(); } + + /// + /// Returns a representation of this entity that can be parsed by the constructor. + /// + public static string AsEntityNameString(this IUser entity) => $"@{entity.Id}::{entity.Username}"; } diff --git a/Modules/ModCommands/Commands/BanKick.cs b/Modules/ModCommands/Commands/BanKick.cs index e5c02fb..a5a8842 100644 --- a/Modules/ModCommands/Commands/BanKick.cs +++ b/Modules/ModCommands/Commands/BanKick.cs @@ -13,14 +13,9 @@ class Ban : BanKick { 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(result.GetResultString(Module.Bot)); - } + if (result.OperationSuccess && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); } else { await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } @@ -39,13 +34,12 @@ class Kick : BanKick { } 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)}"); - } + if (result.OperationSuccess && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); + } else { + await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } - await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } } diff --git a/Modules/ModCommands/Commands/Timeout.cs b/Modules/ModCommands/Commands/Timeout.cs new file mode 100644 index 0000000..7b755ab --- /dev/null +++ b/Modules/ModCommands/Commands/Timeout.cs @@ -0,0 +1,73 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +class Timeout : CommandConfig { + protected bool ForceReason { get; } + protected bool SendNotify { get; } + protected string? SuccessMessage { get; } + + // Configuration: + // "ForceReason" - boolean; Force a reason to be given. Defaults to false. + // "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true. + // "SuccessMessage" - string; Additional message to display on command success. + // TODO future configuration ideas: max timeout, min timeout, default timeout span... + public Timeout(ModCommands module, JObject config) : base(module, config) { + ForceReason = config[nameof(ForceReason)]?.Value() ?? false; + SendNotify = config[nameof(SendNotify)]?.Value() ?? true; + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); + + _usage = $"{Command} `user ID or tag` `time in minutes` `" + (ForceReason ? "reason" : "[reason]") + "`\n" + + "Issues a timeout to the given user, preventing them from participating in the server for a set amount of time. " + + (ForceReason ? "L" : "Optionally l") + "ogs the reason for the timeout to the Audit Log."; + } + + private readonly string _usage; + protected override string DefaultUsageMsg => _usage; + + // Usage: (command) (user) (duration) (reason) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 4, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + string? reason; + if (line.Length < 3) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + if (line.Length == 4) reason = line[3]; // Reason given - keep it + else { + // No reason given + if (ForceReason) { + await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**"); + return; + } + reason = null; + } + + if (!int.TryParse(line[2], out var timeParam)) { + await SendUsageMessageAsync(msg.Channel, ":x: You must specify a duration for the timeout (in minutes)."); + return; + } + + // Get target user. Required to find for our purposes. + 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 { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + var targetUser = g.GetUser(targetId); + + var result = await Module.Bot.SetTimeoutAsync(g, msg.Author.AsEntityNameString(), targetUser, + TimeSpan.FromMinutes(timeParam), reason, SendNotify); + if (result.Success && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.ToResultString()}"); + } else { + await msg.Channel.SendMessageAsync(result.ToResultString()); + } + } +} \ No newline at end of file diff --git a/Modules/ModCommands/ModuleConfig.cs b/Modules/ModCommands/ModuleConfig.cs index b8f9fe7..651e710 100644 --- a/Modules/ModCommands/ModuleConfig.cs +++ b/Modules/ModCommands/ModuleConfig.cs @@ -36,6 +36,7 @@ class ModuleConfig { { "note", typeof(Note) }, { "addnote", typeof(Note) }, { "warn", typeof(Warn) }, + { "timeout", typeof(Commands.Timeout) }, { "addrole", typeof(RoleAdd) }, { "roleadd", typeof(RoleAdd) }, { "delrole", typeof(RoleDel) }, diff --git a/Modules/RegexModerator/ConfDefinition.cs b/Modules/RegexModerator/ConfDefinition.cs index 08e05a4..00a0c1c 100644 --- a/Modules/RegexModerator/ConfDefinition.cs +++ b/Modules/RegexModerator/ConfDefinition.cs @@ -23,8 +23,8 @@ class ConfDefinition { public EntityName? ReportingChannel { get; } public IReadOnlyList Response { get; } public int BanPurgeDays { get; } - public bool NotifyChannelOfRemoval { get; } - public bool NotifyUserOfRemoval { get; } + public bool NotifyChannel { get; } + public bool NotifyUser { get; } public ConfDefinition(JObject def) { Label = def[nameof(Label)]?.Value() @@ -83,8 +83,8 @@ class ConfDefinition { 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; + NotifyChannel = def[nameof(NotifyChannel)]?.Value() ?? true; + NotifyUser = def[nameof(NotifyUser)]?.Value() ?? true; } /// diff --git a/Modules/RegexModerator/ResponseExecutor.cs b/Modules/RegexModerator/ResponseExecutor.cs index 1ebeee1..2495f8a 100644 --- a/Modules/RegexModerator/ResponseExecutor.cs +++ b/Modules/RegexModerator/ResponseExecutor.cs @@ -3,13 +3,14 @@ using RegexBot.Common; using System.Text; namespace RegexBot.Modules.RegexModerator; - /// /// Transient helper class which handles response interpreting and execution. /// class ResponseExecutor { private const string ErrParamNeedNone = "This response type does not accept parameters."; private const string ErrParamWrongAmount = "Incorrect number of parameters defined in the response."; + private const string ErrMissingUser = "The target user is no longer in the server."; + delegate Task ResponseHandler(string? parameter); private readonly ConfDefinition _rule; @@ -140,14 +141,14 @@ class ResponseExecutor { BanKickResult result; if (rt == RemovalType.Ban) { result = await _bot.BanAsync(_guild, LogSource, _user.Id, - _rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval); + _rule.BanPurgeDays, parameter, _rule.NotifyUser); } else { result = await _bot.KickAsync(_guild, LogSource, _user.Id, - parameter, _rule.NotifyUserOfRemoval); + parameter, _rule.NotifyUser); } if (result.ErrorForbidden) return FromError(Messages.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)); + if (result.ErrorNotFound) return FromError(ErrMissingUser); + if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot)); return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM."); } @@ -237,10 +238,7 @@ class ResponseExecutor { var log = await _bot.AddUserNoteAsync(_guild, _user.Id, LogSource, parameter); return FromSuccess($"Note \\#{log.LogId} logged for {_user}."); } - private Task CmdTimeout(string? parameter) { - #warning Not implemented - return Task.FromResult(FromError("not implemented")); - } + private async Task CmdWarn(string? parameter) { if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); var (log, result) = await _bot.AddUserWarnAsync(_guild, _user.Id, LogSource, parameter); @@ -248,6 +246,27 @@ class ResponseExecutor { if (result.Success) return FromSuccess(resultMsg); else return FromError(resultMsg + " Failed to send DM."); } + + private async Task CmdTimeout(string? parameter) { + // parameters: (time in minutes) [reason] + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); + var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (param.Length < 1) return FromError(ErrParamWrongAmount); + + if (!int.TryParse(param[0], out var timemin)) { + return FromError($"Couldn't parse '{param[0]}' as amount of time in minutes."); + } + string? reason = null; + if (param.Length == 2) reason = param[1]; + + var result = await _bot.SetTimeoutAsync(_guild, LogSource, _user, + TimeSpan.FromMinutes(timemin), reason, _rule.NotifyUser); + if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError); + if (result.ErrorNotFound) return FromError(ErrMissingUser); + if (result.Error != null) return FromError(result.Error.Message); + if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.ToResultString()); + return FromSuccess(result.Success ? null : "Unable to send notification DM."); + } #endregion #region Response reporting diff --git a/Services/CommonFunctions/CF_Timeout.cs b/Services/CommonFunctions/CF_Timeout.cs index 6068d87..a8527cb 100644 --- a/Services/CommonFunctions/CF_Timeout.cs +++ b/Services/CommonFunctions/CF_Timeout.cs @@ -29,13 +29,14 @@ internal partial class CommonFunctionsService : Service { UserId = (long)target.Id, LogType = ModLogType.Timeout, IssuedBy = source, - Message = reason + Message = $"Duration: {Math.Floor(duration.TotalMinutes)}min{(reason == null ? "." : " - " + reason)}" }; using (var db = new BotDatabaseContext()) { db.Add(entry); await db.SaveChangesAsync(); } // TODO check if this log entry should be propagated now or if (to be implemented) will do it for us later + await BotClient.PushSharedEventAsync(entry); // Until then, we for sure propagate our own bool dmSuccess; // DM notification diff --git a/Services/CommonFunctions/TimeoutSetResult.cs b/Services/CommonFunctions/TimeoutSetResult.cs index 2e4f645..5274a29 100644 --- a/Services/CommonFunctions/TimeoutSetResult.cs +++ b/Services/CommonFunctions/TimeoutSetResult.cs @@ -37,8 +37,10 @@ public class TimeoutSetResult : IOperationResult { return msg; } else { var msg = ":x: Failed to set timeout: "; - if (ErrorNotFound) msg += ": The specified user could not be found."; - else if (ErrorForbidden) msg += ": " + Messages.ForbiddenGenericError; + if (ErrorNotFound) msg += "The specified user could not be found."; + else if (ErrorForbidden) msg += Messages.ForbiddenGenericError; + else if (Error != null) msg += Error.Message; + else msg += "Unknown error."; return msg; } }