Skip to content

Commit 82b52f8

Browse files
committed
Refactor ModerationService.cs to use IInfractionTypeHandler
1 parent 078982d commit 82b52f8

File tree

6 files changed

+304
-96
lines changed

6 files changed

+304
-96
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#nullable enable
2+
using System.Threading.Tasks;
3+
using Discord;
4+
using Modix.Data.Models.Core;
5+
using Modix.Data.Models.Moderation;
6+
7+
namespace Modix.Services.Moderation;
8+
9+
/// <summary>
10+
/// Handles Ban infraction type behavior.
11+
/// </summary>
12+
public class BanInfractionHandler : IInfractionTypeHandler
13+
{
14+
public InfractionType Type => InfractionType.Ban;
15+
16+
public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationBan;
17+
18+
public bool RequiresReason => false;
19+
20+
public bool CanBeRescinded => true;
21+
22+
public bool RequiresUniqueActiveInfraction => true;
23+
24+
public bool RequiresRankValidation => true;
25+
26+
public async Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason)
27+
{
28+
await guild.AddBanAsync(subjectId, reason: reason);
29+
}
30+
31+
public async Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction)
32+
{
33+
RequestOptions? GetRequestOptions() =>
34+
string.IsNullOrEmpty(reason) ? null : new RequestOptions { AuditLogReason = reason };
35+
36+
await guild.RemoveBanAsync(subjectId, GetRequestOptions());
37+
}
38+
39+
public async Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction)
40+
{
41+
// If the infraction has already been rescinded, we don't need to actually perform the unban
42+
// Doing so will return a 404 from Discord (trying to remove a nonexistent ban)
43+
if (infraction.RescindAction is null)
44+
{
45+
await guild.RemoveBanAsync(subjectId);
46+
}
47+
}
48+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#nullable enable
2+
using System.Threading.Tasks;
3+
using Discord;
4+
using Modix.Data.Models.Core;
5+
using Modix.Data.Models.Moderation;
6+
7+
namespace Modix.Services.Moderation;
8+
9+
/// <summary>
10+
/// Defines the behavior for a specific infraction type.
11+
/// </summary>
12+
public interface IInfractionTypeHandler
13+
{
14+
/// <summary>
15+
/// The infraction type that this handler manages.
16+
/// </summary>
17+
InfractionType Type { get; }
18+
19+
/// <summary>
20+
/// The authorization claim required to create this infraction type.
21+
/// </summary>
22+
AuthorizationClaim RequiredClaim { get; }
23+
24+
/// <summary>
25+
/// Whether this infraction type requires a non-empty reason.
26+
/// </summary>
27+
bool RequiresReason { get; }
28+
29+
/// <summary>
30+
/// Whether this infraction type can be rescinded.
31+
/// </summary>
32+
bool CanBeRescinded { get; }
33+
34+
/// <summary>
35+
/// Whether this infraction type requires checking for existing active infractions.
36+
/// </summary>
37+
bool RequiresUniqueActiveInfraction { get; }
38+
39+
/// <summary>
40+
/// Whether this infraction type requires rank validation (moderator must outrank subject).
41+
/// </summary>
42+
bool RequiresRankValidation { get; }
43+
44+
/// <summary>
45+
/// Applies the infraction to Discord (e.g., adds mute role, bans user).
46+
/// </summary>
47+
Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason);
48+
49+
/// <summary>
50+
/// Rescinds the infraction from Discord (e.g., removes mute role, unbans user).
51+
/// </summary>
52+
Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction);
53+
54+
/// <summary>
55+
/// Deletes the infraction from Discord (e.g., removes mute role, unbans user if not already rescinded).
56+
/// </summary>
57+
Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction);
58+
}

src/Modix.Services/Moderation/ModerationService.cs

Lines changed: 22 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@ public class ModerationService(
2727
IInfractionRepository infractionRepository,
2828
IDeletedMessageRepository deletedMessageRepository,
2929
IDeletedMessageBatchRepository deletedMessageBatchRepository,
30-
ModixContext db)
30+
ModixContext db,
31+
IEnumerable<IInfractionTypeHandler> infractionTypeHandlers)
3132
{
3233
public const string MUTE_ROLE_NAME = "MODiX_Moderation_Mute";
3334
private const int MaxReasonLength = 1000;
3435

36+
private readonly Dictionary<InfractionType, IInfractionTypeHandler> _handlersByType =
37+
infractionTypeHandlers.ToDictionary(h => h.Type);
38+
3539
public async Task AutoRescindExpiredInfractions()
3640
{
3741
var expiredInfractions = await db
@@ -58,7 +62,8 @@ await RescindInfractionAsync(expiredInfraction.Id,
5862
public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId,
5963
string reason, TimeSpan? duration)
6064
{
61-
authorizationService.RequireClaims(_createInfractionClaimsByType[type]);
65+
var handler = _handlersByType[type];
66+
authorizationService.RequireClaims(handler.RequiredClaim);
6267

6368
if (reason is null)
6469
throw new ArgumentNullException(nameof(reason));
@@ -67,18 +72,21 @@ public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, Infrac
6772
throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length",
6873
nameof(reason));
6974

70-
if (type is InfractionType.Notice or InfractionType.Warning && string.IsNullOrWhiteSpace(reason))
75+
if (handler.RequiresReason && string.IsNullOrWhiteSpace(reason))
7176
throw new InvalidOperationException($"{type.ToString()} infractions require a reason to be given");
7277

7378
var guild = await discordClient.GetGuildAsync(guildId);
7479
var subject = await userService.TryGetGuildUserAsync(guild, subjectId, CancellationToken.None);
7580

7681
using (var transaction = await infractionRepository.BeginCreateTransactionAsync())
7782
{
78-
if (type is InfractionType.Ban or InfractionType.Mute)
83+
if (handler.RequiresRankValidation)
7984
{
8085
await RequireSubjectRankLowerThanModeratorRankAsync(guild, moderatorId, subject);
86+
}
8187

88+
if (handler.RequiresUniqueActiveInfraction)
89+
{
8290
if (await infractionRepository.AnyAsync(new InfractionSearchCriteria()
8391
{
8492
GuildId = guildId,
@@ -111,17 +119,7 @@ await infractionRepository.CreateAsync(
111119
// Note that we'll need to upgrade to the latest Discord.NET version to get access to the audit log.
112120

113121
// Assuming that our Infractions repository is always correct, regarding the state of the Discord API.
114-
switch (type)
115-
{
116-
case InfractionType.Mute when subject is not null:
117-
await subject.AddRoleAsync(
118-
await GetDesignatedMuteRoleAsync(guild));
119-
break;
120-
121-
case InfractionType.Ban:
122-
await guild.AddBanAsync(subjectId, reason: reason);
123-
break;
124-
}
122+
await handler.ApplyInfractionAsync(guild, subject, subjectId, reason);
125123
}
126124

127125
public async Task RescindInfractionAsync(long infractionId, string? reason = null)
@@ -185,36 +183,8 @@ await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId,
185183
await infractionRepository.TryDeleteAsync(infraction.Id, authorizationService.CurrentUserId.Value);
186184

187185
var guild = await discordClient.GetGuildAsync(infraction.GuildId);
188-
189-
switch (infraction.Type)
190-
{
191-
case InfractionType.Mute:
192-
193-
if (await userService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id))
194-
{
195-
var subject = await userService.GetGuildUserAsync(guild.Id, infraction.Subject.Id);
196-
await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild));
197-
}
198-
else
199-
{
200-
Log.Warning(
201-
"Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}",
202-
infraction.Subject.Id, guild.Id);
203-
}
204-
205-
break;
206-
207-
case InfractionType.Ban:
208-
209-
//If the infraction has already been rescinded, we don't need to actually perform the unmute/unban
210-
//Doing so will return a 404 from Discord (trying to remove a nonexistant ban)
211-
if (infraction.RescindAction is null)
212-
{
213-
await guild.RemoveBanAsync(infraction.Subject.Id);
214-
}
215-
216-
break;
217-
}
186+
var handler = _handlersByType[infraction.Type];
187+
await handler.DeleteInfractionAsync(guild, infraction.Subject.Id, infraction);
218188
}
219189

220190
public async Task DeleteMessageAsync(IMessage message, string reason, ulong deletedById,
@@ -394,7 +364,8 @@ public async Task<bool> AnyActiveInfractions(ulong guildId, ulong userId, Infrac
394364
if (infraction is null)
395365
return (false, $"An infraction with an ID of {infractionId} could not be found.");
396366

397-
authorizationService.RequireClaims(_createInfractionClaimsByType[infraction.Type]);
367+
var handler = _handlersByType[infraction.Type];
368+
authorizationService.RequireClaims(handler.RequiredClaim);
398369

399370
// Allow users who created the infraction to bypass any further
400371
// validation and update their own infraction
@@ -443,8 +414,10 @@ private async Task DoRescindInfractionAsync(InfractionType type,
443414
string? reason = null,
444415
bool isAutoRescind = false)
445416
{
446-
RequestOptions? GetRequestOptions() =>
447-
string.IsNullOrEmpty(reason) ? null : new RequestOptions {AuditLogReason = reason};
417+
var handler = _handlersByType[type];
418+
419+
if (!handler.CanBeRescinded)
420+
throw new InvalidOperationException($"{type} infractions cannot be rescinded.");
448421

449422
if (!isAutoRescind)
450423
{
@@ -453,30 +426,7 @@ await RequireSubjectRankLowerThanModeratorRankAsync(guildId, authorizationServic
453426
}
454427

455428
var guild = await discordClient.GetGuildAsync(guildId);
456-
457-
switch (type)
458-
{
459-
case InfractionType.Mute:
460-
if (!await userService.GuildUserExistsAsync(guild.Id, subjectId))
461-
{
462-
Log.Information(
463-
"Attempted to remove the mute role from {0} ({1}), but they were not in the server.",
464-
infraction?.Subject.GetFullUsername() ?? "Unknown user",
465-
subjectId);
466-
break;
467-
}
468-
469-
var subject = await userService.GetGuildUserAsync(guild.Id, subjectId);
470-
await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions());
471-
break;
472-
473-
case InfractionType.Ban:
474-
await guild.RemoveBanAsync(subjectId, GetRequestOptions());
475-
break;
476-
477-
default:
478-
throw new InvalidOperationException($"{type} infractions cannot be rescinded.");
479-
}
429+
await handler.RescindInfractionAsync(guild, subjectId, reason, infraction);
480430

481431
if (infraction != null)
482432
{
@@ -485,21 +435,6 @@ await infractionRepository.TryRescindAsync(infraction.Id, authorizationService.C
485435
}
486436
}
487437

488-
private async Task<IRole> GetDesignatedMuteRoleAsync(IGuild guild)
489-
{
490-
var mapping = (await designatedRoleMappingRepository.SearchBriefsAsync(
491-
new DesignatedRoleMappingSearchCriteria()
492-
{
493-
GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false
494-
})).FirstOrDefault();
495-
496-
if (mapping == null)
497-
throw new InvalidOperationException(
498-
$"There are currently no designated mute roles within guild {guild.Id}");
499-
500-
return guild.Roles.First(x => x.Id == mapping.Role.Id);
501-
}
502-
503438
private async Task<IEnumerable<GuildRoleBrief>> GetRankRolesAsync(ulong guildId)
504439
=> (await designatedRoleMappingRepository
505440
.SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria
@@ -554,13 +489,4 @@ private async Task<bool> DoesModeratorOutrankUserAsync(IGuild guild, ulong moder
554489

555490
return greatestSubjectRankPosition < greatestModeratorRankPosition;
556491
}
557-
558-
private static readonly Dictionary<InfractionType, AuthorizationClaim> _createInfractionClaimsByType
559-
= new()
560-
{
561-
{InfractionType.Notice, AuthorizationClaim.ModerationNote},
562-
{InfractionType.Warning, AuthorizationClaim.ModerationWarn},
563-
{InfractionType.Mute, AuthorizationClaim.ModerationMute},
564-
{InfractionType.Ban, AuthorizationClaim.ModerationBan}
565-
};
566492
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#nullable enable
2+
using System;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Discord;
6+
using Modix.Data.Models.Core;
7+
using Modix.Data.Models.Moderation;
8+
using Modix.Data.Repositories;
9+
using Modix.Services.Core;
10+
using Serilog;
11+
12+
namespace Modix.Services.Moderation;
13+
14+
/// <summary>
15+
/// Handles Mute infraction type behavior.
16+
/// </summary>
17+
public class MuteInfractionHandler(
18+
IUserService userService,
19+
IDesignatedRoleMappingRepository designatedRoleMappingRepository) : IInfractionTypeHandler
20+
{
21+
public InfractionType Type => InfractionType.Mute;
22+
23+
public AuthorizationClaim RequiredClaim => AuthorizationClaim.ModerationMute;
24+
25+
public bool RequiresReason => false;
26+
27+
public bool CanBeRescinded => true;
28+
29+
public bool RequiresUniqueActiveInfraction => true;
30+
31+
public bool RequiresRankValidation => true;
32+
33+
public async Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason)
34+
{
35+
if (subject is not null)
36+
{
37+
await subject.AddRoleAsync(await GetDesignatedMuteRoleAsync(guild));
38+
}
39+
}
40+
41+
public async Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction)
42+
{
43+
RequestOptions? GetRequestOptions() =>
44+
string.IsNullOrEmpty(reason) ? null : new RequestOptions { AuditLogReason = reason };
45+
46+
if (!await userService.GuildUserExistsAsync(guild.Id, subjectId))
47+
{
48+
Log.Information(
49+
"Attempted to remove the mute role from {0} ({1}), but they were not in the server.",
50+
infraction?.Subject.Nickname ?? "Unknown user",
51+
subjectId);
52+
return;
53+
}
54+
55+
var subject = await userService.GetGuildUserAsync(guild.Id, subjectId);
56+
await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions());
57+
}
58+
59+
public async Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction)
60+
{
61+
if (await userService.GuildUserExistsAsync(guild.Id, subjectId))
62+
{
63+
var subject = await userService.GetGuildUserAsync(guild.Id, subjectId);
64+
await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild));
65+
}
66+
else
67+
{
68+
Log.Warning(
69+
"Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}",
70+
subjectId, guild.Id);
71+
}
72+
}
73+
74+
private async Task<IRole> GetDesignatedMuteRoleAsync(IGuild guild)
75+
{
76+
var mapping = (await designatedRoleMappingRepository.SearchBriefsAsync(
77+
new DesignatedRoleMappingSearchCriteria()
78+
{
79+
GuildId = guild.Id,
80+
Type = DesignatedRoleType.ModerationMute,
81+
IsDeleted = false
82+
})).FirstOrDefault();
83+
84+
if (mapping == null)
85+
throw new InvalidOperationException(
86+
$"There are currently no designated mute roles within guild {guild.Id}");
87+
88+
return guild.Roles.First(x => x.Id == mapping.Role.Id);
89+
}
90+
}

0 commit comments

Comments
 (0)