diff --git a/src/Modix.Bot/Behaviors/ModerationLoggingBehavior.cs b/src/Modix.Bot/Behaviors/ModerationLoggingBehavior.cs index 0288f6ffd..bd4d866dd 100644 --- a/src/Modix.Bot/Behaviors/ModerationLoggingBehavior.cs +++ b/src/Modix.Bot/Behaviors/ModerationLoggingBehavior.cs @@ -38,7 +38,7 @@ public ModerationLoggingBehavior( DesignatedChannelService = designatedChannelService; Config = config.Value; - _lazyModerationService = new Lazy(() => serviceProvider.GetRequiredService()); + _lazyModerationService = new Lazy(() => serviceProvider.GetRequiredService()); } /// @@ -95,9 +95,9 @@ public async Task OnModerationActionCreatedAsync(long moderationActionId, Modera /// /// An for performing moderation actions. /// - internal protected IModerationService ModerationService + internal protected ModerationService ModerationService => _lazyModerationService.Value; - private readonly Lazy _lazyModerationService; + private readonly Lazy _lazyModerationService; internal protected static ModixConfig Config { get; private set; } diff --git a/src/Modix.Bot/DiscordBotSession.cs b/src/Modix.Bot/DiscordBotSession.cs new file mode 100644 index 000000000..13bc302b9 --- /dev/null +++ b/src/Modix.Bot/DiscordBotSession.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord.Commands; +using Discord.WebSocket; +using Modix.Data.Models.Core; +using Modix.Services; + +namespace Modix.Bot; + +public class DiscordBotSession(DiscordSocketClient discordSocketClient, + AuthorizationClaimService authorizationClaimService) : IScopedSession +{ + public ulong SelfUserId { get; } = discordSocketClient.CurrentUser.Id; + + private ulong _executingUserId; + + public ulong ExecutingUserId => + _executingUserId == default + ? SelfUserId + : _executingUserId; + + private IReadOnlyCollection _authorizationClaims; + + public void ApplyCommandContext(ICommandContext context) + { + _executingUserId = context.User.Id; + } + + private async Task> GetClaims() + { + return _authorizationClaims ??= await authorizationClaimService.GetClaimsForUser(ExecutingUserId); + } + + public async Task HasClaim(params AuthorizationClaim[] claims) + { + var ownedClaims = await GetClaims(); + return claims.All(claim => ownedClaims.Contains(claim)); + } +} diff --git a/src/Modix.Bot/ModixBot.cs b/src/Modix.Bot/ModixBot.cs index ad501c027..25b15111b 100644 --- a/src/Modix.Bot/ModixBot.cs +++ b/src/Modix.Bot/ModixBot.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.Options; using Modix.Bot.Notifications; using Modix.Data.Models.Core; +using Modix.Services; namespace Modix.Bot { @@ -61,6 +62,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) discordSocketClient.MessageDeleted += OnMessageDeleted; discordSocketClient.ReactionAdded += OnReactionAdded; discordSocketClient.ReactionRemoved += OnReactionRemoved; + discordSocketClient.UserJoined += OnUserJoined; + discordSocketClient.AuditLogCreated += OnAuditLogCreated; + discordSocketClient.GuildAvailable += OnGuildAvailable; + discordSocketClient.ChannelCreated += OnChannelCreated; + discordSocketClient.ChannelUpdated += OnChannelUpdated; + discordSocketClient.JoinedGuild += OnJoinedGuild; discordRestClient.Log += discordSerilogAdapter.HandleLog; commandService.Log += discordSerilogAdapter.HandleLog; @@ -77,7 +84,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await commandService.AddModulesAsync(typeof(ModixBot).Assembly, _scope.ServiceProvider); logger.LogInformation("{Modules} modules loaded, containing {Commands} commands", - commandService.Modules.Count(), commandService.Modules.SelectMany(d=>d.Commands).Count()); + commandService.Modules.Count(), commandService.Modules.SelectMany(d => d.Commands).Count()); logger.LogInformation("Logging into Discord and starting the client"); @@ -87,7 +94,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) logger.LogInformation("Loading interaction modules..."); - var modules = (await interactionService.AddModulesAsync(typeof(ModixBot).Assembly, _scope.ServiceProvider)).ToArray(); + var modules = + (await interactionService.AddModulesAsync(typeof(ModixBot).Assembly, _scope.ServiceProvider)) + .ToArray(); foreach (var guild in discordSocketClient.Guilds) { @@ -95,10 +104,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } logger.LogInformation("{Modules} interaction modules loaded", modules.Length); - logger.LogInformation("Loaded {SlashCommands} slash commands", modules.SelectMany(x => x.SlashCommands).Count()); - logger.LogInformation("Loaded {ContextCommands} context commands", modules.SelectMany(x => x.ContextCommands).Count()); - logger.LogInformation("Loaded {ModalCommands} modal commands", modules.SelectMany(x => x.ModalCommands).Count()); - logger.LogInformation("Loaded {ComponentCommands} component commands", modules.SelectMany(x => x.ComponentCommands).Count()); + logger.LogInformation("Loaded {SlashCommands} slash commands", + modules.SelectMany(x => x.SlashCommands).Count()); + logger.LogInformation("Loaded {ContextCommands} context commands", + modules.SelectMany(x => x.ContextCommands).Count()); + logger.LogInformation("Loaded {ModalCommands} modal commands", + modules.SelectMany(x => x.ModalCommands).Count()); + logger.LogInformation("Loaded {ComponentCommands} component commands", + modules.SelectMany(x => x.ComponentCommands).Count()); await Task.Delay(-1, stoppingToken); } @@ -154,7 +167,7 @@ private Task OnDisconnect(Exception ex) { // Reconnections are handled by Discord.NET, we // don't need to worry about handling this ourselves - if(ex is GatewayReconnectException) + if (ex is GatewayReconnectException) { logger.LogInformation("Received gateway reconnect"); return Task.CompletedTask; @@ -171,7 +184,6 @@ private async Task StartClient(CancellationToken cancellationToken) try { - cancellationToken.ThrowIfCancellationRequested(); await discordSocketClient.LoginAsync(TokenType.Bot, modixConfig.Value.DiscordToken); @@ -193,14 +205,18 @@ private void UnregisterClientHandlers() discordSocketClient.LatencyUpdated -= OnLatencyUpdated; discordSocketClient.Disconnected -= OnDisconnect; discordSocketClient.Log -= discordSerilogAdapter.HandleLog; - discordSocketClient.Ready -= OnClientReady; - discordSocketClient.MessageReceived -= OnMessageReceived; discordSocketClient.MessageUpdated -= OnMessageUpdated; discordSocketClient.MessageDeleted -= OnMessageDeleted; discordSocketClient.ReactionAdded -= OnReactionAdded; discordSocketClient.ReactionRemoved -= OnReactionRemoved; + discordSocketClient.UserJoined -= OnUserJoined; + discordSocketClient.AuditLogCreated -= OnAuditLogCreated; + discordSocketClient.GuildAvailable -= OnGuildAvailable; + discordSocketClient.ChannelCreated -= OnChannelCreated; + discordSocketClient.ChannelUpdated -= OnChannelUpdated; + discordSocketClient.JoinedGuild -= OnJoinedGuild; } private async Task OnClientReady() @@ -209,40 +225,53 @@ private async Task OnClientReady() _whenReadySource.SetResult(null); } - private async Task OnMessageReceived(SocketMessage arg) + private async Task PublishMessage(T message) where T : INotification { using var scope = serviceProvider.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Publish(new MessageReceivedNotificationV3(arg)); + await PublishMessage(scope, message); } - private async Task OnMessageUpdated(Cacheable cachedMessage, SocketMessage newMessage, ISocketMessageChannel channel) + private async Task PublishMessage(IServiceScope scope, T message) where T : INotification { - using var scope = serviceProvider.CreateScope(); var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Publish(new MessageUpdatedNotificationV3(cachedMessage, newMessage, channel)); + await mediator.Publish(message); } - private async Task OnMessageDeleted(Cacheable message, Cacheable channel) - { - using var scope = serviceProvider.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Publish(new MessageDeletedNotificationV3(message, channel)); - } + private Task OnMessageReceived(SocketMessage message) => + PublishMessage(new MessageReceivedNotificationV3(message)); - private async Task OnReactionAdded(Cacheable message, Cacheable channel, SocketReaction reaction) - { - using var scope = serviceProvider.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Publish(new ReactionAddedNotificationV3(message, channel, reaction)); - } + private Task OnMessageUpdated(Cacheable cachedMessage, SocketMessage newMessage, + ISocketMessageChannel channel) => + PublishMessage(new MessageUpdatedNotificationV3(cachedMessage, newMessage, channel)); - private async Task OnReactionRemoved(Cacheable message, Cacheable channel, SocketReaction reaction) - { - using var scope = serviceProvider.CreateScope(); - var mediator = scope.ServiceProvider.GetRequiredService(); - await mediator.Publish(new ReactionRemovedNotificationV3(message, channel, reaction)); - } + private Task OnMessageDeleted(Cacheable message, + Cacheable channel) => + PublishMessage(new MessageDeletedNotificationV3(message, channel)); + + private Task OnReactionAdded(Cacheable message, + Cacheable channel, SocketReaction reaction) => + PublishMessage(new ReactionAddedNotificationV3(message, channel, reaction)); + + private Task OnReactionRemoved(Cacheable message, + Cacheable channel, SocketReaction reaction) => + PublishMessage(new ReactionRemovedNotificationV3(message, channel, reaction)); + + private Task OnUserJoined(SocketGuildUser guildUser) => + PublishMessage(new UserJoinedNotificationV3(guildUser)); + + private Task OnAuditLogCreated(SocketAuditLogEntry entry, SocketGuild guild) => + PublishMessage(new AuditLogCreatedNotificationV3(entry, guild)); + + private Task OnGuildAvailable(SocketGuild guild) + => PublishMessage(new GuildAvailableNotificationV3(guild)); + + private Task OnChannelCreated(SocketChannel channel) => + PublishMessage(new ChannelCreatedNotificationV3(channel)); + + private Task OnChannelUpdated(SocketChannel oldChannel, SocketChannel newChannel) => + PublishMessage(new ChannelUpdatedNotificationV3(oldChannel, newChannel)); + + private Task OnJoinedGuild(SocketGuild guild) => PublishMessage(new JoinedGuildNotificationV3(guild)); public override void Dispose() { diff --git a/src/Modix.Bot/Modules/InfractionModule.cs b/src/Modix.Bot/Modules/InfractionModule.cs index bea873291..9a4671e9a 100644 --- a/src/Modix.Bot/Modules/InfractionModule.cs +++ b/src/Modix.Bot/Modules/InfractionModule.cs @@ -24,10 +24,10 @@ namespace Modix.Modules [ModuleHelp("Infractions", "Provides commands for working with infractions.")] public class InfractionModule : InteractionModuleBase { - private readonly IModerationService _moderationService; + private readonly ModerationService _moderationService; private readonly ModixConfig _config; - public InfractionModule(IModerationService moderationService, IOptions config) + public InfractionModule(ModerationService moderationService, IOptions config) { _moderationService = moderationService; _config = config.Value; diff --git a/src/Modix.Bot/Modules/ModerationModule.cs b/src/Modix.Bot/Modules/ModerationModule.cs index 369b05611..93bab6272 100644 --- a/src/Modix.Bot/Modules/ModerationModule.cs +++ b/src/Modix.Bot/Modules/ModerationModule.cs @@ -26,7 +26,7 @@ namespace Modix.Modules public class ModerationModule : ModuleBase { public ModerationModule( - IModerationService moderationService, + ModerationService moderationService, IUserService userService, IOptions config) { @@ -306,7 +306,7 @@ private async ValueTask GetConfirmationIfRequiredAsync(DiscordUserOrMessag + $"{Format.Bold(author.GetDisplayName())} ({userOrAuthor.UserId}), the message's author?"); } - internal protected IModerationService ModerationService { get; } + internal protected ModerationService ModerationService { get; } internal protected IUserService UserService { get; } diff --git a/src/Modix.Bot/Modules/UserInfoModule.cs b/src/Modix.Bot/Modules/UserInfoModule.cs index 45c39a49d..7078b5e5f 100644 --- a/src/Modix.Bot/Modules/UserInfoModule.cs +++ b/src/Modix.Bot/Modules/UserInfoModule.cs @@ -37,7 +37,7 @@ public class UserInfoModule : InteractionModuleBase { private readonly ILogger _log; private readonly IUserService _userService; - private readonly IModerationService _moderationService; + private readonly ModerationService _moderationService; private readonly IAuthorizationService _authorizationService; private readonly IMessageRepository _messageRepository; private readonly IEmojiRepository _emojiRepository; @@ -51,7 +51,7 @@ public class UserInfoModule : InteractionModuleBase public UserInfoModule( ILogger logger, IUserService userService, - IModerationService moderationService, + ModerationService moderationService, IAuthorizationService authorizationService, IMessageRepository messageRepository, IEmojiRepository emojiRepository, diff --git a/src/Modix.Bot/Notifications/AuditLogCreatedNotificationV3.cs b/src/Modix.Bot/Notifications/AuditLogCreatedNotificationV3.cs new file mode 100644 index 000000000..a77016c02 --- /dev/null +++ b/src/Modix.Bot/Notifications/AuditLogCreatedNotificationV3.cs @@ -0,0 +1,10 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class AuditLogCreatedNotificationV3(SocketAuditLogEntry entry, SocketGuild guild) : INotification +{ + public SocketAuditLogEntry Entry { get; } = entry; + public SocketGuild Guild { get; } = guild; +} diff --git a/src/Modix.Bot/Notifications/ChannelCreatedNotificationV3.cs b/src/Modix.Bot/Notifications/ChannelCreatedNotificationV3.cs new file mode 100644 index 000000000..b7f735748 --- /dev/null +++ b/src/Modix.Bot/Notifications/ChannelCreatedNotificationV3.cs @@ -0,0 +1,9 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class ChannelCreatedNotificationV3(SocketChannel channel) : INotification +{ + public SocketChannel Channel { get; } = channel; +} diff --git a/src/Modix.Bot/Notifications/ChannelUpdatedNotificationV3.cs b/src/Modix.Bot/Notifications/ChannelUpdatedNotificationV3.cs new file mode 100644 index 000000000..b1b1897c6 --- /dev/null +++ b/src/Modix.Bot/Notifications/ChannelUpdatedNotificationV3.cs @@ -0,0 +1,10 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class ChannelUpdatedNotificationV3(SocketChannel oldChannel, SocketChannel newChannel) : INotification +{ + public SocketChannel OldChannel { get; } = oldChannel; + public SocketChannel NewChannel { get; } = newChannel; +} diff --git a/src/Modix.Bot/Notifications/GuildAvailableNotificationV3.cs b/src/Modix.Bot/Notifications/GuildAvailableNotificationV3.cs new file mode 100644 index 000000000..cfd64fcce --- /dev/null +++ b/src/Modix.Bot/Notifications/GuildAvailableNotificationV3.cs @@ -0,0 +1,9 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class GuildAvailableNotificationV3(SocketGuild guild) : INotification +{ + public SocketGuild Guild { get; } = guild; +} diff --git a/src/Modix.Bot/Notifications/JoinedGuildNotificationV3.cs b/src/Modix.Bot/Notifications/JoinedGuildNotificationV3.cs new file mode 100644 index 000000000..febaf8941 --- /dev/null +++ b/src/Modix.Bot/Notifications/JoinedGuildNotificationV3.cs @@ -0,0 +1,9 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class JoinedGuildNotificationV3(SocketGuild guild) : INotification +{ + public SocketGuild Guild { get; } = guild; +} diff --git a/src/Modix.Bot/Notifications/UserJoinedNotificationV3.cs b/src/Modix.Bot/Notifications/UserJoinedNotificationV3.cs new file mode 100644 index 000000000..e5631cf8e --- /dev/null +++ b/src/Modix.Bot/Notifications/UserJoinedNotificationV3.cs @@ -0,0 +1,9 @@ +using Discord.WebSocket; +using MediatR; + +namespace Modix.Bot.Notifications; + +public class UserJoinedNotificationV3(SocketGuildUser guildUser) : INotification +{ + public SocketGuildUser GuildUser { get; } = guildUser; +} diff --git a/src/Modix.Bot/Responders/AuditLogCreatedResponder.cs b/src/Modix.Bot/Responders/AuditLogCreatedResponder.cs new file mode 100644 index 000000000..7db9beb52 --- /dev/null +++ b/src/Modix.Bot/Responders/AuditLogCreatedResponder.cs @@ -0,0 +1,51 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; +using MediatR; +using Modix.Bot.Notifications; +using Modix.Data.Models.Moderation; +using Modix.Services.Core; +using Modix.Services.Moderation; +using Modix.Services.Utilities; + +namespace Modix.Bot.Responders; + +public class AuditLogCreatedResponder : + INotificationHandler +{ + private readonly ModerationService _moderationService; + private readonly IAuthorizationService _authorizationService; + + public AuditLogCreatedResponder( + ModerationService moderationService, + IAuthorizationService authorizationService) + { + _moderationService = moderationService; + _authorizationService = authorizationService; + } + + public async Task Handle(AuditLogCreatedNotificationV3 notification, CancellationToken cancellationToken) + { + if (notification.Entry.Action == ActionType.Ban && notification.Entry.Data is SocketBanAuditLogData data) + await EnsureBanInfractionExists(notification.Guild, notification.Entry, data); + } + + private async Task EnsureBanInfractionExists(SocketGuild guild, SocketAuditLogEntry entry, SocketBanAuditLogData data) + { + var bannedUser = await data.Target.GetOrDownloadAsync(); + + if (await _moderationService.AnyActiveInfractions(guild.Id, bannedUser.Id, InfractionType.Ban)) + return; + + var reason = string.IsNullOrWhiteSpace(entry.Reason) + ? $"Banned by {entry.User.GetDisplayName()}." + : entry.Reason; + + var moderator = guild.GetUser(entry.User.Id); + + await _authorizationService.OnAuthenticatedAsync(moderator.Id, moderator.Guild.Id, moderator.Roles.Select(x => x.Id).ToList()); + await _moderationService.CreateInfractionAsync(guild.Id, entry.User.Id, InfractionType.Ban, bannedUser.Id, reason, null); + } +} diff --git a/src/Modix.Bot/Behaviors/CommandListeningBehavior.cs b/src/Modix.Bot/Responders/CommandResponder.cs similarity index 87% rename from src/Modix.Bot/Behaviors/CommandListeningBehavior.cs rename to src/Modix.Bot/Responders/CommandResponder.cs index c4c1a65ad..961400762 100644 --- a/src/Modix.Bot/Behaviors/CommandListeningBehavior.cs +++ b/src/Modix.Bot/Responders/CommandResponder.cs @@ -7,19 +7,21 @@ using MediatR; using Modix.Bot.Notifications; using Modix.Bot.Responders.CommandErrors; +using Modix.Services; using Modix.Services.Core; using Serilog; using Stopwatch = System.Diagnostics.Stopwatch; -namespace Modix.Bot.Behaviors; +namespace Modix.Bot.Responders; -public class CommandListeningBehavior( +public class CommandResponder( ICommandPrefixParser commandPrefixParser, IServiceProvider serviceProvider, CommandService commandService, CommandErrorService commandErrorService, IDiscordClient discordClient, - IAuthorizationService authorizationService) : INotificationHandler + IAuthorizationService authorizationService, + IScopedSession scopedSession) : INotificationHandler { public async Task Handle(MessageReceivedNotificationV3 notification, CancellationToken cancellationToken = default) { @@ -46,6 +48,9 @@ public async Task Handle(MessageReceivedNotificationV3 notification, Cancellatio var commandContext = new CommandContext(discordClient, userMessage); + var discordSession = (DiscordBotSession)scopedSession; + discordSession.ApplyCommandContext(commandContext); + await authorizationService.OnAuthenticatedAsync(author.Id, author.Guild.Id, author.RoleIds.ToList()); var commandResult = await commandService.ExecuteAsync(commandContext, argPos.Value, serviceProvider); diff --git a/src/Modix.Bot/Responders/GuildOnboardingResponder.cs b/src/Modix.Bot/Responders/GuildOnboardingResponder.cs new file mode 100644 index 000000000..1d509521c --- /dev/null +++ b/src/Modix.Bot/Responders/GuildOnboardingResponder.cs @@ -0,0 +1,38 @@ +using System.Threading; +using System.Threading.Tasks; +using Discord; +using MediatR; +using Modix.Bot.Notifications; +using Modix.Services.Moderation; + +namespace Modix.Bot.Responders; + +public class GuildOnboardingResponder(GuildOnboardingService onboardingService) : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler +{ + public async Task Handle(GuildAvailableNotificationV3 notification, CancellationToken cancellationToken) + { + await onboardingService.AutoConfigureGuild(notification.Guild); + } + + public async Task Handle(ChannelCreatedNotificationV3 notification, CancellationToken cancellationToken) + { + await onboardingService.AutoConfigureChannel(notification.Channel); + } + + public async Task Handle(ChannelUpdatedNotificationV3 notification, CancellationToken cancellationToken) + { + await onboardingService.AutoConfigureChannel(notification.NewChannel); + } + + public async Task Handle(JoinedGuildNotificationV3 notification, CancellationToken cancellationToken) + { + if (notification.Guild is IGuild { Available: true }) + { + await onboardingService.AutoConfigureGuild(notification.Guild); + } + } +} diff --git a/src/Modix.Bot/Responders/MutedUserJoinedResponder.cs b/src/Modix.Bot/Responders/MutedUserJoinedResponder.cs new file mode 100644 index 000000000..da2c08926 --- /dev/null +++ b/src/Modix.Bot/Responders/MutedUserJoinedResponder.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Discord.WebSocket; +using MediatR; +using Modix.Bot.Notifications; +using Modix.Data.Models.Moderation; +using Modix.Services.Moderation; +using Serilog; + +namespace Modix.Bot.Responders; + +public class MutedUserJoinedResponder(ModerationService moderationService) + : INotificationHandler +{ + public Task Handle(UserJoinedNotificationV3 notification, CancellationToken cancellationToken) + => MuteUser(notification.GuildUser); + + private async Task MuteUser(SocketGuildUser guildUser) + { + if (!await moderationService.AnyActiveInfractions(guildUser.Guild.Id, guildUser.Id, InfractionType.Mute)) + { + return; + } + + var muteRole = guildUser.Guild.Roles.FirstOrDefault(x => x.Name == ModerationService.MUTE_ROLE_NAME); + + if (muteRole is null) + { + return; + } + + await guildUser.AddRoleAsync(muteRole); + + Log.Debug("User {UserId} was muted, because they have an active mute infraction", guildUser.Id); + } +} diff --git a/src/Modix.Services/AuthorizationClaimMappingService.cs b/src/Modix.Services/AuthorizationClaimMappingService.cs new file mode 100644 index 000000000..f48169bd8 --- /dev/null +++ b/src/Modix.Services/AuthorizationClaimMappingService.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using Modix.Data; +using Modix.Data.Models.Core; + +namespace Modix.Services; + +public class AuthorizationClaimMappingService(ModixContext db, IScopedSession scopedSession) +{ + public async Task AddClaimForRole(ulong guildId, ulong roleId, AuthorizationClaim claim) + { + var entity = new ClaimMappingEntity + { + GuildId = guildId, + RoleId = roleId, + Claim = claim, + Type = ClaimMappingType.Granted, + CreateAction = new ConfigurationActionEntity + { + GuildId = guildId, + Type = ConfigurationActionType.ClaimMappingCreated, + Created = DateTimeOffset.UtcNow, + CreatedById = scopedSession.ExecutingUserId + } + }; + + db.Add(entity); + + await db.SaveChangesAsync(); + } +} diff --git a/src/Modix.Services/AuthorizationClaimService.cs b/src/Modix.Services/AuthorizationClaimService.cs new file mode 100644 index 000000000..2ca4e6248 --- /dev/null +++ b/src/Modix.Services/AuthorizationClaimService.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Modix.Data; +using Modix.Data.Models.Core; + +namespace Modix.Services; + +public class AuthorizationClaimService(ModixContext db) +{ + public async Task> GetClaimsForUser(ulong userId) + { + return await db.Set() + .Where(x => x.UserId == userId) + .Where(x => x.Type == ClaimMappingType.Granted) + .Where(x => x.DeleteActionId == null) + .Select(x => x.Claim) + .ToListAsync(); + } +} diff --git a/src/Modix.Services/Core/AuthorizationAutoConfigBehavior.cs b/src/Modix.Services/Core/AuthorizationAutoConfigBehavior.cs deleted file mode 100644 index fa51aaaa5..000000000 --- a/src/Modix.Services/Core/AuthorizationAutoConfigBehavior.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -using Discord; - -using Modix.Common.Messaging; - -namespace Modix.Services.Core -{ - /// - /// Automatically performs configuration necessary for an to work. - /// This includes seeding the system with authorization claim mappings for guild administrators, if no claims are present - /// so that guild administrators have the ability to configure authorization manually. - /// - public class AuthorizationAutoConfigBehavior - : INotificationHandler, - INotificationHandler - { - /// - /// Constructs a new object, with the given dependencies. - /// - public AuthorizationAutoConfigBehavior( - IAuthorizationService authorizationService) - { - _authorizationService = authorizationService; - } - - /// - public Task HandleNotificationAsync(GuildAvailableNotification notification, CancellationToken cancellationToken = default) - => _authorizationService.AutoConfigureGuildAsync(notification.Guild, cancellationToken); - - /// - public Task HandleNotificationAsync(JoinedGuildNotification notification, CancellationToken cancellationToken = default) - => ((IGuild)notification.Guild).Available - ? _authorizationService.AutoConfigureGuildAsync(notification.Guild, cancellationToken) - : Task.CompletedTask; - - private readonly IAuthorizationService _authorizationService; - } -} diff --git a/src/Modix.Services/Core/AuthorizationService.cs b/src/Modix.Services/Core/AuthorizationService.cs index 46fb314eb..90c4f9946 100644 --- a/src/Modix.Services/Core/AuthorizationService.cs +++ b/src/Modix.Services/Core/AuthorizationService.cs @@ -33,15 +33,6 @@ public interface IAuthorizationService /// ulong? CurrentGuildId { get; } - /// - /// Automatically configures default claim mappings for a guild, if none yet exist. - /// Default claims include granting all existing claims to any role that has the Discord "Administrate" permission. - /// - /// The guild to be configured. - /// A that may be used to cancel the returned . - /// A that will complete when the operation has completed. - Task AutoConfigureGuildAsync(IGuild guild, CancellationToken cancellationToken = default); - /// /// Modifies a claim mapping for a role. /// @@ -223,40 +214,6 @@ public AuthorizationService(DiscordSocketClient discordSocketClient, IServicePro /// public IReadOnlyCollection CurrentClaims { get; internal protected set; } - /// - public async Task AutoConfigureGuildAsync(IGuild guild, CancellationToken cancellationToken = default) - { - if (await ClaimMappingRepository.AnyAsync(new ClaimMappingSearchCriteria() - { - GuildId = guild.Id, - IsDeleted = false, - })) - { - return; - } - - var selfUser = _discordSocketClient.CurrentUser; - - // Need the bot user to exist, before we start adding claims, created by the bot user. - await UserService.TrackUserAsync(guild, selfUser.Id); - - using var transaction = await ClaimMappingRepository.BeginCreateTransactionAsync(); - - foreach (var claim in Enum.GetValues(typeof(AuthorizationClaim)).Cast()) - foreach (var role in guild.Roles.Where(x => x.Permissions.Administrator)) - await ClaimMappingRepository.CreateAsync(new ClaimMappingCreationData() - { - Type = ClaimMappingType.Granted, - GuildId = guild.Id, - RoleId = role.Id, - UserId = null, - Claim = claim, - CreatedById = selfUser.Id - }); - - transaction.Commit(); - } - /// public async Task ModifyClaimMappingAsync(ulong roleId, AuthorizationClaim claim, ClaimMappingType? newType) { diff --git a/src/Modix.Services/Core/CoreSetup.cs b/src/Modix.Services/Core/CoreSetup.cs index dfa3f044f..a24ad6476 100644 --- a/src/Modix.Services/Core/CoreSetup.cs +++ b/src/Modix.Services/Core/CoreSetup.cs @@ -22,9 +22,6 @@ public static IServiceCollection AddModixCore(this IServiceCollection services) => services .AddSingleton() .AddScoped() - .AddScoped() - .AddScoped>(x => x.GetService()) - .AddScoped>(x => x.GetService()) .AddScoped() .AddScoped() .AddScoped>(x => x.GetService()) diff --git a/src/Modix.Services/IScopedSession.cs b/src/Modix.Services/IScopedSession.cs new file mode 100644 index 000000000..b79b02bc5 --- /dev/null +++ b/src/Modix.Services/IScopedSession.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Modix.Data.Models.Core; + +namespace Modix.Services; + +public interface IScopedSession +{ + ulong SelfUserId { get; } + ulong ExecutingUserId { get; } + Task HasClaim(params AuthorizationClaim[] claims); +} diff --git a/src/Modix.Services/Moderation/AttachmentBlacklistBehavior.cs b/src/Modix.Services/Moderation/AttachmentBlacklistBehavior.cs index 21ffe8783..527781e46 100644 --- a/src/Modix.Services/Moderation/AttachmentBlacklistBehavior.cs +++ b/src/Modix.Services/Moderation/AttachmentBlacklistBehavior.cs @@ -98,7 +98,7 @@ public AttachmentBlacklistBehavior( DesignatedChannelService designatedChannelService, DiscordSocketClient discordSocketClient, ILogger logger, - IModerationService moderationService) + ModerationService moderationService) { _designatedChannelService = designatedChannelService; _discordSocketClient = discordSocketClient; @@ -183,6 +183,6 @@ await message.Channel.SendMessageAsync( private readonly DesignatedChannelService _designatedChannelService; private readonly DiscordSocketClient _discordSocketClient; private readonly ILogger _logger; - private readonly IModerationService _moderationService; + private readonly ModerationService _moderationService; } } diff --git a/src/Modix.Services/Moderation/GuildOnboardingService.cs b/src/Modix.Services/Moderation/GuildOnboardingService.cs new file mode 100644 index 000000000..a64adec39 --- /dev/null +++ b/src/Modix.Services/Moderation/GuildOnboardingService.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Net; +using Microsoft.EntityFrameworkCore; +using Modix.Data; +using Modix.Data.Models.Core; +using Modix.Services.Core; +using Serilog; + +namespace Modix.Services.Moderation; + +public class GuildOnboardingService( + ModixContext db, + IScopedSession scopedSession, + IRoleService roleService, + IUserService userService, + DesignatedChannelService designatedChannelService, + AuthorizationClaimMappingService authorizationClaimService) +{ + private static readonly OverwritePermissions _mutePermissions + = new( + addReactions: PermValue.Deny, + requestToSpeak: PermValue.Deny, + sendMessages: PermValue.Deny, + sendMessagesInThreads: PermValue.Deny, + speak: PermValue.Deny, + usePrivateThreads: PermValue.Deny, + usePublicThreads: PermValue.Deny); + + public async Task AutoConfigureGuild(IGuild guild) + { + await userService.TrackUserAsync(guild, scopedSession.ExecutingUserId); + await EnsureMuteRoleIsConfigured(guild); + await EnsureClaimsAreConfigured(guild); + } + + public async Task AutoConfigureChannel(IChannel channel) + { + if (channel is IGuildChannel guildChannel) + { + var isUnmoderated = await designatedChannelService.ChannelHasDesignation(guildChannel.Guild.Id, + channel.Id, DesignatedChannelType.Unmoderated, default); + + if (isUnmoderated) + { + return; + } + + var muteRole = await EnsureMuteRuleExists(guildChannel.Guild); + await ConfigureChannelMuteRolePermissions(guildChannel, muteRole); + } + } + + private async Task EnsureClaimsAreConfigured(IGuild guild) + { + var hasClaimsInGuild = await db + .Set() + .Where(x => x.GuildId == guild.Id) + .Where(x => x.DeleteActionId == null) + .AnyAsync(); + + if (hasClaimsInGuild) + return; // Already configured, probably + + foreach (var claim in Enum.GetValues()) + foreach (var role in guild.Roles.Where(x => x.Permissions.Administrator)) + { + await authorizationClaimService.AddClaimForRole(guild.Id, role.Id, claim); + } + } + + private async Task EnsureMuteRoleIsConfigured(IGuild guild) + { + var muteRole = await EnsureMuteRuleExists(guild); + + var channelsInGuild = await guild.GetChannelsAsync(); + + var unmoderatedChannels = await designatedChannelService.GetDesignatedChannelIds(guild.Id, + DesignatedChannelType.Unmoderated); + + var nonCategoryChannels = + channelsInGuild + .Where(c => c is (ITextChannel or IVoiceChannel) and not IThreadChannel) + .Where(c => !unmoderatedChannels.Contains(c.Id)) + .ToList(); + + var setUpChannels = new List(); + + try + { + foreach (var channel in nonCategoryChannels) + { + setUpChannels.Add(channel); + await ConfigureChannelMuteRolePermissions(channel, muteRole); + } + } + catch (HttpException ex) + { + var errorTemplate = + "An exception was thrown when attempting to set up the mute role {Role} for guild {Guild}, channel #{Channel}. " + + "This is likely due to Modix not having the \"Manage Permissions\" permission - please check your server settings."; + + Log.Error(ex, errorTemplate, muteRole.Name, guild.Name, setUpChannels.Last().Name); + + return; + } + + Log.Information("Successfully configured mute role @{MuteRole} for {ChannelCount} channels: {Channels}", + muteRole.Name, nonCategoryChannels.Count, nonCategoryChannels.Select(c => c.Name)); + } + + private async Task EnsureMuteRuleExists(IGuild guild) + { + var hasRoleMapping = await db.Set() + .Where(x => x.GuildId == guild.Id) + .Where(x => x.Type == DesignatedRoleType.ModerationMute) + .Where(x => x.DeleteActionId == null) + .AnyAsync(); + + var role = guild.Roles.FirstOrDefault(x => x.Name == ModerationService.MUTE_ROLE_NAME) + ?? await guild.CreateRoleAsync(ModerationService.MUTE_ROLE_NAME, isMentionable: false); + + if (hasRoleMapping) + { + var entity = new DesignatedRoleMappingEntity + { + GuildId = guild.Id, + RoleId = role.Id, + Type = DesignatedRoleType.ModerationMute, + CreateAction = new ConfigurationActionEntity + { + GuildId = guild.Id, + Type = ConfigurationActionType.DesignatedRoleMappingCreated, + Created = DateTimeOffset.UtcNow, + CreatedById = scopedSession.ExecutingUserId, + } + }; + + db.Add(entity); + await db.SaveChangesAsync(); + } + + await roleService.TrackRoleAsync(role, default); + + return role; + } + + private static async Task ConfigureChannelMuteRolePermissions(IGuildChannel channel, IRole muteRole) + { + try + { + var permissionOverwrite = channel.GetPermissionOverwrite(muteRole); + + if (permissionOverwrite is null || _mutePermissions.ToDenyList().Any(x => !permissionOverwrite.GetValueOrDefault().ToDenyList().Contains(x))) + { + await channel.AddPermissionOverwriteAsync(muteRole, _mutePermissions, new() { AuditLogReason = "Setting mute role permissions." }); + Log.Debug("Set mute permissions for role {Role} in channel #{Channel}", muteRole.Name, channel.Name); + } + else + { + Log.Debug("Skipping setting mute permissions for channel #{Channel} as they're already set", channel.Name); + } + } + catch (Exception e) + { + Log.Error(e, "Failed setting channel mute role on #{Channel}", channel.Name); + throw; + } + } +} diff --git a/src/Modix.Services/Moderation/InfractionSyncingHandler.cs b/src/Modix.Services/Moderation/InfractionSyncingHandler.cs deleted file mode 100644 index 8e21cc204..000000000 --- a/src/Modix.Services/Moderation/InfractionSyncingHandler.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using Discord; -using Discord.WebSocket; - -using Modix.Common.Messaging; -using Modix.Data.Models.Moderation; -using Modix.Services.Core; -using Modix.Services.Utilities; - -namespace Modix.Services.Moderation -{ - /// - /// Implements a handler that synchronizes infractions when applied manually through the Discord UI instead of through MODiX. - /// - public class InfractionSyncingHandler : - INotificationHandler - { - private readonly IModerationService _moderationService; - private readonly IAuthorizationService _authorizationService; - - /// - /// Constructs a new object with the given injected dependencies. - /// - /// A moderation service to interact with the infractions system. - /// A REST client to interact with the Discord API. - public InfractionSyncingHandler( - IModerationService moderationService, - IAuthorizationService authorizationService) - { - _moderationService = moderationService; - _authorizationService = authorizationService; - } - - public async Task HandleNotificationAsync(AuditLogCreatedNotification notification, CancellationToken cancellationToken) - { - if (notification.Entry.Action == ActionType.Ban && notification.Entry.Data is SocketBanAuditLogData data) - await TryCreateBanInfractionAsync(notification.Guild, notification.Entry, data); - } - - private async Task TryCreateBanInfractionAsync(SocketGuild guild, SocketAuditLogEntry entry, SocketBanAuditLogData data) - { - var bannedUser = await data.Target.GetOrDownloadAsync(); - - if (await _moderationService.AnyInfractionsAsync(GetBanSearchCriteria(guild, bannedUser))) - return; - - var reason = string.IsNullOrWhiteSpace(entry.Reason) - ? $"Banned by {entry.User.GetDisplayName()}." - : entry.Reason; - - var moderator = guild.GetUser(entry.User.Id); - - await _authorizationService.OnAuthenticatedAsync(moderator.Id, moderator.Guild.Id, moderator.Roles.Select(x => x.Id).ToList()); - await _moderationService.CreateInfractionAsync(guild.Id, entry.User.Id, InfractionType.Ban, bannedUser.Id, reason, null); - } - - private static InfractionSearchCriteria GetBanSearchCriteria(IGuild guild, IUser user) - => new() - { - GuildId = guild.Id, - SubjectId = user.Id, - Types = new[] { InfractionType.Ban }, - IsDeleted = false, - IsRescinded = false, - }; - } -} diff --git a/src/Modix.Services/Moderation/MessageContentCheckBehaviour.cs b/src/Modix.Services/Moderation/MessageContentCheckBehaviour.cs index 70129c2fc..42c3bdb17 100644 --- a/src/Modix.Services/Moderation/MessageContentCheckBehaviour.cs +++ b/src/Modix.Services/Moderation/MessageContentCheckBehaviour.cs @@ -20,7 +20,7 @@ public class MessageContentCheckBehaviour : { private readonly DesignatedChannelService _designatedChannelService; private readonly IAuthorizationService _authorizationService; - private readonly IModerationService _moderationService; + private readonly ModerationService _moderationService; private readonly IMessageContentPatternService _messageContentPatternService; private readonly DiscordSocketClient _discordSocketClient; @@ -28,7 +28,7 @@ public MessageContentCheckBehaviour( DesignatedChannelService designatedChannelService, DiscordSocketClient discordSocketClient, IAuthorizationService authorizationService, - IModerationService moderationService, IMessageContentPatternService messageContentPatternService) + ModerationService moderationService, IMessageContentPatternService messageContentPatternService) { _designatedChannelService = designatedChannelService; _discordSocketClient = discordSocketClient; diff --git a/src/Modix.Services/Moderation/ModerationAutoConfigBehavior.cs b/src/Modix.Services/Moderation/ModerationAutoConfigBehavior.cs deleted file mode 100644 index 0560b9714..000000000 --- a/src/Modix.Services/Moderation/ModerationAutoConfigBehavior.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Threading.Tasks; - -using Discord; -using Discord.WebSocket; - -namespace Modix.Services.Moderation -{ - /// - /// Implements a behavior that automatically performs configuration necessary for an to work. - /// - public class ModerationAutoConfigBehavior : BehaviorBase - { - // TODO: Abstract DiscordSocketClient to DiscordSocketClient, or something, to make this testable - /// - /// Constructs a new object, with the given injected dependencies. - /// See for more details. - /// - /// The value to use for . - /// See . - public ModerationAutoConfigBehavior(DiscordSocketClient discordClient, IServiceProvider serviceProvider) - : base(serviceProvider) - { - DiscordClient = discordClient; - } - - /// - internal protected override Task OnStartingAsync() - { - DiscordClient.GuildAvailable += OnGuildAvailableAsync; - DiscordClient.ChannelCreated += OnChannelCreated; - DiscordClient.ChannelUpdated += OnChannelUpdated; - DiscordClient.LeftGuild += OnLeftGuild; - - return Task.CompletedTask; - } - - /// - internal protected override Task OnStoppedAsync() - { - DiscordClient.GuildAvailable -= OnGuildAvailableAsync; - DiscordClient.ChannelCreated -= OnChannelCreated; - DiscordClient.ChannelUpdated -= OnChannelUpdated; - DiscordClient.LeftGuild -= OnLeftGuild; - - return Task.CompletedTask; - } - - /// - internal protected override void Dispose(bool disposeManaged) - { - if (disposeManaged && IsRunning) - OnStoppedAsync(); - - base.Dispose(disposeManaged); - } - - // TODO: Abstract DiscordSocketClient to DiscordSocketClient, or something, to make this testable - /// - /// A for interacting with, and receiving events from, the Discord API. - /// - internal protected DiscordSocketClient DiscordClient { get; } - - private Task OnGuildAvailableAsync(IGuild guild) - => SelfExecuteRequest(x => x.AutoConfigureGuildAsync(guild)); - - private Task OnChannelCreated(IChannel channel) - => SelfExecuteRequest(x => x.AutoConfigureChannelAsync(channel)); - - private Task OnChannelUpdated(IChannel oldChannel, IChannel newChannel) - => SelfExecuteRequest(x => x.AutoConfigureChannelAsync(newChannel)); - - private Task OnLeftGuild(IGuild guild) - => SelfExecuteRequest(x => x.UnConfigureGuildAsync(guild)); - } -} diff --git a/src/Modix.Services/Moderation/ModerationAutoRescindBehavior.cs b/src/Modix.Services/Moderation/ModerationAutoRescindBehavior.cs index 403c2595b..4d6b1406b 100644 --- a/src/Modix.Services/Moderation/ModerationAutoRescindBehavior.cs +++ b/src/Modix.Services/Moderation/ModerationAutoRescindBehavior.cs @@ -86,7 +86,7 @@ private Task OnDiscordClientConnected() private void OnUpdateTimerElapsed(object sender, ElapsedEventArgs e) { #pragma warning disable CS4014 - SelfExecuteRequest(async moderationService => + SelfExecuteRequest(async moderationService => { await moderationService.AutoRescindExpiredInfractions(); diff --git a/src/Modix.Services/Moderation/ModerationService.cs b/src/Modix.Services/Moderation/ModerationService.cs index 006f712fa..7dea9e2f3 100644 --- a/src/Modix.Services/Moderation/ModerationService.cs +++ b/src/Modix.Services/Moderation/ModerationService.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using Discord; -using Discord.Net; -using Discord.WebSocket; using Modix.Data.Models; using Modix.Data.Models.Core; using Modix.Data.Models.Moderation; @@ -14,244 +12,75 @@ using Modix.Services.Utilities; using Serilog; using System.Threading; - -namespace Modix.Services.Moderation +using Microsoft.EntityFrameworkCore; +using Modix.Data; + +namespace Modix.Services.Moderation; + +public class ModerationService( + IDiscordClient discordClient, + IAuthorizationService authorizationService, + IChannelService channelService, + IUserService userService, + IModerationActionRepository moderationActionRepository, + IDesignatedRoleMappingRepository designatedRoleMappingRepository, + IInfractionRepository infractionRepository, + IDeletedMessageRepository deletedMessageRepository, + IDeletedMessageBatchRepository deletedMessageBatchRepository, + ModixContext db) { - public interface IModerationService - { - Task AutoConfigureGuildAsync(IGuild guild); - - Task AutoConfigureChannelAsync(IChannel channel); - - Task AutoRescindExpiredInfractions(); - - Task UnConfigureGuildAsync(IGuild guild); - - Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId, - string reason, TimeSpan? duration); - - Task RescindInfractionAsync(long infractionId, string? reason = null); - - Task RescindInfractionAsync(InfractionType type, ulong guildId, ulong subjectId, string? reason = null); - - Task DeleteInfractionAsync(long infractionId); - - Task DeleteMessageAsync(IMessage message, string reason, ulong deletedById, - CancellationToken cancellationToken); - - Task DeleteMessagesAsync(ITextChannel channel, int count, bool skipOne, Func> confirmDelegate); - - Task DeleteMessagesAsync(ITextChannel channel, IGuildUser user, int count, Func> confirmDelegate); - - Task> SearchDeletedMessagesAsync(DeletedMessageSearchCriteria searchCriteria, - IEnumerable sortingCriteria, PagingCriteria pagingCriteria); - - Task> SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, - IEnumerable? sortingCriterias = null); - - Task> SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, - IEnumerable sortingCriteria, PagingCriteria pagingCriteria); - - Task> GetInfractionCountsForUserAsync(ulong subjectId); - - Task GetModerationActionSummaryAsync(long moderationActionId); + public const string MUTE_ROLE_NAME = "MODiX_Moderation_Mute"; + private const int MaxReasonLength = 1000; - Task GetNextInfractionExpiration(); - - Task DoesModeratorOutrankUserAsync(ulong guildId, ulong moderatorId, ulong subjectId); - - Task AnyInfractionsAsync(InfractionSearchCriteria criteria); - - Task GetOrCreateDesignatedMuteRoleAsync(ulong guildId, ulong currentUserId); - - Task GetOrCreateDesignatedMuteRoleAsync(IGuild guild, ulong currentUserId); - - Task<(bool success, string? errorMessage)> UpdateInfractionAsync(long infractionId, string newReason, - ulong currentUserId); - } - - public class ModerationService : IModerationService + public async Task AutoRescindExpiredInfractions() { - private readonly IDiscordClient _discordClient; - private readonly IAuthorizationService _authorizationService; - private readonly IChannelService _channelService; - private readonly IUserService _userService; - private readonly IModerationActionRepository _moderationActionRepository; - private readonly IInfractionRepository _infractionRepository; - private readonly IDesignatedRoleMappingRepository _designatedRoleMappingRepository; - private readonly IDeletedMessageRepository _deletedMessageRepository; - private readonly IDeletedMessageBatchRepository _deletedMessageBatchRepository; - private readonly IRoleService _roleService; - private readonly DesignatedChannelService _designatedChannelService; - - // TODO: Push this to a bot-wide config? Or maybe on a per-guild basis, but with a bot-wide default, that's pulled from config? - private const string MuteRoleName = "MODiX_Moderation_Mute"; - - private const int MaxReasonLength = 1000; - - public ModerationService( - IDiscordClient discordClient, - IAuthorizationService authorizationService, - IChannelService channelService, - IUserService userService, - IModerationActionRepository moderationActionRepository, - IDesignatedRoleMappingRepository designatedRoleMappingRepository, - IInfractionRepository infractionRepository, - IDeletedMessageRepository deletedMessageRepository, - IDeletedMessageBatchRepository deletedMessageBatchRepository, - IRoleService roleService, - DesignatedChannelService designatedChannelService) - { - _discordClient = discordClient; - _authorizationService = authorizationService; - _channelService = channelService; - _userService = userService; - _moderationActionRepository = moderationActionRepository; - _designatedRoleMappingRepository = designatedRoleMappingRepository; - _infractionRepository = infractionRepository; - _deletedMessageRepository = deletedMessageRepository; - _deletedMessageBatchRepository = deletedMessageBatchRepository; - _roleService = roleService; - _designatedChannelService = designatedChannelService; - } - - public async Task AutoConfigureGuildAsync(IGuild guild) - { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.DesignatedRoleMappingCreate); - - await SetUpMuteRole(guild); - } - - private async Task SetUpMuteRole(IGuild guild) - { - var muteRole = await GetOrCreateDesignatedMuteRoleAsync(guild, _authorizationService.CurrentUserId!.Value); - - var unmoderatedChannels = - await _designatedChannelService.GetDesignatedChannelIds(guild.Id, - DesignatedChannelType.Unmoderated); - - var nonCategoryChannels = - (await guild.GetChannelsAsync()) - .Where(c => c is (ITextChannel or IVoiceChannel) and not IThreadChannel) - .Where(c => !unmoderatedChannels.Contains(c.Id)) - .ToList(); - - var setUpChannels = new List(); - - try + var expiredInfractions = await db + .Set() + .Where(x => x.RescindActionId == null) + .Where(x => x.DeleteActionId == null) + .Where(x => x.CreateAction.Created + x.Duration <= DateTime.UtcNow) + .Select(x => new { - foreach (var channel in nonCategoryChannels) - { - setUpChannels.Add(channel); - await ConfigureChannelMuteRolePermissionsAsync(channel, muteRole); - } - } - catch (HttpException ex) - { - var errorTemplate = - "An exception was thrown when attempting to set up the mute role {Role} for guild {Guild}, channel #{Channel}. " + - "This is likely due to Modix not having the \"Manage Permissions\" permission - please check your server settings."; + x.Id, + x.GuildId, + x.SubjectId + }).ToListAsync(); - Log.Error(ex, errorTemplate, muteRole.Name, guild.Name, setUpChannels.Last().Name); - - return; - } - - Log.Information("Successfully configured mute role @{MuteRole} for {ChannelCount} channels: {Channels}", - muteRole.Name, nonCategoryChannels.Count, nonCategoryChannels.Select(c => c.Name)); - } - - public async Task AutoConfigureChannelAsync(IChannel channel) + foreach (var expiredInfraction in expiredInfractions) { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.DesignatedRoleMappingCreate); - - if (channel is IGuildChannel guildChannel) - { - var isUnmoderated = await _designatedChannelService.ChannelHasDesignation(guildChannel.Guild.Id, - channel.Id, DesignatedChannelType.Unmoderated, default); - - if (isUnmoderated) - { - return; - } - - var muteRole = - await GetOrCreateDesignatedMuteRoleAsync(guildChannel.Guild, - _authorizationService.CurrentUserId.Value); - - await ConfigureChannelMuteRolePermissionsAsync(guildChannel, muteRole); - } + await RescindInfractionAsync(expiredInfraction.Id, + expiredInfraction.GuildId, + expiredInfraction.SubjectId, + isAutoRescind: true); } + } - public async Task AutoRescindExpiredInfractions() - { - var expiredInfractions = await _infractionRepository.SearchSummariesAsync(new InfractionSearchCriteria() - { - ExpiresRange = new DateTimeOffsetRange() { To = DateTimeOffset.UtcNow }, - IsRescinded = false, - IsDeleted = false - }); + public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId, + string reason, TimeSpan? duration) + { + authorizationService.RequireClaims(_createInfractionClaimsByType[type]); - foreach (var expiredInfraction in expiredInfractions) - { - await RescindInfractionAsync(expiredInfraction.Id, expiredInfraction.GuildId, - expiredInfraction.Subject.Id, isAutoRescind: true); - } - } + if (reason is null) + throw new ArgumentNullException(nameof(reason)); - public async Task UnConfigureGuildAsync(IGuild guild) - { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.DesignatedRoleMappingDelete); + if (reason.Length >= MaxReasonLength) + throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length", + nameof(reason)); - using (var transaction = await _designatedRoleMappingRepository.BeginDeleteTransactionAsync()) - { - foreach (var mapping in await _designatedRoleMappingRepository - .SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria() - { - GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false, - })) - { - await _designatedRoleMappingRepository.TryDeleteAsync(mapping.Id, - _authorizationService.CurrentUserId.Value); + if (((type == InfractionType.Notice) || (type == InfractionType.Warning)) + && string.IsNullOrWhiteSpace(reason)) + throw new InvalidOperationException($"{type.ToString()} infractions require a reason to be given"); - var role = guild.Roles.FirstOrDefault(x => x.Id == mapping.Role.Id); - if ((role != null) && (role.Name == MuteRoleName) && (role is IDeletable deletable)) - await deletable.DeleteAsync(); - } + var guild = await discordClient.GetGuildAsync(guildId); + var subject = await userService.TryGetGuildUserAsync(guild, subjectId, default); - transaction.Commit(); - } - } + await RequireSubjectRankLowerThanModeratorRankAsync(guild, moderatorId, subject); - public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId, - string reason, TimeSpan? duration) + using (var transaction = await infractionRepository.BeginCreateTransactionAsync()) { - _authorizationService.RequireClaims(_createInfractionClaimsByType[type]); - - if (reason is null) - throw new ArgumentNullException(nameof(reason)); - - if (reason.Length >= MaxReasonLength) - throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length", - nameof(reason)); - - if (((type == InfractionType.Notice) || (type == InfractionType.Warning)) - && 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, default); - - await RequireSubjectRankLowerThanModeratorRankAsync(guild, moderatorId, subject); - - using (var transaction = await _infractionRepository.BeginCreateTransactionAsync()) + if ((type == InfractionType.Mute) || (type == InfractionType.Ban)) { - if ((type == InfractionType.Mute) || (type == InfractionType.Ban)) - { - if (await _infractionRepository.AnyAsync(new InfractionSearchCriteria() + if (await infractionRepository.AnyAsync(new InfractionSearchCriteria() { GuildId = guildId, Types = new[] {type}, @@ -259,547 +88,481 @@ public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, Infrac IsRescinded = false, IsDeleted = false })) - throw new InvalidOperationException( - $"Discord user {subjectId} already has an active {type} infraction"); - } - - await _infractionRepository.CreateAsync( - new InfractionCreationData() - { - GuildId = guildId, - Type = type, - SubjectId = subjectId, - Reason = reason, - Duration = duration, - CreatedById = moderatorId - }); - - transaction.Commit(); + throw new InvalidOperationException( + $"Discord user {subjectId} already has an active {type} infraction"); } - // TODO: Implement ModerationSyncBehavior to listen for mutes and bans that happen directly in Discord, instead of through bot commands, - // and to read the Discord Audit Log to check for mutes and bans that were missed during downtime, and add all such actions to - // the Infractions and ModerationActions repositories. - // 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; + await infractionRepository.CreateAsync( + new InfractionCreationData() + { + GuildId = guildId, + Type = type, + SubjectId = subjectId, + Reason = reason, + Duration = duration, + CreatedById = moderatorId + }); - case InfractionType.Ban: - await guild.AddBanAsync(subjectId, reason: reason); - break; - } + transaction.Commit(); } - public async Task RescindInfractionAsync(long infractionId, string? reason = null) - { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); - - var infraction = await _infractionRepository.ReadSummaryAsync(infractionId); - await DoRescindInfractionAsync(infraction!.Type, infraction.GuildId, infraction.Subject.Id, infraction, - reason); - } + // TODO: Implement ModerationSyncBehavior to listen for mutes and bans that happen directly in Discord, instead of through bot commands, + // and to read the Discord Audit Log to check for mutes and bans that were missed during downtime, and add all such actions to + // the Infractions and ModerationActions repositories. + // Note that we'll need to upgrade to the latest Discord.NET version to get access to the audit log. - /// - public async Task RescindInfractionAsync(InfractionType type, ulong guildId, ulong subjectId, - string? reason = null) + // Assuming that our Infractions repository is always correct, regarding the state of the Discord API. + switch (type) { - _authorizationService.RequireAuthenticatedGuild(); - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); - - if (reason?.Length >= MaxReasonLength) - throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length", - nameof(reason)); - - var infraction = (await _infractionRepository.SearchSummariesAsync( - new InfractionSearchCriteria() - { - GuildId = _authorizationService.CurrentGuildId.Value, - Types = new[] {type}, - SubjectId = subjectId, - IsRescinded = false, - IsDeleted = false, - })).FirstOrDefault(); - - await DoRescindInfractionAsync(type, guildId, subjectId, infraction, reason); + 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; } + } - private async Task RescindInfractionAsync(long infractionId, ulong guildId, ulong subjectId, - string? reason = null, bool isAutoRescind = false) - { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); + public async Task RescindInfractionAsync(long infractionId, string? reason = null) + { + authorizationService.RequireAuthenticatedUser(); + authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); - var infraction = await _infractionRepository.ReadSummaryAsync(infractionId); + var infraction = await infractionRepository.ReadSummaryAsync(infractionId); + await DoRescindInfractionAsync(infraction!.Type, infraction.GuildId, infraction.Subject.Id, infraction, + reason); + } - await DoRescindInfractionAsync(infraction!.Type, guildId, subjectId, infraction, reason, isAutoRescind); - } + /// + public async Task RescindInfractionAsync(InfractionType type, ulong guildId, ulong subjectId, + string? reason = null) + { + authorizationService.RequireAuthenticatedGuild(); + authorizationService.RequireAuthenticatedUser(); + authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); - public async Task DeleteInfractionAsync(long infractionId) - { - _authorizationService.RequireAuthenticatedUser(); - _authorizationService.RequireClaims(AuthorizationClaim.ModerationDeleteInfraction); + if (reason?.Length >= MaxReasonLength) + throw new ArgumentException($"Reason must be less than {MaxReasonLength} characters in length", + nameof(reason)); - var infraction = await _infractionRepository.ReadSummaryAsync(infractionId); + var infraction = (await infractionRepository.SearchSummariesAsync( + new InfractionSearchCriteria() + { + GuildId = authorizationService.CurrentGuildId.Value, + Types = new[] {type}, + SubjectId = subjectId, + IsRescinded = false, + IsDeleted = false, + })).FirstOrDefault(); - if (infraction == null) - throw new InvalidOperationException($"Infraction {infractionId} does not exist"); + await DoRescindInfractionAsync(type, guildId, subjectId, infraction, reason); + } - await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, - _authorizationService.CurrentUserId.Value, infraction.Subject.Id); + private async Task RescindInfractionAsync(long infractionId, ulong guildId, ulong subjectId, + string? reason = null, bool isAutoRescind = false) + { + authorizationService.RequireAuthenticatedUser(); + authorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); - await _infractionRepository.TryDeleteAsync(infraction.Id, _authorizationService.CurrentUserId.Value); + var infraction = await infractionRepository.ReadSummaryAsync(infractionId); - var guild = await _discordClient.GetGuildAsync(infraction.GuildId); + await DoRescindInfractionAsync(infraction!.Type, guildId, subjectId, infraction, reason, isAutoRescind); + } - switch (infraction.Type) - { - case InfractionType.Mute: + public async Task DeleteInfractionAsync(long infractionId) + { + authorizationService.RequireAuthenticatedUser(); + authorizationService.RequireClaims(AuthorizationClaim.ModerationDeleteInfraction); - 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); - } + var infraction = await infractionRepository.ReadSummaryAsync(infractionId); - break; + if (infraction == null) + throw new InvalidOperationException($"Infraction {infractionId} does not exist"); - case InfractionType.Ban: + await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, + authorizationService.CurrentUserId.Value, infraction.Subject.Id); - //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); - } + await infractionRepository.TryDeleteAsync(infraction.Id, authorizationService.CurrentUserId.Value); - break; - } - } + var guild = await discordClient.GetGuildAsync(infraction.GuildId); - public async Task DeleteMessageAsync(IMessage message, string reason, ulong deletedById, - CancellationToken cancellationToken) + switch (infraction.Type) { - if (message.Channel is not IGuildChannel guildChannel) - throw new InvalidOperationException( - $"Cannot delete message {message.Id} because it is not a guild message"); + case InfractionType.Mute: - await _userService.TrackUserAsync((IGuildUser)message.Author, cancellationToken); - await _channelService.TrackChannelAsync(guildChannel.Name, guildChannel.Id, guildChannel.GuildId, guildChannel is IThreadChannel threadChannel ? threadChannel.CategoryId : null, cancellationToken); - - using var transaction = await _deletedMessageRepository.BeginCreateTransactionAsync(cancellationToken); - - await _deletedMessageRepository.CreateAsync( - new DeletedMessageCreationData() + if (await userService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) { - GuildId = guildChannel.GuildId, - ChannelId = guildChannel.Id, - MessageId = message.Id, - AuthorId = message.Author.Id, - Content = message.Content, - Reason = reason, - CreatedById = deletedById - }, cancellationToken); - - await message.DeleteAsync(new RequestOptions() {CancelToken = cancellationToken}); - - transaction.Commit(); - } + 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); + } - public async Task DeleteMessagesAsync(ITextChannel channel, int count, bool skipOne, - Func> confirmDelegate) - { - _authorizationService.RequireClaims(AuthorizationClaim.ModerationMassDeleteMessages); + break; - if (confirmDelegate is null) - throw new ArgumentNullException(nameof(confirmDelegate)); + case InfractionType.Ban: - if (!(channel is IGuildChannel guildChannel)) - throw new InvalidOperationException( - $"Cannot delete messages in {channel.Name} because it is not a guild channel."); + //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); + } - var confirmed = await confirmDelegate(); + break; + } + } - if (!confirmed) - return; + public async Task DeleteMessageAsync(IMessage message, string reason, ulong deletedById, + CancellationToken cancellationToken) + { + if (message.Channel is not IGuildChannel guildChannel) + throw new InvalidOperationException( + $"Cannot delete message {message.Id} because it is not a guild message"); - var clampedCount = Math.Clamp(count, 0, 100); + await userService.TrackUserAsync((IGuildUser)message.Author, cancellationToken); + await channelService.TrackChannelAsync(guildChannel.Name, guildChannel.Id, guildChannel.GuildId, guildChannel is IThreadChannel threadChannel ? threadChannel.CategoryId : null, cancellationToken); - if (clampedCount == 0) - return; + using var transaction = await deletedMessageRepository.BeginCreateTransactionAsync(cancellationToken); - var messages = skipOne - ? (await channel.GetMessagesAsync(clampedCount + 1).FlattenAsync()).Skip(1) - : await channel.GetMessagesAsync(clampedCount).FlattenAsync(); + await deletedMessageRepository.CreateAsync( + new DeletedMessageCreationData() + { + GuildId = guildChannel.GuildId, + ChannelId = guildChannel.Id, + MessageId = message.Id, + AuthorId = message.Author.Id, + Content = message.Content, + Reason = reason, + CreatedById = deletedById + }, cancellationToken); + + await message.DeleteAsync(new RequestOptions() {CancelToken = cancellationToken}); + + transaction.Commit(); + } - await DoDeleteMessagesAsync(channel, guildChannel, messages); - } + public async Task DeleteMessagesAsync(ITextChannel channel, int count, bool skipOne, + Func> confirmDelegate) + { + authorizationService.RequireClaims(AuthorizationClaim.ModerationMassDeleteMessages); - public async Task DeleteMessagesAsync(ITextChannel channel, IGuildUser user, int count, - Func> confirmDelegate) - { - _authorizationService.RequireClaims(AuthorizationClaim.ModerationMassDeleteMessages); + if (confirmDelegate is null) + throw new ArgumentNullException(nameof(confirmDelegate)); - if (confirmDelegate is null) - throw new ArgumentNullException(nameof(confirmDelegate)); + if (!(channel is IGuildChannel guildChannel)) + throw new InvalidOperationException( + $"Cannot delete messages in {channel.Name} because it is not a guild channel."); - if (!(channel is IGuildChannel guildChannel)) - throw new InvalidOperationException( - $"Cannot delete messages in {channel.Name} because it is not a guild channel."); + var confirmed = await confirmDelegate(); - var confirmed = await confirmDelegate(); + if (!confirmed) + return; - if (!confirmed) - return; + var clampedCount = Math.Clamp(count, 0, 100); - var clampedCount = Math.Clamp(count, 0, 100); + if (clampedCount == 0) + return; - if (clampedCount == 0) - return; + var messages = skipOne + ? (await channel.GetMessagesAsync(clampedCount + 1).FlattenAsync()).Skip(1) + : await channel.GetMessagesAsync(clampedCount).FlattenAsync(); - var messages = (await channel.GetMessagesAsync(100).FlattenAsync()).Where(x => x.Author.Id == user.Id) - .Take(clampedCount); + await DoDeleteMessagesAsync(channel, guildChannel, messages); + } - await DoDeleteMessagesAsync(channel, guildChannel, messages); - } + public async Task DeleteMessagesAsync(ITextChannel channel, IGuildUser user, int count, + Func> confirmDelegate) + { + authorizationService.RequireClaims(AuthorizationClaim.ModerationMassDeleteMessages); - public async Task> SearchDeletedMessagesAsync( - DeletedMessageSearchCriteria searchCriteria, IEnumerable sortingCriteria, - PagingCriteria pagingCriteria) - { - _authorizationService.RequireClaims(AuthorizationClaim.LogViewDeletedMessages); + if (confirmDelegate is null) + throw new ArgumentNullException(nameof(confirmDelegate)); - return await _deletedMessageRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, - pagingCriteria); - } + if (!(channel is IGuildChannel guildChannel)) + throw new InvalidOperationException( + $"Cannot delete messages in {channel.Name} because it is not a guild channel."); - public Task> SearchInfractionsAsync( - InfractionSearchCriteria searchCriteria, IEnumerable? sortingCriteria = null) - { - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); + var confirmed = await confirmDelegate(); - return _infractionRepository.SearchSummariesAsync(searchCriteria, sortingCriteria); - } + if (!confirmed) + return; - public Task> SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, - IEnumerable sortingCriteria, PagingCriteria pagingCriteria) - { - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); + var clampedCount = Math.Clamp(count, 0, 100); - return _infractionRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, pagingCriteria); - } + if (clampedCount == 0) + return; - public async Task> GetInfractionCountsForUserAsync(ulong subjectId) - { - _authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); + var messages = (await channel.GetMessagesAsync(100).FlattenAsync()).Where(x => x.Author.Id == user.Id) + .Take(clampedCount); - return await _infractionRepository.GetInfractionCountsAsync(new InfractionSearchCriteria - { - GuildId = _authorizationService.CurrentGuildId, SubjectId = subjectId, IsDeleted = false - }); - } + await DoDeleteMessagesAsync(channel, guildChannel, messages); + } - public Task GetModerationActionSummaryAsync(long moderationActionId) - { - return _moderationActionRepository.ReadSummaryAsync(moderationActionId); - } + public async Task> SearchDeletedMessagesAsync( + DeletedMessageSearchCriteria searchCriteria, IEnumerable sortingCriteria, + PagingCriteria pagingCriteria) + { + authorizationService.RequireClaims(AuthorizationClaim.LogViewDeletedMessages); - public Task GetNextInfractionExpiration() - => _infractionRepository.ReadExpiresFirstOrDefaultAsync( - new InfractionSearchCriteria() - { - IsRescinded = false, - IsDeleted = false, - ExpiresRange = new DateTimeOffsetRange() - { - From = DateTimeOffset.MinValue, To = DateTimeOffset.MaxValue, - } - }, - new[] - { - new SortingCriteria() - { - PropertyName = nameof(InfractionSummary.Expires), Direction = SortDirection.Ascending - } - }); + return await deletedMessageRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, + pagingCriteria); + } - public async Task DoesModeratorOutrankUserAsync(ulong guildId, ulong moderatorId, ulong subjectId) - { - //If the user doesn't exist in the guild, we outrank them - if (await _userService.GuildUserExistsAsync(guildId, subjectId) == false) - return true; + public Task> SearchInfractionsAsync( + InfractionSearchCriteria searchCriteria, IEnumerable? sortingCriteria = null) + { + authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); - var subject = await _userService.GetGuildUserAsync(guildId, subjectId); + return infractionRepository.SearchSummariesAsync(searchCriteria, sortingCriteria); + } - return await DoesModeratorOutrankUserAsync(subject.Guild, moderatorId, subject); - } + public Task> SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, + IEnumerable sortingCriteria, PagingCriteria pagingCriteria) + { + authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); - public async Task AnyInfractionsAsync(InfractionSearchCriteria criteria) - { - if (criteria is null) - throw new ArgumentNullException(nameof(criteria)); + return infractionRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, pagingCriteria); + } - return await _infractionRepository.AnyAsync(criteria); - } + public async Task> GetInfractionCountsForUserAsync(ulong subjectId) + { + authorizationService.RequireClaims(AuthorizationClaim.ModerationRead); - public async Task GetOrCreateDesignatedMuteRoleAsync(ulong guildId, ulong currentUserId) + return await infractionRepository.GetInfractionCountsAsync(new InfractionSearchCriteria { - var guild = await _discordClient.GetGuildAsync(guildId); - return await GetOrCreateDesignatedMuteRoleAsync(guild, currentUserId); - } + GuildId = authorizationService.CurrentGuildId, SubjectId = subjectId, IsDeleted = false + }); + } - public async Task GetOrCreateDesignatedMuteRoleAsync(IGuild guild, ulong currentUserId) - { - using var transaction = await _designatedRoleMappingRepository.BeginCreateTransactionAsync(); + public Task GetModerationActionSummaryAsync(long moderationActionId) + { + return moderationActionRepository.ReadSummaryAsync(moderationActionId); + } - var mapping = (await _designatedRoleMappingRepository.SearchBriefsAsync( - new DesignatedRoleMappingSearchCriteria() + public Task GetNextInfractionExpiration() + => infractionRepository.ReadExpiresFirstOrDefaultAsync( + new InfractionSearchCriteria() + { + IsRescinded = false, + IsDeleted = false, + ExpiresRange = new DateTimeOffsetRange() { - GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false - })).FirstOrDefault(); - - if (!(mapping is null)) - return guild.Roles.First(x => x.Id == mapping.Role.Id); - - var role = guild.Roles.FirstOrDefault(x => x.Name == MuteRoleName) - ?? await guild.CreateRoleAsync(MuteRoleName, isMentionable: false); - - await _roleService.TrackRoleAsync(role, default); - - await _designatedRoleMappingRepository.CreateAsync(new DesignatedRoleMappingCreationData() + From = DateTimeOffset.MinValue, To = DateTimeOffset.MaxValue, + } + }, + new[] { - GuildId = guild.Id, - RoleId = role.Id, - Type = DesignatedRoleType.ModerationMute, - CreatedById = currentUserId + new SortingCriteria() + { + PropertyName = nameof(InfractionSummary.Expires), Direction = SortDirection.Ascending + } }); - transaction.Commit(); - return role; - } + public async Task DoesModeratorOutrankUserAsync(ulong guildId, ulong moderatorId, ulong subjectId) + { + //If the user doesn't exist in the guild, we outrank them + if (await userService.GuildUserExistsAsync(guildId, subjectId) == false) + return true; - public async Task<(bool success, string? errorMessage)> UpdateInfractionAsync(long infractionId, - string newReason, ulong currentUserId) - { - var infraction = await _infractionRepository.ReadSummaryAsync(infractionId); + var subject = await userService.GetGuildUserAsync(guildId, subjectId); - if (infraction is null) - return (false, $"An infraction with an ID of {infractionId} could not be found."); + return await DoesModeratorOutrankUserAsync(subject.Guild, moderatorId, subject); + } - _authorizationService.RequireClaims(_createInfractionClaimsByType[infraction.Type]); + public async Task AnyActiveInfractions(ulong guildId, ulong userId, InfractionType? type = null) + { + return await db + .Set() + .Where(x => x.GuildId == guildId) + .Where(x => x.SubjectId == userId) + .Where(x => x.DeleteActionId == null) + .Where(x => x.RescindActionId == null) + .Where(x => type == null || x.Type == type) + .AnyAsync(); + } - // Allow users who created the infraction to bypass any further - // validation and update their own infraction - if (infraction.CreateAction.CreatedBy.Id == currentUserId) - { - return (await _infractionRepository.TryUpdateAsync(infractionId, newReason, currentUserId), null); - } + public async Task<(bool success, string? errorMessage)> UpdateInfractionAsync(long infractionId, + string newReason, ulong currentUserId) + { + var infraction = await infractionRepository.ReadSummaryAsync(infractionId); - // Else we know it's not the user's infraction - _authorizationService.RequireClaims(AuthorizationClaim.ModerationUpdateInfraction); + if (infraction is null) + return (false, $"An infraction with an ID of {infractionId} could not be found."); - return (await _infractionRepository.TryUpdateAsync(infractionId, newReason, currentUserId), null); - } + authorizationService.RequireClaims(_createInfractionClaimsByType[infraction.Type]); - private static async Task ConfigureChannelMuteRolePermissionsAsync(IGuildChannel channel, IRole muteRole) + // Allow users who created the infraction to bypass any further + // validation and update their own infraction + if (infraction.CreateAction.CreatedBy.Id == currentUserId) { - try - { - var permissionOverwrite = channel.GetPermissionOverwrite(muteRole); - - if (permissionOverwrite is null || _mutePermissions.ToDenyList().Any(x => !permissionOverwrite.GetValueOrDefault().ToDenyList().Contains(x))) - { - await channel.AddPermissionOverwriteAsync(muteRole, _mutePermissions, new() { AuditLogReason = "Setting mute role permissions." }); - Log.Debug("Set mute permissions for role {Role} in channel #{Channel}.", muteRole.Name, channel.Name); - } - else - { - Log.Debug("Skipping setting mute permissions for channel #{Channel} as they're already set.", channel.Name); - } - } - catch (Exception e) - { - Log.Error(e, "Failed setting channel mute role on #{Channel}", channel.Name); - throw; - } + return (await infractionRepository.TryUpdateAsync(infractionId, newReason, currentUserId), null); } - private async Task DoDeleteMessagesAsync(ITextChannel channel, IGuildChannel guildChannel, - IEnumerable messages) - { - await channel.DeleteMessagesAsync(messages); + // Else we know it's not the user's infraction + authorizationService.RequireClaims(AuthorizationClaim.ModerationUpdateInfraction); - using var transaction = await _deletedMessageBatchRepository.BeginCreateTransactionAsync(); - await _channelService.TrackChannelAsync(channel.Name, channel.Id, channel.GuildId, channel is IThreadChannel threadChannel ? threadChannel.CategoryId : null); + return (await infractionRepository.TryUpdateAsync(infractionId, newReason, currentUserId), null); + } - await _deletedMessageBatchRepository.CreateAsync(new DeletedMessageBatchCreationData() - { - CreatedById = _authorizationService.CurrentUserId!.Value, - GuildId = _authorizationService.CurrentGuildId!.Value, - Data = messages.Select( - x => new DeletedMessageCreationData() - { - AuthorId = x.Author.Id, - ChannelId = x.Channel.Id, - Content = x.Content, - GuildId = _authorizationService.CurrentGuildId.Value, - MessageId = x.Id, - Reason = "Mass-deleted.", - }), - }); + private async Task DoDeleteMessagesAsync(ITextChannel channel, IGuildChannel guildChannel, + IEnumerable messages) + { + await channel.DeleteMessagesAsync(messages); - transaction.Commit(); - } + using var transaction = await deletedMessageBatchRepository.BeginCreateTransactionAsync(); + await channelService.TrackChannelAsync(channel.Name, channel.Id, channel.GuildId, channel is IThreadChannel threadChannel ? threadChannel.CategoryId : null); - private async Task DoRescindInfractionAsync(InfractionType type, - ulong guildId, - ulong subjectId, - InfractionSummary? infraction, - string? reason = null, - bool isAutoRescind = false) + await deletedMessageBatchRepository.CreateAsync(new DeletedMessageBatchCreationData() { - RequestOptions? GetRequestOptions() => - string.IsNullOrEmpty(reason) ? null : new RequestOptions {AuditLogReason = reason}; + CreatedById = authorizationService.CurrentUserId!.Value, + GuildId = authorizationService.CurrentGuildId!.Value, + Data = messages.Select( + x => new DeletedMessageCreationData() + { + AuthorId = x.Author.Id, + ChannelId = x.Channel.Id, + Content = x.Content, + GuildId = authorizationService.CurrentGuildId.Value, + MessageId = x.Id, + Reason = "Mass-deleted.", + }), + }); + + transaction.Commit(); + } - if (!isAutoRescind) - { - await RequireSubjectRankLowerThanModeratorRankAsync(guildId, _authorizationService.CurrentUserId!.Value, - subjectId); - } + private async Task DoRescindInfractionAsync(InfractionType type, + ulong guildId, + ulong subjectId, + InfractionSummary? infraction, + string? reason = null, + bool isAutoRescind = false) + { + RequestOptions? GetRequestOptions() => + string.IsNullOrEmpty(reason) ? null : new RequestOptions {AuditLogReason = reason}; - var guild = await _discordClient.GetGuildAsync(guildId); + if (!isAutoRescind) + { + await RequireSubjectRankLowerThanModeratorRankAsync(guildId, authorizationService.CurrentUserId!.Value, + subjectId); + } - 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; + var guild = await discordClient.GetGuildAsync(guildId); - case InfractionType.Ban: - await guild.RemoveBanAsync(subjectId, GetRequestOptions()); + 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; + } - default: - throw new InvalidOperationException($"{type} infractions cannot be rescinded."); - } + var subject = await userService.GetGuildUserAsync(guild.Id, subjectId); + await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions()); + break; - if (infraction != null) - { - await _infractionRepository.TryRescindAsync(infraction.Id, _authorizationService.CurrentUserId!.Value, - reason); - } + case InfractionType.Ban: + await guild.RemoveBanAsync(subjectId, GetRequestOptions()); + break; + + default: + throw new InvalidOperationException($"{type} infractions cannot be rescinded."); } - private async Task GetDesignatedMuteRoleAsync(IGuild guild) + if (infraction != null) { - var mapping = (await _designatedRoleMappingRepository.SearchBriefsAsync( - new DesignatedRoleMappingSearchCriteria() - { - GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false - })).FirstOrDefault(); + await infractionRepository.TryRescindAsync(infraction.Id, authorizationService.CurrentUserId!.Value, + reason); + } + } - if (mapping == null) - throw new InvalidOperationException( - $"There are currently no designated mute roles within guild {guild.Id}"); + private async Task GetDesignatedMuteRoleAsync(IGuild guild) + { + var mapping = (await designatedRoleMappingRepository.SearchBriefsAsync( + new DesignatedRoleMappingSearchCriteria() + { + GuildId = guild.Id, Type = DesignatedRoleType.ModerationMute, IsDeleted = false + })).FirstOrDefault(); - return guild.Roles.First(x => x.Id == mapping.Role.Id); - } + if (mapping == null) + throw new InvalidOperationException( + $"There are currently no designated mute roles within guild {guild.Id}"); - private async Task> GetRankRolesAsync(ulong guildId) - => (await _designatedRoleMappingRepository - .SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria - { - GuildId = guildId, Type = DesignatedRoleType.Rank, IsDeleted = false, - })) - .Select(r => r.Role); + return guild.Roles.First(x => x.Id == mapping.Role.Id); + } - private async Task RequireSubjectRankLowerThanModeratorRankAsync(ulong guildId, ulong moderatorId, - ulong subjectId) - { - if (!await DoesModeratorOutrankUserAsync(guildId, moderatorId, subjectId)) - throw new InvalidOperationException( - "Cannot moderate users that have a rank greater than or equal to your own."); - } + private async Task> GetRankRolesAsync(ulong guildId) + => (await designatedRoleMappingRepository + .SearchBriefsAsync(new DesignatedRoleMappingSearchCriteria + { + GuildId = guildId, Type = DesignatedRoleType.Rank, IsDeleted = false, + })) + .Select(r => r.Role); - private async ValueTask RequireSubjectRankLowerThanModeratorRankAsync(IGuild guild, ulong moderatorId, - IGuildUser? subject) - { - // If the subject is null, then the moderator automatically outranks them. - if (subject is null) - return; + private async Task RequireSubjectRankLowerThanModeratorRankAsync(ulong guildId, ulong moderatorId, + ulong subjectId) + { + if (!await DoesModeratorOutrankUserAsync(guildId, moderatorId, subjectId)) + throw new InvalidOperationException( + "Cannot moderate users that have a rank greater than or equal to your own."); + } - if (!await DoesModeratorOutrankUserAsync(guild, moderatorId, subject)) - throw new InvalidOperationException( - "Cannot moderate users that have a rank greater than or equal to your own."); - } + private async ValueTask RequireSubjectRankLowerThanModeratorRankAsync(IGuild guild, ulong moderatorId, + IGuildUser? subject) + { + // If the subject is null, then the moderator automatically outranks them. + if (subject is null) + return; - private async Task DoesModeratorOutrankUserAsync(IGuild guild, ulong moderatorId, IGuildUser subject) - { - //If the subject is the guild owner, and the moderator is not the owner, the moderator does not outrank them - if (guild.OwnerId == subject.Id && guild.OwnerId != moderatorId) - return false; + if (!await DoesModeratorOutrankUserAsync(guild, moderatorId, subject)) + throw new InvalidOperationException( + "Cannot moderate users that have a rank greater than or equal to your own."); + } - var moderator = await _userService.GetGuildUserAsync(guild.Id, moderatorId); + private async Task DoesModeratorOutrankUserAsync(IGuild guild, ulong moderatorId, IGuildUser subject) + { + //If the subject is the guild owner, and the moderator is not the owner, the moderator does not outrank them + if (guild.OwnerId == subject.Id && guild.OwnerId != moderatorId) + return false; - // If the moderator has the "Admin" permission, they outrank everyone in the guild but the owner - if (moderator.GuildPermissions.Administrator) - return true; + var moderator = await userService.GetGuildUserAsync(guild.Id, moderatorId); - var rankRoles = await GetRankRolesAsync(guild.Id); + // If the moderator has the "Admin" permission, they outrank everyone in the guild but the owner + if (moderator.GuildPermissions.Administrator) + return true; - var subjectRankRoles = rankRoles.Where(r => subject.RoleIds.Contains(r.Id)); - var moderatorRankRoles = rankRoles.Where(r => moderator.RoleIds.Contains(r.Id)); + var rankRoles = await GetRankRolesAsync(guild.Id); - var greatestSubjectRankPosition = subjectRankRoles.Any() - ? subjectRankRoles.Select(r => r.Position).Max() - : int.MinValue; - var greatestModeratorRankPosition = moderatorRankRoles.Any() - ? moderatorRankRoles.Select(r => r.Position).Max() - : int.MinValue; + var subjectRankRoles = rankRoles.Where(r => subject.RoleIds.Contains(r.Id)); + var moderatorRankRoles = rankRoles.Where(r => moderator.RoleIds.Contains(r.Id)); - return greatestSubjectRankPosition < greatestModeratorRankPosition; - } + var greatestSubjectRankPosition = subjectRankRoles.Any() + ? subjectRankRoles.Select(r => r.Position).Max() + : int.MinValue; + var greatestModeratorRankPosition = moderatorRankRoles.Any() + ? moderatorRankRoles.Select(r => r.Position).Max() + : int.MinValue; - private static readonly OverwritePermissions _mutePermissions - = new( - addReactions: PermValue.Deny, - requestToSpeak: PermValue.Deny, - sendMessages: PermValue.Deny, - sendMessagesInThreads: PermValue.Deny, - speak: PermValue.Deny, - usePrivateThreads: PermValue.Deny, - usePublicThreads: PermValue.Deny); - - private static readonly Dictionary _createInfractionClaimsByType - = new() - { - {InfractionType.Notice, AuthorizationClaim.ModerationNote}, - {InfractionType.Warning, AuthorizationClaim.ModerationWarn}, - {InfractionType.Mute, AuthorizationClaim.ModerationMute}, - {InfractionType.Ban, AuthorizationClaim.ModerationBan} - }; + 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/ModerationSetup.cs b/src/Modix.Services/Moderation/ModerationSetup.cs index e6d16228e..b0b244eea 100644 --- a/src/Modix.Services/Moderation/ModerationSetup.cs +++ b/src/Modix.Services/Moderation/ModerationSetup.cs @@ -20,20 +20,17 @@ public static class ModerationSetup /// public static IServiceCollection AddModixModeration(this IServiceCollection services) => services - .AddSingleton() .AddSingleton() .AddSingleton(serviceProvider => serviceProvider.GetRequiredService()) .AddSingleton(serviceProvider => serviceProvider.GetRequiredService()) - .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped, MessageContentCheckBehaviour>() - .AddScoped, MessageContentCheckBehaviour>() - .AddScoped, MutePersistingHandler>() - .AddScoped, InfractionSyncingHandler>(); + .AddScoped, MessageContentCheckBehaviour>(); } } diff --git a/src/Modix.Services/Moderation/MutePersistingHandler.cs b/src/Modix.Services/Moderation/MutePersistingHandler.cs deleted file mode 100644 index 534623d33..000000000 --- a/src/Modix.Services/Moderation/MutePersistingHandler.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -using Discord; -using Discord.WebSocket; - -using Modix.Common.Messaging; -using Modix.Data.Models.Moderation; -using Modix.Services.Core; - -using Serilog; - -namespace Modix.Services.Moderation -{ - /// - /// Implements a handler that persists mutes for users who leave and rejoin a guild. - /// - public class MutePersistingHandler : - INotificationHandler - { - private readonly DiscordSocketClient _discordSocketClient; - private readonly IModerationService _moderationService; - - /// - /// Constructs a new object with the given injected dependencies. - /// - /// A moderation service to interact with the infractions system. - /// The Discord user that the bot is running as. - public MutePersistingHandler( - DiscordSocketClient discordSocketClient, - IModerationService moderationService) - { - _discordSocketClient = discordSocketClient; - _moderationService = moderationService; - } - - public Task HandleNotificationAsync(UserJoinedNotification notification, CancellationToken cancellationToken) - => TryMuteUserAsync(notification.GuildUser); - - /// - /// Mutes the user if they have an active mute infraction in the guild. - /// - /// The guild that the user joined. - /// The user who joined the guild. - /// - /// A that will complete when the operation completes. - /// - private async Task TryMuteUserAsync(SocketGuildUser guildUser) - { - var userHasActiveMuteInfraction = await _moderationService.AnyInfractionsAsync(new InfractionSearchCriteria() - { - GuildId = guildUser.Guild.Id, - IsDeleted = false, - IsRescinded = false, - SubjectId = guildUser.Id, - Types = new[] { InfractionType.Mute }, - }); - - if (!userHasActiveMuteInfraction) - { - Log.Debug("User {0} was not muted, because they do not have any active mute infractions.", guildUser.Id); - return; - } - - var muteRole = await _moderationService.GetOrCreateDesignatedMuteRoleAsync(guildUser.Guild, _discordSocketClient.CurrentUser.Id); - - Log.Debug("User {0} was muted, because they have an active mute infraction.", guildUser.Id); - - await guildUser.AddRoleAsync(muteRole); - } - } -} diff --git a/src/Modix.Web/Components/DeletedMessages.razor b/src/Modix.Web/Components/DeletedMessages.razor index 21392c0ba..d9055cd97 100644 --- a/src/Modix.Web/Components/DeletedMessages.razor +++ b/src/Modix.Web/Components/DeletedMessages.razor @@ -137,7 +137,7 @@ @code { [Inject] - public IModerationService ModerationService { get; set; } = null!; + public ModerationService ModerationService { get; set; } = null!; [Inject] public DiscordHelper DiscordHelper { get; set; } = null!; diff --git a/src/Modix.Web/Components/Infractions.razor b/src/Modix.Web/Components/Infractions.razor index aad078c4e..5326bb96d 100644 --- a/src/Modix.Web/Components/Infractions.razor +++ b/src/Modix.Web/Components/Infractions.razor @@ -172,7 +172,7 @@ public string? Id { get; set; } [Inject] - public IModerationService ModerationService { get; set; } = null!; + public ModerationService ModerationService { get; set; } = null!; [Inject] public DiscordHelper DiscordHelper { get; set; } = null!; diff --git a/src/Modix/Extensions/ServiceCollectionExtensions.cs b/src/Modix/Extensions/ServiceCollectionExtensions.cs index 76bc0e95a..57ad276ed 100644 --- a/src/Modix/Extensions/ServiceCollectionExtensions.cs +++ b/src/Modix/Extensions/ServiceCollectionExtensions.cs @@ -162,6 +162,10 @@ public static IServiceCollection AddModix( services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddMemoryCache();