Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/Modix.Services/Moderation/BanInfractionHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Handles Ban infraction type behavior.
/// </summary>
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);
}
}
}
58 changes: 58 additions & 0 deletions src/Modix.Services/Moderation/IInfractionTypeHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Defines the behavior for a specific infraction type.
/// </summary>
public interface IInfractionTypeHandler
{
/// <summary>
/// The infraction type that this handler manages.
/// </summary>
InfractionType Type { get; }

/// <summary>
/// The authorization claim required to create this infraction type.
/// </summary>
AuthorizationClaim RequiredClaim { get; }

/// <summary>
/// Whether this infraction type requires a non-empty reason.
/// </summary>
bool RequiresReason { get; }

/// <summary>
/// Whether this infraction type can be rescinded.
/// </summary>
bool CanBeRescinded { get; }

/// <summary>
/// Whether this infraction type requires checking for existing active infractions.
/// </summary>
bool RequiresUniqueActiveInfraction { get; }

/// <summary>
/// Whether this infraction type requires rank validation (moderator must outrank subject).
/// </summary>
bool RequiresRankValidation { get; }

/// <summary>
/// Applies the infraction to Discord (e.g., adds mute role, bans user).
/// </summary>
Task ApplyInfractionAsync(IGuild guild, IGuildUser? subject, ulong subjectId, string reason);

/// <summary>
/// Rescinds the infraction from Discord (e.g., removes mute role, unbans user).
/// </summary>
Task RescindInfractionAsync(IGuild guild, ulong subjectId, string? reason, InfractionSummary? infraction);

/// <summary>
/// Deletes the infraction from Discord (e.g., removes mute role, unbans user if not already rescinded).
/// </summary>
Task DeleteInfractionAsync(IGuild guild, ulong subjectId, InfractionSummary infraction);
}
118 changes: 22 additions & 96 deletions src/Modix.Services/Moderation/ModerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ public class ModerationService(
IInfractionRepository infractionRepository,
IDeletedMessageRepository deletedMessageRepository,
IDeletedMessageBatchRepository deletedMessageBatchRepository,
ModixContext db)
ModixContext db,
IEnumerable<IInfractionTypeHandler> infractionTypeHandlers)
{
public const string MUTE_ROLE_NAME = "MODiX_Moderation_Mute";
private const int MaxReasonLength = 1000;

private readonly Dictionary<InfractionType, IInfractionTypeHandler> _handlersByType =
infractionTypeHandlers.ToDictionary(h => h.Type);

public async Task AutoRescindExpiredInfractions()
{
var expiredInfractions = await db
Expand All @@ -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));
Expand All @@ -67,18 +72,21 @@ 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);
var subject = await userService.TryGetGuildUserAsync(guild, subjectId, CancellationToken.None);

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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -394,7 +364,8 @@ public async Task<bool> 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
Expand Down Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -485,21 +435,6 @@ await infractionRepository.TryRescindAsync(infraction.Id, authorizationService.C
}
}

private async Task<IRole> 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<IEnumerable<GuildRoleBrief>> GetRankRolesAsync(ulong guildId)
=> (await designatedRoleMappingRepository
.SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria
Expand Down Expand Up @@ -554,13 +489,4 @@ private async Task<bool> DoesModeratorOutrankUserAsync(IGuild guild, ulong moder

return greatestSubjectRankPosition < greatestModeratorRankPosition;
}

private static readonly Dictionary<InfractionType, AuthorizationClaim> _createInfractionClaimsByType
= new()
{
{InfractionType.Notice, AuthorizationClaim.ModerationNote},
{InfractionType.Warning, AuthorizationClaim.ModerationWarn},
{InfractionType.Mute, AuthorizationClaim.ModerationMute},
{InfractionType.Ban, AuthorizationClaim.ModerationBan}
};
}
90 changes: 90 additions & 0 deletions src/Modix.Services/Moderation/MuteInfractionHandler.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Handles Mute infraction type behavior.
/// </summary>
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<IRole> 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);
}
}
Loading