From 921065a8be5d131b9b86e6b6c954428f54cc47de Mon Sep 17 00:00:00 2001 From: Bryce Walther Date: Mon, 15 Dec 2025 21:30:08 -0500 Subject: [PATCH] Refactor ModerationService.cs to use IInfractionTypeHandler --- .../Moderation/BanInfractionHandler.cs | 48 +++++++ .../Moderation/IInfractionTypeHandler.cs | 58 +++++++++ .../Moderation/ModerationService.cs | 118 ++++-------------- .../Moderation/MuteInfractionHandler.cs | 90 +++++++++++++ .../Moderation/NoticeInfractionHandler.cs | 43 +++++++ .../Moderation/WarningInfractionHandler.cs | 43 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 5 + 7 files changed, 309 insertions(+), 96 deletions(-) create mode 100644 src/Modix.Services/Moderation/BanInfractionHandler.cs create mode 100644 src/Modix.Services/Moderation/IInfractionTypeHandler.cs create mode 100644 src/Modix.Services/Moderation/MuteInfractionHandler.cs create mode 100644 src/Modix.Services/Moderation/NoticeInfractionHandler.cs create mode 100644 src/Modix.Services/Moderation/WarningInfractionHandler.cs diff --git a/src/Modix.Services/Moderation/BanInfractionHandler.cs b/src/Modix.Services/Moderation/BanInfractionHandler.cs new file mode 100644 index 00000000..d8b70124 --- /dev/null +++ b/src/Modix.Services/Moderation/BanInfractionHandler.cs @@ -0,0 +1,48 @@ +#nullable enable +using System.Threading.Tasks; +using Discord; +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; + +namespace Modix.Services.Moderation; + +/// +/// Handles Ban infraction type behavior. +/// +public class BanInfractionHandler : IInfractionTypeHandler +{ + public InfractionType Type => InfractionType.Ban; + + public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationBan; + + public bool RequiresReason => false; + + public bool CanBeRescinded => true; + + public bool RequiresUniqueActiveInfraction => true; + + public bool RequiresRankValidation => true; + + public async Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason) + { + await guild.AddBanAsync(subjectId, reason: reason); + } + + public async Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction) + { + RequestOptions? GetRequestOptions() => + string.IsNullOrEmpty(reason) ? null : new RequestOptions { AuditLogReason = reason }; + + await guild.RemoveBanAsync(subjectId, GetRequestOptions()); + } + + public async Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction) + { + // If the infraction has already been rescinded, we don't need to actually perform the unban + // Doing so will return a 404 from Discord (trying to remove a nonexistent ban) + if (infraction.RescindAction is null) + { + await guild.RemoveBanAsync(subjectId); + } + } +} diff --git a/src/Modix.Services/Moderation/IInfractionTypeHandler.cs b/src/Modix.Services/Moderation/IInfractionTypeHandler.cs new file mode 100644 index 00000000..652f26f7 --- /dev/null +++ b/src/Modix.Services/Moderation/IInfractionTypeHandler.cs @@ -0,0 +1,58 @@ +#nullable enable +using System.Threading.Tasks; +using Discord; +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; + +namespace Modix.Services.Moderation; + +/// +/// Defines the behavior for a specific infraction type. +/// +public interface IInfractionTypeHandler +{ + /// + /// The infraction type that this handler manages. + /// + InfractionType Type { get; } + + /// + /// The authorization claim required to create this infraction type. + /// + AuthorizationClaim RequiredClaim { get; } + + /// + /// Whether this infraction type requires a non-empty reason. + /// + bool RequiresReason { get; } + + /// + /// Whether this infraction type can be rescinded. + /// + bool CanBeRescinded { get; } + + /// + /// Whether this infraction type requires checking for existing active infractions. + /// + bool RequiresUniqueActiveInfraction { get; } + + /// + /// Whether this infraction type requires rank validation (moderator must outrank subject). + /// + bool RequiresRankValidation { get; } + + /// + /// Applies the infraction to Discord (e.g., adds mute role, bans user). + /// + Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason); + + /// + /// Rescinds the infraction from Discord (e.g., removes mute role, unbans user). + /// + Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction); + + /// + /// Deletes the infraction from Discord (e.g., removes mute role, unbans user if not already rescinded). + /// + Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction); +} diff --git a/src/Modix.Services/Moderation/ModerationService.cs b/src/Modix.Services/Moderation/ModerationService.cs index 4941f442..2d61fc65 100644 --- a/src/Modix.Services/Moderation/ModerationService.cs +++ b/src/Modix.Services/Moderation/ModerationService.cs @@ -27,11 +27,15 @@ public class ModerationService( IInfractionRepository infractionRepository, IDeletedMessageRepository deletedMessageRepository, IDeletedMessageBatchRepository deletedMessageBatchRepository, - ModixContext db) + ModixContext db, + IEnumerable infractionTypeHandlers) { public const string MUTE_ROLE_NAME = "MODiX_Moderation_Mute"; private const int MaxReasonLength = 1000; + private readonly Dictionary _handlersByType = + infractionTypeHandlers.ToDictionary(h => h.Type); + public async Task AutoRescindExpiredInfractions() { var expiredInfractions = await db @@ -58,7 +62,8 @@ await RescindInfractionAsync(expiredInfraction.Id, public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId, string reason, TimeSpan? duration) { - authorizationService.RequireClaims(_createInfractionClaimsByType[type]); + var handler = _handlersByType[type]; + authorizationService.RequireClaims(handler.RequiredClaim); if (reason is null) throw new ArgumentNullException(nameof(reason)); @@ -67,7 +72,7 @@ public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, Infrac throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length", nameof(reason)); - if (type is InfractionType.Notice or InfractionType.Warning && string.IsNullOrWhiteSpace(reason)) + if (handler.RequiresReason && string.IsNullOrWhiteSpace(reason)) throw new InvalidOperationException($"{type.ToString()} infractions require a reason to be given"); var guild = await discordClient.GetGuildAsync(guildId); @@ -75,10 +80,13 @@ public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, Infrac using (var transaction = await infractionRepository.BeginCreateTransactionAsync()) { - if (type is InfractionType.Ban or InfractionType.Mute) + if (handler.RequiresRankValidation) { await RequireSubjectRankLowerThanModeratorRankAsync(guild, moderatorId, subject); + } + if (handler.RequiresUniqueActiveInfraction) + { if (await infractionRepository.AnyAsync(new InfractionSearchCriteria() { GuildId = guildId, @@ -111,17 +119,7 @@ await infractionRepository.CreateAsync( // Note that we'll need to upgrade to the latest Discord.NET version to get access to the audit log. // Assuming that our Infractions repository is always correct, regarding the state of the Discord API. - switch (type) - { - case InfractionType.Mute when subject is not null: - await subject.AddRoleAsync( - await GetDesignatedMuteRoleAsync(guild)); - break; - - case InfractionType.Ban: - await guild.AddBanAsync(subjectId, reason: reason); - break; - } + await handler.ApplyInfractionAsync(guild, subject, subjectId, reason); } public async Task RescindInfractionAsync(long infractionId, string? reason = null) @@ -185,36 +183,8 @@ await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, await infractionRepository.TryDeleteAsync(infraction.Id, authorizationService.CurrentUserId.Value); var guild = await discordClient.GetGuildAsync(infraction.GuildId); - - switch (infraction.Type) - { - case InfractionType.Mute: - - if (await userService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) - { - var subject = await userService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); - await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild)); - } - else - { - Log.Warning( - "Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}", - infraction.Subject.Id, guild.Id); - } - - break; - - case InfractionType.Ban: - - //If the infraction has already been rescinded, we don't need to actually perform the unmute/unban - //Doing so will return a 404 from Discord (trying to remove a nonexistant ban) - if (infraction.RescindAction is null) - { - await guild.RemoveBanAsync(infraction.Subject.Id); - } - - break; - } + var handler = _handlersByType[infraction.Type]; + await handler.DeleteInfractionAsync(guild, infraction.Subject.Id, infraction); } public async Task DeleteMessageAsync(IMessage message, string reason, ulong deletedById, @@ -394,7 +364,8 @@ public async Task AnyActiveInfractions(ulong guildId, ulong userId, Infrac if (infraction is null) return (false, $"An infraction with an ID of {infractionId} could not be found."); - authorizationService.RequireClaims(_createInfractionClaimsByType[infraction.Type]); + var handler = _handlersByType[infraction.Type]; + authorizationService.RequireClaims(handler.RequiredClaim); // Allow users who created the infraction to bypass any further // validation and update their own infraction @@ -443,8 +414,10 @@ private async Task DoRescindInfractionAsync(InfractionType type, string? reason = null, bool isAutoRescind = false) { - RequestOptions? GetRequestOptions() => - string.IsNullOrEmpty(reason) ? null : new RequestOptions {AuditLogReason = reason}; + var handler = _handlersByType[type]; + + if (!handler.CanBeRescinded) + throw new InvalidOperationException($"{type} infractions cannot be rescinded."); if (!isAutoRescind) { @@ -453,30 +426,7 @@ await RequireSubjectRankLowerThanModeratorRankAsync(guildId, authorizationServic } var guild = await discordClient.GetGuildAsync(guildId); - - switch (type) - { - case InfractionType.Mute: - if (!await userService.GuildUserExistsAsync(guild.Id, subjectId)) - { - Log.Information( - "Attempted to remove the mute role from {0} ({1}), but they were not in the server.", - infraction?.Subject.GetFullUsername() ?? "Unknown user", - subjectId); - break; - } - - var subject = await userService.GetGuildUserAsync(guild.Id, subjectId); - await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions()); - break; - - case InfractionType.Ban: - await guild.RemoveBanAsync(subjectId, GetRequestOptions()); - break; - - default: - throw new InvalidOperationException($"{type} infractions cannot be rescinded."); - } + await handler.RescindInfractionAsync(guild, subjectId, reason, infraction); if (infraction != null) { @@ -485,21 +435,6 @@ await infractionRepository.TryRescindAsync(infraction.Id, authorizationService.C } } - private async Task GetDesignatedMuteRoleAsync(IGuild guild) - { - var mapping = (await designatedRoleMappingRepository.SearchBriefsAsync( - new DesignatedRoleMappingSearchCriteria() - { - GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false - })).FirstOrDefault(); - - if (mapping == null) - throw new InvalidOperationException( - $"There are currently no designated mute roles within guild {guild.Id}"); - - return guild.Roles.First(x => x.Id == mapping.Role.Id); - } - private async Task> GetRankRolesAsync(ulong guildId) => (await designatedRoleMappingRepository .SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria @@ -554,13 +489,4 @@ private async Task DoesModeratorOutrankUserAsync(IGuild guild, ulong moder return greatestSubjectRankPosition < greatestModeratorRankPosition; } - - private static readonly Dictionary _createInfractionClaimsByType - = new() - { - {InfractionType.Notice, AuthorizationClaim.ModerationNote}, - {InfractionType.Warning, AuthorizationClaim.ModerationWarn}, - {InfractionType.Mute, AuthorizationClaim.ModerationMute}, - {InfractionType.Ban, AuthorizationClaim.ModerationBan} - }; } diff --git a/src/Modix.Services/Moderation/MuteInfractionHandler.cs b/src/Modix.Services/Moderation/MuteInfractionHandler.cs new file mode 100644 index 00000000..26af10b1 --- /dev/null +++ b/src/Modix.Services/Moderation/MuteInfractionHandler.cs @@ -0,0 +1,90 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; +using Modix.Data.Repositories; +using Modix.Services.Core; +using Serilog; + +namespace Modix.Services.Moderation; + +/// +/// Handles Mute infraction type behavior. +/// +public class MuteInfractionHandler( + IUserService userService, + IDesignatedRoleMappingRepository designatedRoleMappingRepository) : IInfractionTypeHandler +{ + public InfractionType Type => InfractionType.Mute; + + public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationMute; + + public bool RequiresReason => false; + + public bool CanBeRescinded => true; + + public bool RequiresUniqueActiveInfraction => true; + + public bool RequiresRankValidation => true; + + public async Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason) + { + if (subject is not null) + { + await subject.AddRoleAsync(await GetDesignatedMuteRoleAsync(guild)); + } + } + + public async Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction) + { + RequestOptions? GetRequestOptions() => + string.IsNullOrEmpty(reason) ? null : new RequestOptions { AuditLogReason = reason }; + + if (!await userService.GuildUserExistsAsync(guild.Id, subjectId)) + { + Log.Information( + "Attempted to remove the mute role from {0} ({1}), but they were not in the server.", + infraction?.Subject.Nickname ?? "Unknown user", + subjectId); + return; + } + + var subject = await userService.GetGuildUserAsync(guild.Id, subjectId); + await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions()); + } + + public async Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction) + { + if (await userService.GuildUserExistsAsync(guild.Id, subjectId)) + { + var subject = await userService.GetGuildUserAsync(guild.Id, subjectId); + await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild)); + } + else + { + Log.Warning( + "Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}", + subjectId, guild.Id); + } + } + + private async Task GetDesignatedMuteRoleAsync(IGuild guild) + { + var mapping = (await designatedRoleMappingRepository.SearchBriefsAsync( + new DesignatedRoleMappingSearchCriteria() + { + GuildId = guild.Id, + Type = DesignatedRoleType.ModerationMute, + IsDeleted = false + })).FirstOrDefault(); + + if (mapping == null) + throw new InvalidOperationException( + $"There are currently no designated mute roles within guild {guild.Id}"); + + return guild.Roles.First(x => x.Id == mapping.Role.Id); + } +} diff --git a/src/Modix.Services/Moderation/NoticeInfractionHandler.cs b/src/Modix.Services/Moderation/NoticeInfractionHandler.cs new file mode 100644 index 00000000..6079ff1e --- /dev/null +++ b/src/Modix.Services/Moderation/NoticeInfractionHandler.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Threading.Tasks; +using Discord; +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; + +namespace Modix.Services.Moderation; + +/// +/// Handles Notice infraction type behavior. +/// +public class NoticeInfractionHandler : IInfractionTypeHandler +{ + public InfractionType Type => InfractionType.Notice; + + public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationNote; + + public bool RequiresReason => true; + + public bool CanBeRescinded => false; + + public bool RequiresUniqueActiveInfraction => false; + + public bool RequiresRankValidation => false; + + public Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason) + { + // Notices don't require any Discord action + return Task.CompletedTask; + } + + public Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction) + { + // Notices cannot be rescinded + return Task.CompletedTask; + } + + public Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction) + { + // Notices don't require any Discord action when deleted + return Task.CompletedTask; + } +} diff --git a/src/Modix.Services/Moderation/WarningInfractionHandler.cs b/src/Modix.Services/Moderation/WarningInfractionHandler.cs new file mode 100644 index 00000000..1348bcb8 --- /dev/null +++ b/src/Modix.Services/Moderation/WarningInfractionHandler.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Threading.Tasks; +using Discord; +using Modix.Data.Models.Core; +using Modix.Data.Models.Moderation; + +namespace Modix.Services.Moderation; + +/// +/// Handles Warning infraction type behavior. +/// +public class WarningInfractionHandler : IInfractionTypeHandler +{ + public InfractionType Type => InfractionType.Warning; + + public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationWarn; + + public bool RequiresReason => true; + + public bool CanBeRescinded => false; + + public bool RequiresUniqueActiveInfraction => false; + + public bool RequiresRankValidation => false; + + public Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason) + { + // Warnings don't require any Discord action + return Task.CompletedTask; + } + + public Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction) + { + // Warnings cannot be rescinded + return Task.CompletedTask; + } + + public Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction) + { + // Warnings don't require any Discord action when deleted + return Task.CompletedTask; + } +} diff --git a/src/Modix/Extensions/ServiceCollectionExtensions.cs b/src/Modix/Extensions/ServiceCollectionExtensions.cs index 57ad276e..4b64b58b 100644 --- a/src/Modix/Extensions/ServiceCollectionExtensions.cs +++ b/src/Modix/Extensions/ServiceCollectionExtensions.cs @@ -172,6 +172,11 @@ public static IServiceCollection AddModix( services.AddScoped(); services.AddScoped, PromotionLoggingHandler>(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); return services;