diff --git a/Services/CommonFunctions/CF_Timeout.Hooks.cs b/Services/CommonFunctions/CF_Timeout.Hooks.cs new file mode 100644 index 0000000..7188d47 --- /dev/null +++ b/Services/CommonFunctions/CF_Timeout.Hooks.cs @@ -0,0 +1,18 @@ +namespace RegexBot; +partial class RegexbotClient { + /// + /// Sets a timeout on a user while also adding an entry to the moderation log and attempting to notify the user. + /// + /// The guild which the target user is associated. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The user to be issued a timeout. + /// The duration of the timeout. + /// Reason for the action. Sent to the Audit Log and user (if specified). + /// Specify whether to send a direct message to the target user informing them of the action. + public Task SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target, + TimeSpan duration, string? reason, bool sendNotificationDM) + => _svcCommonFunctions.SetTimeoutAsync(guild, source, target, duration, reason, sendNotificationDM); +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_Timeout.cs b/Services/CommonFunctions/CF_Timeout.cs new file mode 100644 index 0000000..6068d87 --- /dev/null +++ b/Services/CommonFunctions/CF_Timeout.cs @@ -0,0 +1,65 @@ +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Discord.Net; +using RegexBot.Data; + +namespace RegexBot.Services.CommonFunctions; +internal partial class CommonFunctionsService : Service { + // Hooked + internal async Task SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target, + TimeSpan duration, string? reason, bool sendNotificationDM) { + if (duration < TimeSpan.FromMinutes(1)) + return new TimeoutSetResult(new ArgumentOutOfRangeException( + nameof(duration), "Cannot set a timeout with a duration less than 60 seconds."), true, target); + if (duration > TimeSpan.FromDays(28)) + return new TimeoutSetResult(new ArgumentOutOfRangeException( + nameof(duration), "Cannot set a timeout with a duration greater than 28 days."), true, target); + if (target.TimedOutUntil != null && DateTimeOffset.UtcNow < target.TimedOutUntil) + return new TimeoutSetResult(new InvalidOperationException( + "Cannot set a timeout. The user is already timed out."), true, target); + + Discord.RequestOptions? audit = null; + if (reason != null) audit = new() { AuditLogReason = reason }; + try { + await target.SetTimeOutAsync(duration, audit); + } catch (HttpException ex) { + return new TimeoutSetResult(ex, false, target); + } + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)target.Id, + LogType = ModLogType.Timeout, + IssuedBy = source, + Message = 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 + + bool dmSuccess; + // DM notification + if (sendNotificationDM) { + dmSuccess = await SendUserTimeoutNoticeAsync(target, duration, reason); + } else dmSuccess = true; + + return new TimeoutSetResult(null, dmSuccess, target); + } + + internal async Task SendUserTimeoutNoticeAsync(SocketGuildUser target, TimeSpan duration, string? reason) { + // you have been issued a timeout in x. + // the timeout will expire on + const string DMTemplate1 = "You have been issued a timeout in {0}"; + const string DMTemplateReason = " for the following reason:\n{2}"; + const string DMTemplate2 = "\nThe timeout will expire on ()."; + + var expireTime = (DateTimeOffset.UtcNow + duration).ToUnixTimeSeconds(); + var outMessage = string.IsNullOrWhiteSpace(reason) + ? string.Format($"{DMTemplate1}.{DMTemplate2}", target.Guild.Name, expireTime) + : string.Format($"{DMTemplate1}{DMTemplateReason}\n{DMTemplate2}", target.Guild.Name, expireTime, reason); + + var dch = await target.CreateDMChannelAsync(); + try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; } + return true; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/IOperationResult.cs b/Services/CommonFunctions/IOperationResult.cs new file mode 100644 index 0000000..a7a1532 --- /dev/null +++ b/Services/CommonFunctions/IOperationResult.cs @@ -0,0 +1,40 @@ +namespace RegexBot; +/// +/// Contains information on success or failure outcomes for certain operations. +/// +public interface IOperationResult { + /// + /// Indicates whether the operation was successful. + /// + /// + /// Be aware this value may return while + /// returns . + /// + bool Success { get; } + + /// + /// The exception thrown, if any, when attempting to perform the operation. + /// + Exception? Error { get; } + + /// + /// Indicates if the operation failed due to being unable to find the user. + /// + bool ErrorNotFound { get; } + + /// + /// Indicates if the operation failed due to a permissions issue. + /// + bool ErrorForbidden { get; } + + /// + /// Indicates if user DM notification for this event was successful. + /// Always returns in cases where no notification was requested. + /// + bool NotificationSuccess { get; } + + /// + /// Returns a message representative of this result that may be posted as-is within a Discord channel. + /// + string ToResultString(); +} \ No newline at end of file diff --git a/Services/CommonFunctions/TimeoutSetResult.cs b/Services/CommonFunctions/TimeoutSetResult.cs new file mode 100644 index 0000000..2e4f645 --- /dev/null +++ b/Services/CommonFunctions/TimeoutSetResult.cs @@ -0,0 +1,45 @@ +using Discord.Net; +using RegexBot.Common; + +namespace RegexBot; +/// +/// Contains information on various success/failure outcomes for setting a timeout. +/// +public class TimeoutSetResult : IOperationResult { + private readonly SocketGuildUser? _target; + + /// + public bool Success => Error == null; + + /// + public Exception? Error { get; } + + /// + public bool ErrorNotFound => (_target == null) || ((Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.NotFound); + + /// + public bool ErrorForbidden => (Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.Forbidden; + + /// + public bool NotificationSuccess { get; } + + internal TimeoutSetResult(Exception? error, bool notificationSuccess, SocketGuildUser? target) { + Error = error; + NotificationSuccess = notificationSuccess; + _target = target; + } + + /// + public string ToResultString() { + if (Success) { + var msg = $":white_check_mark: Timeout set for **{_target!.Username}#{_target.Discriminator}**."; + if (!NotificationSuccess) msg += "\n(User was unable to receive notification message.)"; + 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; + return msg; + } + } +} \ No newline at end of file