/// <inheritdoc /> public async Task RescindInfractionAsync(long infractionId, string reason) { AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); var infraction = await InfractionRepository.ReadAsync(infractionId); if (infraction == null) { throw new ArgumentException("Infraction does not exist", nameof(infractionId)); } switch (infraction.Type) { case InfractionType.Mute: await DoDiscordUnMuteAsync(infraction.Subject.Id); break; case InfractionType.Ban: await DoDiscordUnBanAsync(infraction.Subject.Id); break; } var actionId = await ModerationActionRepository.CreateAsync(new ModerationActionCreationData() { Type = ModerationActionType.InfractionRescinded, CreatedById = AuthorizationService.CurrentUserId.Value, Reason = reason, InfractionId = infractionId }); // TODO: Log action to a channel, pulled from IModerationConfigRepository. }
/// <inheritdoc /> public async Task <ServiceResult <DateTimeOffset> > GetNextInfractionExpiration() { var result = await 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 } }); if (result == null) { return(ServiceResult <DateTimeOffset> .FromError("No expiring infractions found.")); } return(ServiceResult.FromResult(result.Value)); }
/// <inheritdoc /> public async Task <ServiceResult> RescindInfractionAsync(InfractionType type, ulong subjectId) { var authResult = AuthorizationService.CheckClaims(AuthorizationClaim.ModerationRescind); if (authResult.IsFailure) { return(authResult); } var rankResult = await RequireSubjectRankLowerThanModeratorRankAsync(AuthorizationService.CurrentGuildId.Value, subjectId); if (rankResult.IsFailure) { return(rankResult); } await DoRescindInfractionAsync( (await InfractionRepository.SearchSummariesAsync( new InfractionSearchCriteria() { GuildId = AuthorizationService.CurrentGuildId.Value, Types = new [] { type }, SubjectId = subjectId, IsRescinded = false, IsDeleted = false, })) .FirstOrDefault()); return(ServiceResult.FromSuccess()); }
/// <inheritdoc /> public async Task DeleteInfractionAsync(long infractionId) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationDelete); var infraction = await InfractionRepository.ReadSummaryAsync(infractionId); if (infraction == null) { throw new InvalidOperationException($"Infraction {infractionId} does not exist"); } await InfractionRepository.TryDeleteAsync(infraction.Id, AuthorizationService.CurrentUserId.Value); var guild = await GuildService.GetGuildAsync(infraction.GuildId); var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); switch (infraction.Type) { case InfractionType.Mute: await subject.RemoveRoleAsync( await GetOrCreateMuteRoleInGuildAsync(guild)); break; case InfractionType.Ban: await guild.RemoveBanAsync(subject); break; } }
private async Task DoRescindInfractionAsync(InfractionSummary infraction) { if (infraction == null) { throw new InvalidOperationException("Infraction does not exist"); } await InfractionRepository.TryRescindAsync(infraction.Id, AuthorizationService.CurrentUserId.Value); var guild = await DiscordClient.GetGuildAsync(infraction.GuildId); switch (infraction.Type) { case InfractionType.Mute: if (!await UserService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) { throw new InvalidOperationException("Cannot unmute a user who is not in the server."); } var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild)); break; case InfractionType.Ban: await guild.RemoveBanAsync(infraction.Subject.Id); break; default: throw new InvalidOperationException($"{infraction.Type} infractions cannot be rescinded."); } }
public async Task <bool> UpdateInfractionAsync(long infractionId, string newReason, ulong currentUserId) { var infraction = await InfractionRepository.ReadSummaryAsync(infractionId); var editCutoff = DateTimeOffset.Now.AddDays(-1); if (infraction.CreateAction.Created <= editCutoff) { return(false); } AuthorizationService.RequireClaims(_createInfractionClaimsByType[infraction.Type]); // 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.TryUpdateAync(infractionId, newReason, currentUserId)); } // Else we know it's not the user's infraction AuthorizationService.RequireClaims(AuthorizationClaim.ModerationUpdateInfraction); return(await InfractionRepository.TryUpdateAync(infractionId, newReason, currentUserId)); }
/// <inheritdoc /> public async Task RescindInfractionAsync(long infractionId) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); await DoRescindInfractionAsync( await InfractionRepository.ReadSummaryAsync(infractionId)); }
public async Task <ServiceResult <IDictionary <InfractionType, int> > > GetInfractionCountsForUserAsync(ulong subjectId) => await AuthorizationService.CheckClaims(AuthorizationClaim.ModerationRead) .ShortCircuitAsync(InfractionRepository.GetInfractionCountsAsync(new InfractionSearchCriteria { GuildId = AuthorizationService.CurrentGuildId, SubjectId = subjectId, IsDeleted = false }));
public void Constructor_Always_InvokesBaseConstructor() { var modixContext = Substitute.For <ModixContext>(); var uut = new InfractionRepository(modixContext); uut.ModixContext.ShouldBeSameAs(modixContext); }
/// <inheritdoc /> public async Task RescindInfractionAsync(long infractionId, string reason = null, bool isAutoRescind = false) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); await DoRescindInfractionAsync( await InfractionRepository.ReadSummaryAsync(infractionId), reason, isAutoRescind); }
public async Task <bool> AnyInfractionsAsync(InfractionSearchCriteria criteria) { if (criteria is null) { throw new ArgumentNullException(nameof(criteria)); } return(await InfractionRepository.AnyAsync(criteria)); }
public void Constructor_Always_InvokesBaseConstructor() { var modixContext = Substitute.For <ModixContext>(); var moderationActionEventHandlers = Enumerable.Empty <IModerationActionEventHandler>(); var infractionEventHandlers = Enumerable.Empty <IInfractionEventHandler>(); var uut = new InfractionRepository(modixContext, moderationActionEventHandlers, infractionEventHandlers); uut.ModixContext.ShouldBeSameAs(modixContext); }
public async Task <bool> AnyInfractionsAsync(InfractionSearchCriteria criteria) { AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRead); if (criteria is null) { throw new ArgumentNullException(nameof(criteria)); } return(await InfractionRepository.AnyAsync(criteria)); }
public async Task <IDictionary <InfractionType, int> > GetInfractionCountsForUserAsync(ulong subjectId) { AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRead); return(await InfractionRepository.GetInfractionCountsAsync(new InfractionSearchCriteria { GuildId = AuthorizationService.CurrentGuildId, SubjectId = subjectId, IsDeleted = false })); }
/// <inheritdoc /> public async Task <ServiceResult> RescindInfractionAsync(long infractionId, bool isAutoRescind = false) { var authResult = AuthorizationService.CheckClaims(AuthorizationClaim.ModerationRescind); if (authResult.IsFailure) { return(authResult); } await DoRescindInfractionAsync( await InfractionRepository.ReadSummaryAsync(infractionId), isAutoRescind); return(ServiceResult.FromSuccess()); }
/// <inheritdoc /> public async Task DeleteInfractionAsync(long infractionId) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationDeleteInfraction); var infraction = await InfractionRepository.ReadSummaryAsync(infractionId); if (infraction == null) { throw new InvalidOperationException($"Infraction {infractionId} does not exist"); } await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, AuthorizationService.CurrentUserId.Value, infraction.Subject.Id); await InfractionRepository.TryDeleteAsync(infraction.Id, AuthorizationService.CurrentUserId.Value); var guild = await DiscordClient.GetGuildAsync(infraction.GuildId); switch (infraction.Type) { case InfractionType.Mute: if (await UserService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) { var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild)); } else { Log.Warning("Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}", infraction.Subject.Id, guild.Id); } break; case InfractionType.Ban: //If the infraction has already been rescinded, we don't need to actually perform the unmute/unban //Doing so will return a 404 from Discord (trying to remove a nonexistant ban) if (infraction.RescindAction == null) { await guild.RemoveBanAsync(infraction.Subject.Id); } break; } }
private async Task DoRescindInfractionAsync(InfractionSummary infraction, string reason = null, bool isAutoRescind = false) { RequestOptions GetRequestOptions() => string.IsNullOrEmpty(reason) ? null : new RequestOptions { AuditLogReason = reason }; if (infraction == null) { throw new InvalidOperationException("Infraction does not exist"); } if (!isAutoRescind) { await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, AuthorizationService.CurrentUserId.Value, infraction.Subject.Id); } await InfractionRepository.TryRescindAsync(infraction.Id, AuthorizationService.CurrentUserId.Value, reason); var guild = await DiscordClient.GetGuildAsync(infraction.GuildId); switch (infraction.Type) { case InfractionType.Mute: if (!await UserService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) { Log.Information("Attempted to remove the mute role from {0} ({1}), but they were not in the server.", infraction.Subject.GetFullUsername(), infraction.Subject.Id); break; } var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild), GetRequestOptions()); break; case InfractionType.Ban: await guild.RemoveBanAsync(infraction.Subject.Id, GetRequestOptions()); break; default: throw new InvalidOperationException($"{infraction.Type} infractions cannot be rescinded."); } }
/// <inheritdoc /> public async Task AutoRescindExpiredInfractions() { var expiredInfractionIds = await InfractionRepository.SearchIdsAsync(new InfractionSearchCriteria() { ExpiresRange = new DateTimeOffsetRange() { To = DateTimeOffset.Now }, IsRescinded = false, IsDeleted = false }); foreach (var expiredInfractionId in expiredInfractionIds) { await RescindInfractionAsync(expiredInfractionId); } }
/// <inheritdoc /> public async Task <ServiceResult> DeleteInfractionAsync(long infractionId) { var authResult = AuthorizationService.CheckClaims(AuthorizationClaim.ModerationDeleteInfraction); if (authResult.IsFailure) { return(authResult); } var infraction = await InfractionRepository.ReadSummaryAsync(infractionId); if (infraction == null) { return(ServiceResult.FromError($"Infraction {infractionId} does not exist")); } var rankResult = await RequireSubjectRankLowerThanModeratorRankAsync(AuthorizationService.CurrentGuildId.Value, infraction.Subject.Id); if (rankResult.IsFailure) { return(rankResult); } await InfractionRepository.TryDeleteAsync(infraction.Id, AuthorizationService.CurrentUserId.Value); var guild = await DiscordClient.GetGuildAsync(infraction.GuildId); var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); switch (infraction.Type) { case InfractionType.Mute: await subject.RemoveRoleAsync( await GetDesignatedMuteRoleAsync(guild)); break; case InfractionType.Ban: await guild.RemoveBanAsync(subject); break; } return(ServiceResult.FromSuccess()); }
/// <inheritdoc /> public async Task RescindInfractionAsync(InfractionType type, ulong subjectId) { AuthorizationService.RequireAuthenticatedGuild(); AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRescind); await DoRescindInfractionAsync( (await InfractionRepository.SearchSummariesAsync( new InfractionSearchCriteria() { GuildId = AuthorizationService.CurrentGuildId.Value, Types = new [] { type }, SubjectId = subjectId, IsRescinded = false, IsDeleted = false, })) .FirstOrDefault()); }
/// <inheritdoc /> public Task <DateTimeOffset?> 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 } });
/// <inheritdoc /> public async Task DeleteInfractionAsync(long infractionId) { AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationDeleteInfraction); var infraction = await InfractionRepository.ReadSummaryAsync(infractionId); if (infraction == null) { throw new InvalidOperationException($"Infraction {infractionId} does not exist"); } await RequireSubjectRankLowerThanModeratorRankAsync(infraction.GuildId, infraction.Subject.Id); await InfractionRepository.TryDeleteAsync(infraction.Id, AuthorizationService.CurrentUserId.Value); var guild = await DiscordClient.GetGuildAsync(infraction.GuildId); switch (infraction.Type) { case InfractionType.Mute: if (await UserService.GuildUserExistsAsync(guild.Id, infraction.Subject.Id)) { var subject = await UserService.GetGuildUserAsync(guild.Id, infraction.Subject.Id); await subject.RemoveRoleAsync(await GetDesignatedMuteRoleAsync(guild)); } else { Log.Warning("Tried to unmute {User} while deleting mute infraction, but they weren't in the guild: {Guild}", infraction.Subject.Id, guild.Id); } break; case InfractionType.Ban: await guild.RemoveBanAsync(infraction.Subject.Id); break; } }
/// <inheritdoc /> public async Task CreateInfractionAsync(InfractionType type, ulong subjectId, string reason, TimeSpan?duration) { AuthorizationService.RequireClaims(_createInfractionClaimsByType[type]); switch (type) { case InfractionType.Mute: await DoDiscordMuteAsync(subjectId); break; case InfractionType.Ban: await DoDiscordBanAsync(subjectId); break; } var actionId = await ModerationActionRepository.CreateAsync(new ModerationActionCreationData() { Type = ModerationActionType.InfractionCreated, CreatedById = AuthorizationService.CurrentUserId.Value, Reason = reason }); var infractionId = await InfractionRepository.CreateAsync(new InfractionCreationData() { Type = type, SubjectId = subjectId, Duration = duration, CreateActionId = actionId }); await ModerationActionRepository.UpdateAsync(actionId, data => { data.InfractionId = infractionId; }); // TODO: Log action to a channel, pulled from IModerationConfigRepository. // TODO: Implement InfractionAutoExpirationBehavior (or whatever) to automatically rescind infractions, based on Duration, and notify it here that a new infraction has been created, if it has a duration. }
/// <summary> /// Imports the given <see cref="RowboatInfraction"/>s, mapping them to Modix infractions /// </summary> /// <param name="rowboatInfractions">The <see cref="IEnumerable{T}"/> of infractions to be imported</param> /// <returns>The count of imported infractions</returns> public async Task <int> ImportInfractionsAsync(IEnumerable <RowboatInfraction> rowboatInfractions) { AuthorizationService.RequireAuthenticatedGuild(); AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(AuthorizationClaim.ModerationConfigure, AuthorizationClaim.ModerationWarn, AuthorizationClaim.ModerationNote, AuthorizationClaim.ModerationBan); if (!AuthorizationService.CurrentGuildId.HasValue) { throw new InvalidOperationException("Cannot import infractions without a guild context"); } var importCount = 0; using (var transaction = await InfractionRepository.BeginCreateTransactionAsync()) { foreach (var infraction in rowboatInfractions.Where(d => d.Active)) { if (await GuildUserRepository.ReadSummaryAsync(infraction.User.Id, AuthorizationService.CurrentGuildId.Value) != null && await GuildUserRepository.ReadSummaryAsync(infraction.Actor.Id, AuthorizationService.CurrentGuildId.Value) != null) { await InfractionRepository.CreateAsync( new InfractionCreationData() { GuildId = AuthorizationService.CurrentGuildId.Value, Type = infraction.ModixInfractionType, SubjectId = infraction.User.Id, Reason = infraction.Reason, CreatedById = infraction.Actor.Id }); importCount++; } } transaction.Commit(); } return(importCount); }
/// <inheritdoc /> public async Task RescindInfractionAsync(InfractionType type, ulong subjectId, string reason = null) { 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)); } await DoRescindInfractionAsync( (await InfractionRepository.SearchSummariesAsync( new InfractionSearchCriteria() { GuildId = AuthorizationService.CurrentGuildId.Value, Types = new[] { type }, SubjectId = subjectId, IsRescinded = false, IsDeleted = false, })) .FirstOrDefault(), reason); }
/// <inheritdoc /> public Task <RecordsPage <InfractionSummary> > SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, IEnumerable <SortingCriteria> sortingCriteria, PagingCriteria pagingCriteria) { AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRead); return(InfractionRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, pagingCriteria)); }
/// <inheritdoc /> public Task <IReadOnlyCollection <InfractionSummary> > SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, IEnumerable <SortingCriteria> sortingCriteria = null) { AuthorizationService.RequireClaims(AuthorizationClaim.ModerationRead); return(InfractionRepository.SearchSummariesAsync(searchCriteria, sortingCriteria)); }
/// <inheritdoc /> public async Task CreateInfractionAsync(InfractionType type, ulong subjectId, string reason, TimeSpan?duration) { AuthorizationService.RequireAuthenticatedGuild(); AuthorizationService.RequireAuthenticatedUser(); AuthorizationService.RequireClaims(_createInfractionClaimsByType[type]); var guild = await GuildService.GetGuildAsync(AuthorizationService.CurrentGuildId.Value); var subject = await UserService.GetGuildUserAsync(guild.Id, subjectId); if (reason == null) { throw new ArgumentNullException(nameof(reason)); } if (((type == InfractionType.Notice) || (type == InfractionType.Warning)) && string.IsNullOrWhiteSpace(reason)) { throw new InvalidOperationException($"{type.ToString()} infractions require a reason to be given"); } using (var transaction = await InfractionRepository.BeginCreateTransactionAsync()) { if ((type == InfractionType.Mute) || (type == InfractionType.Ban)) { if (await InfractionRepository.AnyAsync(new InfractionSearchCriteria() { GuildId = guild.Id, Types = new[] { type }, SubjectId = subject.Id, IsRescinded = false, IsDeleted = false })) { throw new InvalidOperationException($"Discord user {subjectId} already has an active {type} infraction"); } } await InfractionRepository.CreateAsync( new InfractionCreationData() { GuildId = guild.Id, Type = type, SubjectId = subjectId, Reason = reason, Duration = duration, CreatedById = AuthorizationService.CurrentUserId.Value }); transaction.Commit(); } // 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: await subject.AddRoleAsync( await GetOrCreateMuteRoleInGuildAsync(guild)); break; case InfractionType.Ban: await guild.AddBanAsync(subject, reason : reason); break; } }
/// <inheritdoc /> public async Task <ServiceResult <RecordsPage <InfractionSummary> > > SearchInfractionsAsync(InfractionSearchCriteria searchCriteria, IEnumerable <SortingCriteria> sortingCriteria, PagingCriteria pagingCriteria) => await AuthorizationService.CheckClaims(AuthorizationClaim.ModerationRead) .ShortCircuitAsync(InfractionRepository.SearchSummariesPagedAsync(searchCriteria, sortingCriteria, pagingCriteria));
/// <inheritdoc /> public async Task CreateInfractionAsync(ulong guildId, ulong moderatorId, InfractionType type, ulong subjectId, string reason, TimeSpan?duration) { AuthorizationService.RequireClaims(_createInfractionClaimsByType[type]); await RequireSubjectRankLowerThanModeratorRankAsync(guildId, moderatorId, subjectId); var guild = await DiscordClient.GetGuildAsync(guildId); IGuildUser subject; if (!await UserService.GuildUserExistsAsync(guildId, subjectId)) { subject = await UserService.GetUserInformationAsync(guildId, subjectId); if (subject == null) { throw new InvalidOperationException($"The given subject was not valid, ID: {subjectId}"); } await UserService.TrackUserAsync(subject); } else { subject = await UserService.GetGuildUserAsync(guildId, subjectId); } if (reason == null) { throw new ArgumentNullException(nameof(reason)); } if (reason.Length > 1000) { throw new ArgumentException("Reason must be less than 1000 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"); } using (var transaction = await InfractionRepository.BeginCreateTransactionAsync()) { if ((type == InfractionType.Mute) || (type == InfractionType.Ban)) { if (await InfractionRepository.AnyAsync(new InfractionSearchCriteria() { GuildId = guildId, Types = new[] { type }, SubjectId = subjectId, 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(); try { var guildName = await DiscordClient.GetGuildAsync(guildId); Stats.Increment("infractions", tags: new[] { $"infraction_type:{type}", $"guild:{guild.Name}" }); } catch (Exception) { // The world mourned, but nothing of tremendous value was lost. } } // 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: await subject.AddRoleAsync( await GetDesignatedMuteRoleAsync(guild)); break; case InfractionType.Ban: await guild.AddBanAsync(subject, reason : reason); break; } }