-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Moderation: Support appeal cooldowns #435
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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<string> reasonParts) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -162,4 +167,59 @@ private async Task<CommandResult> CheckTimeout(CommandContext context) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
: $"{targetUser.Name} is not timed out. {infoText}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
private async Task<CommandResult> SetAppealCooldown(CommandContext context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
(User targetUser, TimeSpan timeSpan) = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await context.ParseArgs<User, TimeSpan>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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<CommandResult> SetUnappealable(CommandContext context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
User targetUser = await context.ParseArgs<User>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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<CommandResult> CheckAppealCooldown(CommandContext context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
User targetUser = await context.ParseArgs<User>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (!targetUser.Banned) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return new CommandResult { Response = $"{targetUser.Name} is not banned." }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
AppealCooldownLog? recentLog = await _appealCooldownRepo.FindMostRecent(targetUser.Id); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
string? issuerName = recentLog?.IssuerUserId == null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
? "<automated>" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
: (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}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+217
to
+223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not sure if
Suggested change
Or, given the infoText gets appended anyway, maybe:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<ModerationActionPerformedEventArgs>? 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); | ||
Comment on lines
+81
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to make it impossible to change the appeal cooldown without logging, would it be better to put both into one call somehow? Not sure how example though |
||
|
||
Comment on lines
+76
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this entire section only run if |
||
ModerationActionPerformed?.Invoke(this, new ModerationActionPerformedEventArgs( | ||
issuerUser, targetUser, isBan ? ModerationActionType.Ban : ModerationActionType.Unban)); | ||
|
||
|
@@ -112,4 +123,34 @@ await _timeoutLogRepo.LogTimeout( | |
|
||
return TimeoutResult.Ok; | ||
} | ||
|
||
public Task<SetAppealCooldownResult> SetAppealCooldown(User issueruser, User targetuser, Duration duration) => | ||
_SetAppealCooldown(issueruser, targetuser, duration); | ||
public Task<SetAppealCooldownResult> SetUnappealable(User issueruser, User targetuser) => | ||
_SetAppealCooldown(issueruser, targetuser, null); | ||
|
||
private async Task<SetAppealCooldownResult> _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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -76,6 +76,10 @@ public class User : PropertyEquatable<User> | |
|
||
public Instant? TimeoutExpiration { get; init; } | ||
public bool Banned { get; init; } | ||
/// <summary> | ||
/// When a user can appeal. If null, the user is never allowed to appeal. | ||
/// </summary> | ||
public Instant? AppealDate { get; init; } | ||
Comment on lines
+79
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you add a clarification how this value should get interpreted in the case the user isn't banned? Maybe it can just be any value, but that arbitrary value should bear no meaning in that case? |
||
|
||
public User( | ||
string id, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might be impossible since we never delete anyone from the users repo as far as I know, but as a safeguard against failing to look up a user, maybe sth. like this?