diff --git a/TPP.Core/Commands/Definitions/ModerationCommands.cs b/TPP.Core/Commands/Definitions/ModerationCommands.cs index 4ad65d8c..233695cb 100644 --- a/TPP.Core/Commands/Definitions/ModerationCommands.cs +++ b/TPP.Core/Commands/Definitions/ModerationCommands.cs @@ -19,15 +19,17 @@ public class ModerationCommands : ICommandCollection private readonly IBanLogRepo _banLogRepo; private readonly ITimeoutLogRepo _timeoutLogRepo; private readonly IUserRepo _userRepo; + private readonly IAppealCooldownLogRepo _appealCooldownRepo; private readonly IClock _clock; public ModerationCommands( - ModerationService moderationService, IBanLogRepo banLogRepo, ITimeoutLogRepo timeoutLogRepo, IUserRepo userRepo, + ModerationService moderationService, IBanLogRepo banLogRepo, ITimeoutLogRepo timeoutLogRepo, IAppealCooldownLogRepo appealCooldownRepo, IUserRepo userRepo, IClock clock) { _moderationService = moderationService; _banLogRepo = banLogRepo; _timeoutLogRepo = timeoutLogRepo; + _appealCooldownRepo = appealCooldownRepo; _userRepo = userRepo; _clock = clock; } @@ -40,6 +42,9 @@ public ModerationCommands( new Command("timeout", TimeoutCmd), new Command("untimeout", UntimeoutCmd), new Command("checktimeout", CheckTimeout), + new Command("setappealcooldown", SetAppealCooldown), + new Command("setunappealable", SetUnappealable), + new Command("checkappealcooldown", CheckAppealCooldown), }.Select(cmd => cmd.WithModeratorsOnly()); private static string ParseReasonArgs(ManyOf reasonParts) @@ -162,4 +167,59 @@ private async Task CheckTimeout(CommandContext context) : $"{targetUser.Name} is not timed out. {infoText}" }; } + + private async Task SetAppealCooldown(CommandContext context) + { + (User targetUser, TimeSpan timeSpan) = + await context.ParseArgs(); + Duration duration = Duration.FromTimeSpan(timeSpan); + return new CommandResult + { + Response = await _moderationService.SetAppealCooldown(context.Message.User, targetUser, duration) switch + { + SetAppealCooldownResult.Ok => $"{targetUser.Name} is now eligible to appeal at {_clock.GetCurrentInstant() + duration}.", + SetAppealCooldownResult.UserNotBanned => $"{targetUser.Name} is not banned.", + SetAppealCooldownResult.AlreadyPermanent => throw new InvalidOperationException("Unexpected AlreadyPermanent repsonse"), + } + }; + } + + private async Task SetUnappealable(CommandContext context) + { + User targetUser = await context.ParseArgs(); + return new CommandResult + { + Response = await _moderationService.SetUnappealable(context.Message.User, targetUser) switch + { + SetAppealCooldownResult.Ok => $"{targetUser.Name} is now ineligible to appeal their ban.", + SetAppealCooldownResult.UserNotBanned => $"{targetUser.Name} is not banned.", + SetAppealCooldownResult.AlreadyPermanent => $"{targetUser.Name} is already ineligible to appeal.", + } + }; + } + + private async Task CheckAppealCooldown(CommandContext context) + { + User targetUser = await context.ParseArgs(); + if (!targetUser.Banned) + return new CommandResult { Response = $"{targetUser.Name} is not banned." }; + + AppealCooldownLog? recentLog = await _appealCooldownRepo.FindMostRecent(targetUser.Id); + string? issuerName = recentLog?.IssuerUserId == null + ? "" + : (await _userRepo.FindById(recentLog.IssuerUserId))?.Name; + string infoText = recentLog == null + ? "No logs available." + : $"Last action was {recentLog.Type} by {issuerName} " + + $"at {recentLog.Timestamp}"; + if (recentLog?.Duration != null) infoText += $" for {recentLog.Duration.Value.ToTimeSpan().ToHumanReadable()}"; + + return new CommandResult { Response = + targetUser.AppealDate is null + ? $"{targetUser.Name} is not eligible to appeal. {infoText}" + : targetUser.AppealDate < _clock.GetCurrentInstant() + ? $"{targetUser.Name} is eligible to appeal now. {infoText}" + : $"{targetUser.Name} will be eligible to appeal on {targetUser.AppealDate}. {infoText}" + }; + } } diff --git a/TPP.Core/Moderation/ModerationService.cs b/TPP.Core/Moderation/ModerationService.cs index 3de3b51f..048d7eac 100644 --- a/TPP.Core/Moderation/ModerationService.cs +++ b/TPP.Core/Moderation/ModerationService.cs @@ -8,6 +8,7 @@ namespace TPP.Core.Moderation; public enum TimeoutResult { Ok, MustBe2WeeksOrLess, UserIsBanned, UserIsModOrOp, NotSupportedInChannel } public enum BanResult { Ok, UserIsModOrOp, NotSupportedInChannel } +public enum SetAppealCooldownResult { Ok, UserNotBanned, AlreadyPermanent } public enum ModerationActionType { Ban, Unban, Timeout, Untimeout } public class ModerationActionPerformedEventArgs : EventArgs { @@ -28,17 +29,19 @@ public class ModerationService private readonly IExecutor? _executor; private readonly ITimeoutLogRepo _timeoutLogRepo; private readonly IBanLogRepo _banLogRepo; + private readonly IAppealCooldownLogRepo _appealCooldownLogRepo; private readonly IUserRepo _userRepo; public event EventHandler? ModerationActionPerformed; public ModerationService( - IClock clock, IExecutor? executor, ITimeoutLogRepo timeoutLogRepo, IBanLogRepo banLogRepo, IUserRepo userRepo) + IClock clock, IExecutor? executor, ITimeoutLogRepo timeoutLogRepo, IBanLogRepo banLogRepo, IAppealCooldownLogRepo appealCooldownLogRepo, IUserRepo userRepo) { _clock = clock; _executor = executor; _timeoutLogRepo = timeoutLogRepo; _banLogRepo = banLogRepo; + _appealCooldownLogRepo = appealCooldownLogRepo; _userRepo = userRepo; } @@ -70,6 +73,14 @@ await _timeoutLogRepo.LogTimeout( // bans/unbans automatically lift timeouts issuerUser.Id, now, null); await _userRepo.SetBanned(targetUser, isBan); + // First ban can be appealed after 1 month by default. + // I don't want to automatically calculate how many bans the user has had, because some might be joke/mistakes + // A mod should manually set the next appeal cooldown based on the rules. + var DEFAULT_APPEAL_TIME = Duration.FromDays(30); + Instant expiration = now + DEFAULT_APPEAL_TIME; + await _userRepo.SetAppealCooldown(targetUser, expiration); + await _appealCooldownLogRepo.LogAppealCooldownChange(targetUser.Id, "auto_appeal_cooldown", issuerUser.Id, now, DEFAULT_APPEAL_TIME); + ModerationActionPerformed?.Invoke(this, new ModerationActionPerformedEventArgs( issuerUser, targetUser, isBan ? ModerationActionType.Ban : ModerationActionType.Unban)); @@ -112,4 +123,34 @@ await _timeoutLogRepo.LogTimeout( return TimeoutResult.Ok; } + + public Task SetAppealCooldown(User issueruser, User targetuser, Duration duration) => + _SetAppealCooldown(issueruser, targetuser, duration); + public Task SetUnappealable(User issueruser, User targetuser) => + _SetAppealCooldown(issueruser, targetuser, null); + + private async Task _SetAppealCooldown( + User issueruser, User targetUser, Duration? duration) + { + if (!targetUser.Banned) + return SetAppealCooldownResult.UserNotBanned; + + Instant now = _clock.GetCurrentInstant(); + + if (duration.HasValue) + { + Instant expiration = now + duration.Value; + await _userRepo.SetAppealCooldown(targetUser, expiration); + await _appealCooldownLogRepo.LogAppealCooldownChange(targetUser.Id, "manual_cooldown_change", issueruser.Id, now, duration); + } + else + { + if (targetUser.AppealDate is null) + return SetAppealCooldownResult.AlreadyPermanent; + await _userRepo.SetAppealCooldown(targetUser, null); + await _appealCooldownLogRepo.LogAppealCooldownChange(targetUser.Id, "manual_perma", issueruser.Id, now, null); + } + + return SetAppealCooldownResult.Ok; + } } diff --git a/TPP.Core/Setups.cs b/TPP.Core/Setups.cs index 8ee31e96..5ac101bf 100644 --- a/TPP.Core/Setups.cs +++ b/TPP.Core/Setups.cs @@ -112,7 +112,8 @@ public static CommandProcessor SetUpCommandProcessor( databases.CommandLogger, argsParser); var moderationService = new ModerationService( - SystemClock.Instance, executor, databases.TimeoutLogRepo, databases.BanLogRepo, databases.UserRepo); + SystemClock.Instance, executor, databases.TimeoutLogRepo, databases.BanLogRepo, + databases.AppealCooldownLogRepo, databases.UserRepo); ILogger logger = loggerFactory.CreateLogger(); moderationService.ModerationActionPerformed += (_, args) => TaskToVoidSafely(logger, () => { @@ -139,7 +140,7 @@ public static CommandProcessor SetUpCommandProcessor( chatModeChanger, databases.LinkedAccountRepo, databases.ResponseCommandRepo ).Commands, new ModerationCommands( - moderationService, databases.BanLogRepo, databases.TimeoutLogRepo, databases.UserRepo, + moderationService, databases.BanLogRepo, databases.TimeoutLogRepo, databases.AppealCooldownLogRepo, databases.UserRepo, SystemClock.Instance ).Commands }.SelectMany(cmds => cmds).ToList(); @@ -189,6 +190,7 @@ public record Databases( IModbotLogRepo ModbotLogRepo, IBanLogRepo BanLogRepo, ITimeoutLogRepo TimeoutLogRepo, + IAppealCooldownLogRepo AppealCooldownLogRepo, IResponseCommandRepo ResponseCommandRepo, IRunCounterRepo RunCounterRepo, IInputLogRepo InputLogRepo, @@ -252,6 +254,7 @@ public static Databases SetUpRepositories(ILoggerFactory loggerFactory, ILogger ModbotLogRepo: new ModbotLogRepo(mongoDatabase), BanLogRepo: new BanLogRepo(mongoDatabase), TimeoutLogRepo: new TimeoutLogRepo(mongoDatabase), + AppealCooldownLogRepo: new AppealCooldownLogRepo(mongoDatabase), ResponseCommandRepo: new ResponseCommandRepo(mongoDatabase), RunCounterRepo: new RunCounterRepo(mongoDatabase), InputLogRepo: new InputLogRepo(mongoDatabase), diff --git a/TPP.Model/Logs.cs b/TPP.Model/Logs.cs index 6b143783..7fe0b86e 100644 --- a/TPP.Model/Logs.cs +++ b/TPP.Model/Logs.cs @@ -70,6 +70,8 @@ public record BanLog( public record TimeoutLog( string Id, string Type, string UserId, string Reason, string? IssuerUserId, Instant Timestamp, Duration? Duration); + public record AppealCooldownLog( + string Id, string Type, string UserId, string IssuerUserId, Instant Timestamp, Duration? Duration); public record SubscriptionLog( string Id, diff --git a/TPP.Model/User.cs b/TPP.Model/User.cs index fe583a63..1333cb5c 100644 --- a/TPP.Model/User.cs +++ b/TPP.Model/User.cs @@ -76,6 +76,10 @@ public class User : PropertyEquatable public Instant? TimeoutExpiration { get; init; } public bool Banned { get; init; } + /// + /// When a user can appeal. If null, the user is never allowed to appeal. + /// + public Instant? AppealDate { get; init; } public User( string id, diff --git a/TPP.Persistence.MongoDB/Repos/ModerationRelatedRepos.cs b/TPP.Persistence.MongoDB/Repos/ModerationRelatedRepos.cs index 12157ac1..162865ce 100644 --- a/TPP.Persistence.MongoDB/Repos/ModerationRelatedRepos.cs +++ b/TPP.Persistence.MongoDB/Repos/ModerationRelatedRepos.cs @@ -163,3 +163,56 @@ public async Task LogTimeout( .SortByDescending(log => log.Timestamp) .FirstOrDefaultAsync(); } + +public class AppealCooldownLogRepo : IAppealCooldownLogRepo +{ + private const string CollectionName = "appealcooldownlog"; + + public readonly IMongoCollection Collection; + + static AppealCooldownLogRepo() + { + BsonClassMap.RegisterClassMap(cm => + { + cm.MapIdProperty(b => b.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(ObjectIdAsStringSerializer.Instance); + cm.MapProperty(b => b.Type).SetElementName("type"); + cm.MapProperty(b => b.UserId).SetElementName("user"); + cm.MapProperty(b => b.IssuerUserId).SetElementName("issuer"); + cm.MapProperty(b => b.Timestamp).SetElementName("timestamp"); + cm.MapProperty(b => b.Duration).SetElementName("duration") + .SetSerializer(NullableDurationAsSecondsSerializer.Instance); + }); + } + + public AppealCooldownLogRepo(IMongoDatabase database) + { + database.CreateCollectionIfNotExists(CollectionName).Wait(); + Collection = database.GetCollection(CollectionName); + InitIndexes(); + } + + private void InitIndexes() + { + Collection.Indexes.CreateMany(new[] + { + new CreateIndexModel(Builders.IndexKeys.Ascending(u => u.UserId)), + new CreateIndexModel(Builders.IndexKeys.Descending(u => u.Timestamp)), + }); + } + + public async Task LogAppealCooldownChange( + string userId, string type, string issuerUserId, Instant timestamp, Duration? duration) + { + var log = new AppealCooldownLog(string.Empty, type, userId, issuerUserId, timestamp, duration); + await Collection.InsertOneAsync(log); + Debug.Assert(log.Id.Length > 0, "The MongoDB driver injected a generated ID"); + return log; + } + + public async Task FindMostRecent(string userId) => await Collection + .Find(log => log.UserId == userId) + .SortByDescending(log => log.Timestamp) + .FirstOrDefaultAsync(); +} diff --git a/TPP.Persistence.MongoDB/Repos/UserRepo.cs b/TPP.Persistence.MongoDB/Repos/UserRepo.cs index f9b732bf..04e0f93c 100644 --- a/TPP.Persistence.MongoDB/Repos/UserRepo.cs +++ b/TPP.Persistence.MongoDB/Repos/UserRepo.cs @@ -62,6 +62,7 @@ static UserRepo() cm.MapProperty(u => u.TimeoutExpiration).SetElementName("timeout_expiration"); cm.MapProperty(u => u.Roles).SetElementName("roles") .SetDefaultValue(new HashSet()); + cm.MapProperty(u => u.AppealDate).SetElementName("appeal_date").SetDefaultValue(Instant.MinValue); }); } @@ -274,5 +275,11 @@ await Collection.FindOneAndUpdateAsync( .Set(u => u.Banned, false) .Set(u => u.TimeoutExpiration, timeoutExpiration), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + public async Task SetAppealCooldown(User user, Instant? canAppeal) => + await Collection.FindOneAndUpdateAsync( + u => u.Id == user.Id, + Builders.Update.Set(u => u.AppealDate, canAppeal), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } } diff --git a/TPP.Persistence/IUserRepo.cs b/TPP.Persistence/IUserRepo.cs index 32fc2214..0ea776d7 100644 --- a/TPP.Persistence/IUserRepo.cs +++ b/TPP.Persistence/IUserRepo.cs @@ -28,6 +28,10 @@ public Task SetSubscriptionInfo( public Task SetBanned(User user, bool banned); public Task SetTimedOut(User user, Instant? timeoutExpiration); + /// + /// Sets the time a user can appeal a ban. Null = can't appeal. + /// + public Task SetAppealCooldown(User user, Instant? canAppeal); /// Unselects the specified species as the presented badge if it is the currently equipped species. /// Used for resetting the equipped badge after a user lost all of that species' badges. diff --git a/TPP.Persistence/ModerationRelatedRepos.cs b/TPP.Persistence/ModerationRelatedRepos.cs index d3219ba8..1c88450f 100644 --- a/TPP.Persistence/ModerationRelatedRepos.cs +++ b/TPP.Persistence/ModerationRelatedRepos.cs @@ -20,4 +20,10 @@ Task LogTimeout( string userId, string type, string reason, string? issuerUserId, Instant timestamp, Duration? duration); Task FindMostRecent(string userId); } + public interface IAppealCooldownLogRepo + { + Task LogAppealCooldownChange( + string userId, string type, string issuerUserId, Instant timestamp, Duration? duration); + Task FindMostRecent(string userId); + } }