diff --git a/src/Nellebot.Common/Models/UserLogs/UserLogType.cs b/src/Nellebot.Common/Models/UserLogs/UserLogType.cs index df8b635f..2e10d729 100644 --- a/src/Nellebot.Common/Models/UserLogs/UserLogType.cs +++ b/src/Nellebot.Common/Models/UserLogs/UserLogType.cs @@ -5,8 +5,8 @@ public enum UserLogType Unknown = 0, UsernameChange = 1, NicknameChange = 2, - AvatarHashChange = 3, - GuildAvatarHashChange = 4, JoinedServer = 5, LeftServer = 6, + Quarantined = 7, + Approved = 8, } diff --git a/src/Nellebot.Common/Models/UserLogs/UserLogTypesMap.cs b/src/Nellebot.Common/Models/UserLogs/UserLogTypesMap.cs index 51c9679f..155a302d 100644 --- a/src/Nellebot.Common/Models/UserLogs/UserLogTypesMap.cs +++ b/src/Nellebot.Common/Models/UserLogs/UserLogTypesMap.cs @@ -10,9 +10,9 @@ public static class UserLogTypesMap { UserLogType.Unknown, typeof(object) }, { UserLogType.UsernameChange, typeof(string) }, { UserLogType.NicknameChange, typeof(string) }, - { UserLogType.AvatarHashChange, typeof(string) }, - { UserLogType.GuildAvatarHashChange, typeof(string) }, { UserLogType.JoinedServer, typeof(DateTime) }, { UserLogType.LeftServer, typeof(DateTime) }, + { UserLogType.Quarantined, typeof(string) }, + { UserLogType.Approved, typeof(string) }, }; } diff --git a/src/Nellebot/BotOptions.cs b/src/Nellebot/BotOptions.cs index 446ffa3a..bd92a314 100644 --- a/src/Nellebot/BotOptions.cs +++ b/src/Nellebot/BotOptions.cs @@ -58,6 +58,10 @@ public class BotOptions public ulong GhostRoleId { get; init; } + public ulong QuarantineRoleId { get; init; } + + public ulong QuarantineChannelId { get; init; } + /// /// Gets a value indicating whether feature flag for populating message refs on Ready event. /// diff --git a/src/Nellebot/CommandHandlers/ApproveUser.cs b/src/Nellebot/CommandHandlers/ApproveUser.cs new file mode 100644 index 00000000..6121b64f --- /dev/null +++ b/src/Nellebot/CommandHandlers/ApproveUser.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.Commands; +using DSharpPlus.Commands.Processors.SlashCommands; +using DSharpPlus.Entities; +using MediatR; +using Microsoft.Extensions.Options; +using Nellebot.Services; + +namespace Nellebot.CommandHandlers; + +public record ApproveUserCommand(CommandContext Ctx, DiscordMember Member) + : BotCommandCommand(Ctx); + +public class ApproveUserHandler : IRequestHandler +{ + private readonly QuarantineService _quarantineService; + private readonly BotOptions _options; + + public ApproveUserHandler(IOptions options, QuarantineService quarantineService) + { + _quarantineService = quarantineService; + _options = options.Value; + } + + public async Task Handle(ApproveUserCommand request, CancellationToken cancellationToken) + { + CommandContext ctx = request.Ctx; + DiscordMember currentMember = ctx.Member ?? throw new Exception("Member is null"); + DiscordMember targetMember = request.Member; + + if (ctx.Member?.Id == targetMember.Id) + { + await TryRespondEphemeral(ctx, "Hmm"); + return; + } + + bool userIsQuarantined = targetMember.Roles.Any(r => r.Id == _options.QuarantineRoleId); + + if (!userIsQuarantined) + { + await TryRespondEphemeral(ctx, "User is not quarantined"); + + return; + } + + await _quarantineService.ApproveMember(targetMember, currentMember); + + await TryRespondEphemeral(ctx, "User approved successfully"); + } + + private static async Task TryRespondEphemeral(CommandContext ctx, string successMessage) + { + if (ctx is SlashCommandContext slashCtx) + await slashCtx.RespondAsync(successMessage, ephemeral: true); + else + await ctx.RespondAsync(successMessage); + } +} diff --git a/src/Nellebot/CommandHandlers/MessageTemplates/SetGreetingMessage.cs b/src/Nellebot/CommandHandlers/MessageTemplates/SetGreetingMessage.cs index 871098fe..ebbf6bdf 100644 --- a/src/Nellebot/CommandHandlers/MessageTemplates/SetGreetingMessage.cs +++ b/src/Nellebot/CommandHandlers/MessageTemplates/SetGreetingMessage.cs @@ -36,7 +36,7 @@ public async Task Handle(SetGreetingMessageCommand request, CancellationToken ca string previewMemberMention = ctx.Member?.Mention ?? string.Empty; - string? messagePreview = await _botSettingsService.GetGreetingsMessage(previewMemberMention); + string? messagePreview = await _botSettingsService.GetGreetingMessage(previewMemberMention); var sb = new StringBuilder("Greeting message updated successfully. Here's a preview:"); sb.AppendLine(); diff --git a/src/Nellebot/CommandHandlers/MessageTemplates/SetQuarantineMessage.cs b/src/Nellebot/CommandHandlers/MessageTemplates/SetQuarantineMessage.cs new file mode 100644 index 00000000..e2c0439f --- /dev/null +++ b/src/Nellebot/CommandHandlers/MessageTemplates/SetQuarantineMessage.cs @@ -0,0 +1,49 @@ +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.Commands; +using MediatR; +using Nellebot.Services; + +namespace Nellebot.CommandHandlers.MessageTemplates; + +public record SetQuarantineMessageCommand : BotCommandCommand +{ + public SetQuarantineMessageCommand(CommandContext ctx, string quarantineMessage) + : base(ctx) + { + QuarantineMessage = quarantineMessage; + } + + public string QuarantineMessage { get; } +} + +public class SetQuarantineMessageHandler : IRequestHandler +{ + private readonly BotSettingsService _botSettingsService; + + public SetQuarantineMessageHandler(BotSettingsService botSettingsService) + { + _botSettingsService = botSettingsService; + } + + public async Task Handle(SetQuarantineMessageCommand request, CancellationToken cancellationToken) + { + CommandContext ctx = request.Ctx; + string message = request.QuarantineMessage; + + await _botSettingsService.SetQuarantineMessage(message); + + string previewMemberMention = ctx.Member?.Mention ?? string.Empty; + const string previewReason = "Sussy"; + + string? messagePreview = await _botSettingsService.GetQuarantineMessage(previewMemberMention, previewReason); + + var sb = new StringBuilder("Quarantine message updated successfully. Here's a preview:"); + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine(messagePreview); + + await ctx.RespondAsync(sb.ToString()); + } +} diff --git a/src/Nellebot/CommandHandlers/QuarantineUser.cs b/src/Nellebot/CommandHandlers/QuarantineUser.cs new file mode 100644 index 00000000..1be3ea10 --- /dev/null +++ b/src/Nellebot/CommandHandlers/QuarantineUser.cs @@ -0,0 +1,116 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus.Commands; +using DSharpPlus.Commands.Processors.SlashCommands; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using DSharpPlus.Interactivity; +using MediatR; +using Microsoft.Extensions.Options; +using Nellebot.Services; +using Nellebot.Utils; + +namespace Nellebot.CommandHandlers; + +public record QuarantineUserCommand(CommandContext Ctx, DiscordMember Member, string? Reason) + : BotCommandCommand(Ctx); + +public class QuarantineUserHandler : IRequestHandler +{ + private const string ModalTextInputId = "modal-text-input"; + private readonly InteractivityExtension _interactivityExtension; + private readonly BotOptions _options; + private readonly QuarantineService _quarantineService; + + public QuarantineUserHandler( + IOptions options, + QuarantineService quarantineService, + InteractivityExtension interactivityExtension) + { + _quarantineService = quarantineService; + _interactivityExtension = interactivityExtension; + _options = options.Value; + } + + public async Task Handle(QuarantineUserCommand request, CancellationToken cancellationToken) + { + CommandContext ctx = request.Ctx; + DiscordMember currentMember = ctx.Member ?? throw new Exception("Member is null"); + DiscordMember targetMember = request.Member; + + if (ctx.Member?.Id == targetMember.Id) + { + await ctx.TryRespondEphemeral("Hmm"); + return; + } + + TimeSpan guildAge = DateTimeOffset.UtcNow - targetMember.JoinedAt; + + int maxAgeHours = _options.ValhallKickMaxMemberAgeInHours; + + if (guildAge.TotalHours >= maxAgeHours) + { + var content = + $"You cannot quarantine this user. They have been a member of the server for more than {maxAgeHours} hours."; + + await ctx.TryRespondEphemeral(content); + + return; + } + + bool userAlreadyQuarantined = targetMember.Roles.Any(r => r.Id == _options.QuarantineRoleId); + + if (userAlreadyQuarantined) + { + await ctx.TryRespondEphemeral("User is already quarantined"); + } + + DiscordInteraction? modalInteraction = null; + string? quarantineReason = null; + + if (ctx is SlashCommandContext slashCtx && request.Reason == null) + { + ModalSubmittedEventArgs modalSubmissionResult = await ShowGetReasonModal(slashCtx); + + modalInteraction = modalSubmissionResult.Interaction; + + quarantineReason = modalSubmissionResult.Values[ModalTextInputId]; + + await modalInteraction.DeferAsync(ephemeral: true); + } + + quarantineReason = quarantineReason.NullOrWhiteSpaceTo("/shrug"); + + await _quarantineService.QuarantineMember(targetMember, currentMember, quarantineReason); + + await ctx.TryRespondEphemeral("User quarantined successfully", modalInteraction); + } + + private async Task ShowGetReasonModal(SlashCommandContext ctx) + { + var modalId = $"get-reason-modal-{Guid.NewGuid()}"; + + DiscordInteractionResponseBuilder interactionBuilder = new DiscordInteractionResponseBuilder() + .WithTitle("Quarantine user") + .WithCustomId(modalId) + .AddTextInputComponent( + new DiscordTextInputComponent( + "Reason", + ModalTextInputId, + "Write a reason for quarantining", + string.Empty, + required: true, + DiscordTextInputStyle.Paragraph, + min_length: 0, + DiscordConstants.MaxAuditReasonLength)); + + await ctx.RespondWithModalAsync(interactionBuilder); + + InteractivityResult modalSubmission = + await _interactivityExtension.WaitForModalAsync(modalId, DiscordConstants.MaxDeferredInteractionWait); + + return modalSubmission.Result; + } +} diff --git a/src/Nellebot/CommandHandlers/ValhallKickUser.cs b/src/Nellebot/CommandHandlers/ValhallKickUser.cs index 4a169c68..91ba2b23 100644 --- a/src/Nellebot/CommandHandlers/ValhallKickUser.cs +++ b/src/Nellebot/CommandHandlers/ValhallKickUser.cs @@ -4,21 +4,26 @@ using DSharpPlus.Commands; using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using DSharpPlus.Interactivity; using MediatR; using Microsoft.Extensions.Options; using Nellebot.Utils; namespace Nellebot.CommandHandlers; -public record ValhallKickUserCommand(CommandContext Ctx, DiscordMember Member, string Reason) +public record ValhallKickUserCommand(CommandContext Ctx, DiscordMember Member, string? Reason) : BotCommandCommand(Ctx); public class ValhallKickUserHandler : IRequestHandler { + private readonly InteractivityExtension _interactivityExtension; + private const string ModalTextInputId = "modal-text-input"; private readonly BotOptions _options; - public ValhallKickUserHandler(IOptions options) + public ValhallKickUserHandler(IOptions options, InteractivityExtension interactivityExtension) { + _interactivityExtension = interactivityExtension; _options = options.Value; } @@ -30,7 +35,7 @@ public async Task Handle(ValhallKickUserCommand request, CancellationToken cance if (ctx.Member?.Id == targetMember.Id) { - await TryRespondEphemeral(ctx, "Hmm"); + await ctx.TryRespondEphemeral("Hmm"); return; } @@ -43,24 +48,58 @@ public async Task Handle(ValhallKickUserCommand request, CancellationToken cance var content = $"You cannot vkick this user. They have been a member of the server for more than {maxAgeHours} hours."; - await TryRespondEphemeral(ctx, content); + await ctx.TryRespondEphemeral(content); return; } - var kickReason = - $"Kicked on behalf of {currentMember.DisplayName}. Reason: {request.Reason.NullOrWhiteSpaceTo("/shrug")}"; + DiscordInteraction? modalInteraction = null; + string? kickReason = null; - await targetMember.RemoveAsync(kickReason); + if (ctx is SlashCommandContext slashCtx && request.Reason == null) + { + ModalSubmittedEventArgs modalSubmissionResult = await ShowGetReasonModal(slashCtx); + + modalInteraction = modalSubmissionResult.Interaction; + + kickReason = modalSubmissionResult.Values[ModalTextInputId]; + + await modalInteraction.DeferAsync(ephemeral: true); + } + + kickReason = kickReason.NullOrWhiteSpaceTo("/shrug"); - await TryRespondEphemeral(ctx, "User vkicked successfully"); + var onBehalfOfReason = + $"Kicked on behalf of {currentMember.DisplayName}. Reason: {kickReason}"; + + await targetMember.RemoveAsync(onBehalfOfReason); + + await ctx.TryRespondEphemeral("User vkicked successfully", modalInteraction); } - private static async Task TryRespondEphemeral(CommandContext ctx, string successMessage) + private async Task ShowGetReasonModal(SlashCommandContext ctx) { - if (ctx is SlashCommandContext slashCtx) - await slashCtx.RespondAsync(successMessage, true); - else - await ctx.RespondAsync(successMessage); + var modalId = $"get-reason-modal-{Guid.NewGuid()}"; + + DiscordInteractionResponseBuilder interactionBuilder = new DiscordInteractionResponseBuilder() + .WithTitle("Valhall kick user") + .WithCustomId(modalId) + .AddTextInputComponent( + new DiscordTextInputComponent( + "Reason", + ModalTextInputId, + "Write a reason for kicking", + string.Empty, + required: true, + DiscordTextInputStyle.Paragraph, + min_length: 0, + DiscordConstants.MaxAuditReasonLength)); + + await ctx.RespondWithModalAsync(interactionBuilder); + + InteractivityResult modalSubmission = + await _interactivityExtension.WaitForModalAsync(modalId, DiscordConstants.MaxDeferredInteractionWait); + + return modalSubmission.Result; } } diff --git a/src/Nellebot/CommandModules/AdminModule.cs b/src/Nellebot/CommandModules/AdminModule.cs index d3d1d975..08c7f2d0 100644 --- a/src/Nellebot/CommandModules/AdminModule.cs +++ b/src/Nellebot/CommandModules/AdminModule.cs @@ -49,6 +49,12 @@ public Task SetGreetingMessage(CommandContext ctx, [RemainingText] string messag return _commandQueue.Writer.WriteAsync(new SetGreetingMessageCommand(ctx, message)).AsTask(); } + [Command("set-quarantine-message")] + public Task SetQuarantineMessage(CommandContext ctx, [RemainingText] string message) + { + return _commandQueue.Writer.WriteAsync(new SetQuarantineMessageCommand(ctx, message)).AsTask(); + } + [Command("populate-messages")] public Task PopulateMessages(CommandContext ctx) { diff --git a/src/Nellebot/CommandModules/HelpModule.cs b/src/Nellebot/CommandModules/HelpModule.cs index f8aff7e4..ab175fbd 100644 --- a/src/Nellebot/CommandModules/HelpModule.cs +++ b/src/Nellebot/CommandModules/HelpModule.cs @@ -45,11 +45,11 @@ public ValueTask Help(CommandContext ctx) sb.AppendLine(); sb.AppendLine("Staff commands:"); - sb.AppendLine($"`{commandPrefix}help admin-misc`"); + sb.AppendLine($"`{commandPrefix}help admin`"); sb.AppendLine($"`{commandPrefix}help valhall`"); sb.AppendLine(); - sb.AppendLine($"Most of the commands are also available as slash commands."); + sb.AppendLine("Most of the commands are also available as slash commands."); sb.AppendLine($"Try typing `{slashPrefix}` in the chat to see them."); sb.AppendLine(); @@ -67,20 +67,26 @@ public ValueTask Help(CommandContext ctx) return ctx.RespondAsync(eb); } - [Command("admin-misc")] - public ValueTask HelpAdminMisc(CommandContext ctx) + [Command("admin")] + public ValueTask HelpAdmin(CommandContext ctx) { var sb = new StringBuilder(); var command = $"{_options.CommandPrefix}admin"; sb.AppendLine($"`{command} nickname [name]`"); - sb.AppendLine("` Change Reifnir's nickname`"); + sb.AppendLine("` Change Reifnir's nickname.`"); sb.AppendLine(); sb.AppendLine($"`{command} set-greeting-message [message]`"); sb.AppendLine("` Set the greeting message that Reifnir welcomes new users with.`"); - sb.AppendLine("` Use the token $USER to @mention the new user in the message`"); + sb.AppendLine("` Use the token $USER to @mention the new user in the message.`"); + sb.AppendLine(); + + sb.AppendLine($"`{command} set-quarantine-message [message]`"); + sb.AppendLine("` Set the quarantine message that Reifnir sends to quarantined users.`"); + sb.AppendLine( + "` Use the tokens $USER and $REASON to include the quarantined user's @mention and the quarantine reason in the message.`"); sb.AppendLine(); DiscordEmbed eb = EmbedBuilderHelper.BuildSimpleEmbed("Misc. admin commands", sb.ToString()); @@ -89,19 +95,22 @@ public ValueTask HelpAdminMisc(CommandContext ctx) } [Command("valhall")] - public ValueTask HelpValhallMisc(CommandContext ctx) + public ValueTask HelpValhall(CommandContext ctx) { var sb = new StringBuilder(); string commandPrefix = _options.CommandPrefix; sb.AppendLine($"`{commandPrefix}vkick [user] [reason]`"); - sb.AppendLine("` Kick a recently joined user with a fresh Discord account.`"); - sb.AppendLine("` Max 24hrs server memembership, max 7 days Discord account age.`"); + sb.AppendLine("` Kick a recently joined user (< 48hrs ago).`"); sb.AppendLine(); - sb.AppendLine($"`{commandPrefix}list-award-channels`"); - sb.AppendLine("` List the channels where Reifnir keeps track of cookies.`"); + sb.AppendLine($"`{commandPrefix}quarantine [user] [reason]`"); + sb.AppendLine("` Quarantine a recently joined user (< 48hrs ago).`"); + sb.AppendLine(); + + sb.AppendLine($"`{commandPrefix}approve [user]`"); + sb.AppendLine("` Approve a quarantined user.`"); sb.AppendLine(); sb.AppendLine($"`{commandPrefix}goodbye-msg add [message]`"); @@ -119,6 +128,10 @@ public ValueTask HelpValhallMisc(CommandContext ctx) sb.AppendLine("` List all goodbye message templates.`"); sb.AppendLine(); + sb.AppendLine($"`{commandPrefix}list-award-channels`"); + sb.AppendLine("` List the channels where Reifnir keeps track of cookies.`"); + sb.AppendLine(); + DiscordEmbed eb = EmbedBuilderHelper.BuildSimpleEmbed("Misc. valhall commands", sb.ToString()); return ctx.RespondAsync(eb); diff --git a/src/Nellebot/CommandModules/Messages/MetaMessageMenuModule.cs b/src/Nellebot/CommandModules/Messages/MetaMessageMenuModule.cs index 586b6218..e41c8dc0 100644 --- a/src/Nellebot/CommandModules/Messages/MetaMessageMenuModule.cs +++ b/src/Nellebot/CommandModules/Messages/MetaMessageMenuModule.cs @@ -26,6 +26,8 @@ public async Task AddMessage(SlashCommandContext ctx, DiscordMessage message) await _commandQueue.Writer.WriteAsync(new AddMetaMessageCommand(ctx, ctx.Channel, message)); } + [BaseCommandCheck] + [RequireTrustedMember] [Command("Edit meta message")] [SlashCommandTypes(DiscordApplicationCommandType.MessageContextMenu)] public async Task EditMessage(SlashCommandContext ctx, DiscordMessage message) diff --git a/src/Nellebot/CommandModules/RandomModule.cs b/src/Nellebot/CommandModules/RandomModule.cs index 50dea015..d8957c04 100644 --- a/src/Nellebot/CommandModules/RandomModule.cs +++ b/src/Nellebot/CommandModules/RandomModule.cs @@ -66,4 +66,18 @@ public static ValueTask Ban( { return ctx.RespondAsync($"Why ban **{str}** when I can ban you instead?"); } + + [BaseCommandCheck] + [Command("tingle")] + [Description("Tingle")] + public static ValueTask Tingle(TextCommandContext ctx, [RemainingText] string? text = null) + { + string? theText = ctx.Message.ReferencedMessage?.Content ?? text; + + if (string.IsNullOrEmpty(theText)) return ValueTask.CompletedTask; + + const string specialMention = "<@131479587287728128>"; + + return ctx.RespondAsync($"{specialMention}'s butt got pounded by a {theText}"); + } } diff --git a/src/Nellebot/CommandModules/TrustedMemberModule.cs b/src/Nellebot/CommandModules/TrustedMemberModule.cs index 6ddd1149..10c98a16 100644 --- a/src/Nellebot/CommandModules/TrustedMemberModule.cs +++ b/src/Nellebot/CommandModules/TrustedMemberModule.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Text; using System.Threading.Tasks; using DSharpPlus.Commands; using DSharpPlus.Commands.ArgumentModifiers; +using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; using Microsoft.Extensions.Options; using Nellebot.Attributes; @@ -24,15 +26,63 @@ public TrustedMemberModule(CommandParallelQueueChannel commandQueue, IOptions guildChannels = await ctx.Guild!.GetChannelsAsync(); IEnumerable categoryChannels = guildChannels - .Where( - c => c.Type == DiscordChannelType.Category - && groupIds.Contains(c.Id)); + .Where(c => c.Type == DiscordChannelType.Category + && groupIds.Contains(c.Id)); foreach (DiscordChannel category in categoryChannels) { diff --git a/src/Nellebot/Infrastructure/SharedCacheKeys.cs b/src/Nellebot/Infrastructure/SharedCacheKeys.cs index 53415713..c7efe8be 100644 --- a/src/Nellebot/Infrastructure/SharedCacheKeys.cs +++ b/src/Nellebot/Infrastructure/SharedCacheKeys.cs @@ -6,6 +6,8 @@ public static class SharedCacheKeys public static string GreetingMessage => "GreetingMessage"; + public static string QuarantineMessage => "QuarantineMessage"; + public static string GoodbyeMessages => "GoodbyeMessages"; public static string UserLog => "UserLog_{0}_{1}"; diff --git a/src/Nellebot/Jobs/RoleMaintenanceJob.cs b/src/Nellebot/Jobs/RoleMaintenanceJob.cs index bfbdf521..f6e32879 100644 --- a/src/Nellebot/Jobs/RoleMaintenanceJob.cs +++ b/src/Nellebot/Jobs/RoleMaintenanceJob.cs @@ -45,6 +45,7 @@ public async Task Execute(IJobExecutionContext context) ulong memberRoleId = _options.MemberRoleId; ulong[] memberRoleIds = _options.MemberRoleIds; ulong ghostRoleId = _options.GhostRoleId; + ulong quarantineRoleId = _options.QuarantineRoleId; DiscordGuild guild = _client.Guilds[guildId]; @@ -59,9 +60,9 @@ public async Task Execute(IJobExecutionContext context) DiscordRole ghostRole = guild.Roles[ghostRoleId] ?? throw new Exception($"Could not find ghost role with id {ghostRoleId}"); - await AddMissingMemberRoles(allMembers, memberRoleIds, memberRole, cancellationToken); + await AddMissingMemberRoles(allMembers, memberRoleIds, memberRole, quarantineRoleId, cancellationToken); - await RemoveUnneededMemberRoles(allMembers, memberRoleIds, memberRole, cancellationToken); + await RemoveUnneededMemberRoles(allMembers, memberRoleIds, memberRole, quarantineRoleId, cancellationToken); await AddMissingGhostRoles(allMembers, ghostRole, cancellationToken); @@ -80,11 +81,11 @@ private async Task AddMissingMemberRoles( List allMembers, ulong[] memberRoleIds, DiscordRole memberRole, + ulong quarantineRoleId, CancellationToken cancellationToken) { List missingMemberRoleMembers = allMembers - .Where(m => m.Roles.All(r => r.Id != memberRole.Id)) - .Where(m => m.Roles.Any(r => memberRoleIds.Contains(r.Id))) + .Where(m => !HasMemberRole(m) && HasMandatoryRoles(m) && !HasQuarantineRole(m)) .ToList(); if (missingMemberRoleMembers.Count != 0) @@ -102,18 +103,34 @@ private async Task AddMissingMemberRoles( _discordLogger.LogExtendedActivityMessage( $"Done adding Member role for {successCount}/{totalCount} users."); } + + return; + + bool HasMandatoryRoles(DiscordMember m) + { + return m.Roles.Any(r => memberRoleIds.Contains(r.Id)); + } + + bool HasMemberRole(DiscordMember m) + { + return m.Roles.Any(r => r.Id == memberRole.Id); + } + + bool HasQuarantineRole(DiscordMember m) + { + return m.Roles.Any(r => r.Id == quarantineRoleId); + } } private async Task RemoveUnneededMemberRoles( List allMembers, ulong[] memberRoleIds, DiscordRole memberRole, + ulong quarantineRoleId, CancellationToken cancellationToken) { List memberRoleCandidates = allMembers - .Where( - m => !m.Roles.Any(r => memberRoleIds.Contains(r.Id)) - && m.Roles.Any(r => r.Id == memberRole.Id)) + .Where(m => HasMemberRole(m) && (!HasMandatoryRoles(m) || HasQuarantineRole(m))) .ToList(); if (memberRoleCandidates.Count != 0) @@ -131,6 +148,23 @@ private async Task RemoveUnneededMemberRoles( _discordLogger.LogExtendedActivityMessage( $"Done removing Member role for {successCount}/{totalCount} users."); } + + return; + + bool HasMandatoryRoles(DiscordMember m) + { + return m.Roles.Any(r => memberRoleIds.Contains(r.Id)); + } + + bool HasMemberRole(DiscordMember m) + { + return m.Roles.Any(r => r.Id == memberRole.Id); + } + + bool HasQuarantineRole(DiscordMember m) + { + return m.Roles.Any(r => r.Id == quarantineRoleId); + } } private async Task AddMissingGhostRoles( @@ -154,8 +188,7 @@ private async Task AddMissingGhostRoles( m => m.GrantRoleAsync(ghostRole), cancellationToken); - _discordLogger.LogExtendedActivityMessage( - $"Done adding Ghost role for {successCount}/{totalCount} users."); + _discordLogger.LogExtendedActivityMessage($"Done adding Ghost role for {successCount}/{totalCount} users."); } } @@ -194,12 +227,13 @@ private static async Task ExecuteRoleChangeWithRetry( var successCount = 0; const int roleChangeDelayMs = 100; const int retryDelayMs = 1000; + const int maxAttempts = 3; foreach (DiscordMember member in roleRecipients) { var attempt = 0; - while (attempt < 3) + while (attempt < maxAttempts) { attempt++; diff --git a/src/Nellebot/Nellebot.csproj b/src/Nellebot/Nellebot.csproj index abe9ce93..4484f0ab 100644 --- a/src/Nellebot/Nellebot.csproj +++ b/src/Nellebot/Nellebot.csproj @@ -15,9 +15,9 @@ 1.2.3-a.4 - - - + + + diff --git a/src/Nellebot/NotificationHandlers/ActivityLogHandler.cs b/src/Nellebot/NotificationHandlers/ActivityLogHandler.cs index d3313fc3..01e59bd3 100644 --- a/src/Nellebot/NotificationHandlers/ActivityLogHandler.cs +++ b/src/Nellebot/NotificationHandlers/ActivityLogHandler.cs @@ -20,13 +20,18 @@ namespace Nellebot.NotificationHandlers; +/// +/// This class logs stuff to log channels +/// public class ActivityLogHandler : INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler, + INotificationHandler { private readonly BotOptions _botOptions; private readonly IDiscordErrorLogger _discordErrorLogger; @@ -163,24 +168,24 @@ await _userLogService.CreateUserLog( public async Task Handle(GuildMemberUpdatedNotification notification, CancellationToken cancellationToken) { - var totalChanges = 0; + var typeOfChangeCount = 0; GuildMemberUpdatedEventArgs args = notification.EventArgs; - int roleChanges = CheckForRolesUpdate(args); - totalChanges += roleChanges; + bool rolesChanged = await CheckForRolesUpdate(args); + if (rolesChanged) typeOfChangeCount++; bool nicknameUpdated = await CheckForNicknameUpdate(args); - if (nicknameUpdated) totalChanges++; + if (nicknameUpdated) typeOfChangeCount++; bool usernameUpdated = await CheckForUsernameUpdate(args); - if (usernameUpdated) totalChanges++; + if (usernameUpdated) typeOfChangeCount++; - // Test if there actually are several changes in the same event - if (totalChanges > 2) + // Test if there actually are several types of changes in the same event + if (typeOfChangeCount > 2) { _discordLogger.LogExtendedActivityMessage( - $"{nameof(GuildMemberUpdatedNotification)} contained {totalChanges} changes"); + $"{nameof(GuildMemberUpdatedNotification)} contained {typeOfChangeCount} types of changes"); } } @@ -369,10 +374,39 @@ await _discordResolver.TryResolveAuditLogEntry( _discordLogger.LogExtendedActivityMessage(logMessage); } + public async Task Handle(MemberApprovedNotification notification, CancellationToken cancellationToken) + { + DiscordMember member = notification.Member; + DiscordMember memberResponsible = notification.MemberResponsible; + string memberMention = member.Mention; + + _discordLogger.LogExtendedActivityMessage( + $"{memberMention} has been approved by **{memberResponsible.DisplayName}**."); + + await _userLogService.CreateUserLog(member.Id, string.Empty, UserLogType.Approved); + } + + public async Task Handle(MemberQuarantinedNotification notification, CancellationToken cancellationToken) + { + DiscordMember member = notification.Member; + string memberIdentifier = member.GetDetailedMemberIdentifier(useMention: true); + string memberMention = member.Mention; + DiscordMember memberResponsible = notification.MemberResponsible; + string reason = notification.Reason; + + _discordLogger.LogTrustedChannelMessage( + $"Awoooooo! **{memberIdentifier}** has been quarantined. Reason: {reason}."); + + _discordLogger.LogExtendedActivityMessage( + $"{memberMention} has been quarantined by **{memberResponsible.DisplayName}**."); + + await _userLogService.CreateUserLog(member.Id, reason, UserLogType.Quarantined); + } + private async Task CheckForUsernameUpdate(GuildMemberUpdatedEventArgs args) { string? usernameAfter = args.MemberAfter.GetFullUsername(); - string? usernameBefore = args.MemberBefore?.GetFullUsername(); + string? usernameBefore = args.MemberBefore.GetFullUsername(); if (string.IsNullOrWhiteSpace(usernameBefore) || usernameBefore == usernameAfter) { @@ -404,67 +438,60 @@ private async Task CheckForNicknameUpdate(GuildMemberUpdatedEventArgs args // TODO check if member's nickname was changed by moderator if (nicknameBefore == nicknameAfter) return false; - var message = - $"Nickname change for {args.Member.Mention}. {nicknameBefore ?? "*no nickname*"} => {nicknameAfter ?? "*no nickname*"}."; - - _discordLogger.LogExtendedActivityMessage(message); + const string noNickname = "*no nickname*"; + _discordLogger.LogExtendedActivityMessage( + $"Nickname change for {args.Member.Mention}. {nicknameBefore ?? noNickname} => {nicknameAfter ?? noNickname}."); await _userLogService.CreateUserLog(args.Member.Id, nicknameAfter, UserLogType.NicknameChange); return true; } - private int CheckForRolesUpdate(GuildMemberUpdatedEventArgs args) + private async Task CheckForRolesUpdate(GuildMemberUpdatedEventArgs args) { + var roleChanges = false; + DiscordMember member = args.Member; + string memberMention = member.Mention; + string memberDisplayName = member.DisplayName; + List addedRoles = args.RolesAfter.ExceptBy(args.RolesBefore.Select(r => r.Id), x => x.Id).ToList(); List removedRoles = args.RolesBefore.ExceptBy(args.RolesAfter.Select(r => r.Id), x => x.Id).ToList(); - string memberMention = args.Member.Mention; - string memberDisplayName = args.Member.DisplayName; - string memberDetailedIdentifier = args.Member.GetDetailedMemberIdentifier(true); - - int roleChangesCount = addedRoles.Count + removedRoles.Count; - - var warningMessage = new StringBuilder(); - if (addedRoles.Count > 0) { + roleChanges = true; string addedRolesNames = string.Join(", ", addedRoles.Select(r => r.Name)); _discordLogger.LogActivityMessage($"Added roles to **{memberDisplayName}**: {addedRolesNames}"); foreach (DiscordRole addedRole in addedRoles) { _discordLogger.LogExtendedActivityMessage($"Role change for {memberMention}: Added {addedRole.Name}."); - - if (addedRole.Id == _botOptions.SpammerRoleId) - { - warningMessage.AppendLine($"Awoooooo! **{memberDetailedIdentifier}** is a **{addedRole.Name}**."); - } } } if (removedRoles.Count > 0) { + roleChanges = true; string removedRolesNames = string.Join(", ", removedRoles.Select(r => r.Name)); _discordLogger.LogActivityMessage($"Removed roles from **{memberDisplayName}**: {removedRolesNames}"); + ulong quarantineRoleId = _botOptions.QuarantineRoleId; + DiscordRole? quarantineRole = _discordResolver.ResolveRole(quarantineRoleId); + foreach (DiscordRole removedRole in removedRoles) { _discordLogger.LogExtendedActivityMessage( $"Role change for {memberMention}: Removed {removedRole.Name}."); - } - } - if (addedRoles.Count > 2) - { - warningMessage.AppendLine( - $"Awoooooo! **{memberDetailedIdentifier}** chose more than 2 roles in one go. Possibly bot."); + if (quarantineRole is not null && removedRole.Id == quarantineRole.Id) + { + await _userLogService.CreateUserLog(member.Id, string.Empty, UserLogType.Approved); + } + } } - if (warningMessage.Length > 0) _discordLogger.LogTrustedChannelMessage(warningMessage.ToString().TrimEnd()); - - return roleChangesCount; + return roleChanges; } private async Task MapAndEnrichMessage(DiscordMessage deletedMessage) diff --git a/src/Nellebot/NotificationHandlers/EventNotifications.cs b/src/Nellebot/NotificationHandlers/EventNotifications.cs index 37627708..c92b3673 100644 --- a/src/Nellebot/NotificationHandlers/EventNotifications.cs +++ b/src/Nellebot/NotificationHandlers/EventNotifications.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using DSharpPlus.Entities; using DSharpPlus.EventArgs; using MediatR; @@ -41,3 +42,8 @@ public record ClientConnected(SocketOpenedEventArgs EventArgs) : EventNotificati public record ClientDisconnected(SocketClosedEventArgs EventArgs) : EventNotification; public record BufferedMemberLeftNotification(IEnumerable Usernames) : EventNotification; + +public record MemberQuarantinedNotification(DiscordMember Member, DiscordMember MemberResponsible, string Reason) + : EventNotification; + +public record MemberApprovedNotification(DiscordMember Member, DiscordMember MemberResponsible) : EventNotification; diff --git a/src/Nellebot/NotificationHandlers/GreetingHandler.cs b/src/Nellebot/NotificationHandlers/GreetingHandler.cs index fc266762..2ebb22b9 100644 --- a/src/Nellebot/NotificationHandlers/GreetingHandler.cs +++ b/src/Nellebot/NotificationHandlers/GreetingHandler.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using DSharpPlus.Entities; using MediatR; +using Microsoft.Extensions.Options; using Nellebot.Common.Models; using Nellebot.Data.Repositories; using Nellebot.Infrastructure; @@ -13,33 +15,42 @@ namespace Nellebot.NotificationHandlers; public class GreetingHandler : - INotificationHandler, INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler, + INotificationHandler { private const int MaxUsernamesToDisplay = 100; + private const string FallbackGreetingMessage = "Welcome, $USER!"; + private const string FallbackQuarantineMessage = "Welcome to quarantine, $USER!"; private const string GoodbyeMessageTemplateType = "goodbye"; private const string FallbackGoodbyeMessageTemplate = "$USER has left. Goodbye!"; private const int MessageTemplatesCacheDurationMinutes = 5; + private readonly BotSettingsService _botSettingsService; private readonly SharedCache _cache; - + private readonly BotOptions _botOptions; private readonly DiscordLogger _discordLogger; + private readonly IDiscordErrorLogger _discordErrorLogger; private readonly GoodbyeMessageBuffer _goodbyeMessageBuffer; private readonly MessageTemplateRepository _messageTemplateRepo; public GreetingHandler( DiscordLogger discordLogger, + IDiscordErrorLogger discordErrorLogger, BotSettingsService botSettingsService, GoodbyeMessageBuffer goodbyeMessageBuffer, MessageTemplateRepository messageTemplateRepo, - SharedCache cache) + SharedCache cache, + IOptions botOptions) { _discordLogger = discordLogger; + _discordErrorLogger = discordErrorLogger; _botSettingsService = botSettingsService; _goodbyeMessageBuffer = goodbyeMessageBuffer; _messageTemplateRepo = messageTemplateRepo; _cache = cache; + _botOptions = botOptions.Value; } public async Task Handle(BufferedMemberLeftNotification notification, CancellationToken cancellationToken) @@ -48,43 +59,87 @@ public async Task Handle(BufferedMemberLeftNotification notification, Cancellati if (userList.Count == 0) return; - if (userList.Count == 1) + string? greetingMessage; + + switch (userList.Count) { - _discordLogger.LogGreetingMessage(await GetRandomGoodbyeMessage(userList.First())); - return; + case 1: + greetingMessage = await GetRandomGoodbyeMessage(userList.First()); + break; + + case <= MaxUsernamesToDisplay: + { + string userListOutput = string.Join(", ", userList.Select(x => $"**{x}**")); + greetingMessage = $"The following users have left the server: {userListOutput}. Goodbye!"; + break; + } + + default: + { + IEnumerable usersToShow = userList.Take(MaxUsernamesToDisplay); + int remainingCount = userList.Count - MaxUsernamesToDisplay; + string usersToShowOutput = string.Join(", ", usersToShow.Select(x => $"**{x}**")); + + greetingMessage = + $"The following users have left the server: {usersToShowOutput} and {remainingCount} others. Goodbye!"; + break; + } } - if (userList.Count <= MaxUsernamesToDisplay) + _discordLogger.LogGreetingMessage(greetingMessage); + } + + public async Task Handle(MemberApprovedNotification notification, CancellationToken cancellationToken) + { + DiscordMember newMember = notification.Member; + string memberMention = newMember.Mention; + + string? greetingMessage = await _botSettingsService.GetGreetingMessage(memberMention); + + if (greetingMessage == null) { - string userListOutput = string.Join(", ", userList.Select(x => $"**{x}**")); - _discordLogger.LogGreetingMessage($"The following users have left the server: {userListOutput}. Goodbye!"); - return; - } + greetingMessage = FallbackGreetingMessage; - IEnumerable usersToShow = userList.Take(MaxUsernamesToDisplay); - int remainingCount = userList.Count - MaxUsernamesToDisplay; - string usersToShowOutput = string.Join(", ", usersToShow.Select(x => $"**{x}**")); + _discordErrorLogger.LogError("Greeting message couldn't be retrieved"); + } - _discordLogger.LogGreetingMessage( - $"The following users have left the server: {usersToShowOutput} and {remainingCount} others. Goodbye!"); + _discordLogger.LogGreetingMessage(greetingMessage); } - public async Task Handle(GuildMemberAddedNotification notification, CancellationToken cancellationToken) + public async Task Handle(MemberQuarantinedNotification notification, CancellationToken cancellationToken) { - string memberMention = notification.EventArgs.Member.Mention; + DiscordMember newMember = notification.Member; + string memberMention = newMember.Mention; + string reason = notification.Reason; - string? greetingMessage = await _botSettingsService.GetGreetingsMessage(memberMention); + string? quarantineMessage = await _botSettingsService.GetQuarantineMessage(memberMention, reason); - if (greetingMessage == null) throw new Exception("Could not load greeting message"); + if (quarantineMessage == null) + { + quarantineMessage = FallbackQuarantineMessage; - _discordLogger.LogGreetingMessage(greetingMessage); + _discordErrorLogger.LogError("Quarantine message couldn't be retrieved"); + } + + _discordLogger.LogQuarantineMessage(quarantineMessage); } public Task Handle(GuildMemberRemovedNotification notification, CancellationToken cancellationToken) { - string memberName = notification.EventArgs.Member.DisplayName; + DiscordMember member = notification.EventArgs.Member; + string memberName = member.DisplayName; + + TimeSpan memberJoinedAgo = DateTimeOffset.UtcNow - member.JoinedAt; + bool memberIsQuarantined = member.Roles.Any(r => r.Id == _botOptions.QuarantineRoleId); + + // Don't want to say goodbye to quarantined users who just recently joined, + // since they were most likely not greeted either. + const int minQuarantineJoinDateForGoodbyeDays = 2; + bool skipSayingGoodbye = memberIsQuarantined + && memberJoinedAgo < TimeSpan.FromDays(minQuarantineJoinDateForGoodbyeDays); - _goodbyeMessageBuffer.AddUser(memberName); + if (!skipSayingGoodbye) + _goodbyeMessageBuffer.AddUser(memberName); return Task.CompletedTask; } @@ -100,12 +155,12 @@ await _messageTemplateRepo .GetAllMessageTemplates(GoodbyeMessageTemplateType), TimeSpan .FromMinutes(MessageTemplatesCacheDurationMinutes)) - ?? Enumerable.Empty()) + ?? []) .ToList(); if (goodbyeMessages.Count > 0) { - int idx = new Random().Next(0, goodbyeMessages.Count); + int idx = new Random().Next(minValue: 0, goodbyeMessages.Count); messageTemplate = goodbyeMessages[idx].Message; } else diff --git a/src/Nellebot/NotificationHandlers/MemberRoleIntegrityHandler.cs b/src/Nellebot/NotificationHandlers/MemberRoleIntegrityHandler.cs index c69922a6..c0c75536 100644 --- a/src/Nellebot/NotificationHandlers/MemberRoleIntegrityHandler.cs +++ b/src/Nellebot/NotificationHandlers/MemberRoleIntegrityHandler.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; -using System.Configuration; using System.Linq; using System.Threading; using System.Threading.Tasks; using DSharpPlus.Entities; +using DSharpPlus.EventArgs; using MediatR; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Nellebot.Jobs; +using Nellebot.Services; +using Nellebot.Utils; using Quartz; namespace Nellebot.NotificationHandlers; @@ -17,72 +19,98 @@ public class MemberRoleIntegrityHandler : INotificationHandler _logger; private readonly ISchedulerFactory _schedulerFactory; + private readonly DiscordResolver _discordResolver; + private readonly QuarantineService _quarantineService; private readonly BotOptions _options; public MemberRoleIntegrityHandler( ILogger logger, IOptions options, - ISchedulerFactory schedulerFactory) + ISchedulerFactory schedulerFactory, + DiscordResolver discordResolver, + QuarantineService quarantineService) { _logger = logger; _schedulerFactory = schedulerFactory; + _discordResolver = discordResolver; + _quarantineService = quarantineService; _options = options.Value; } public async Task Handle(GuildMemberUpdatedNotification notification, CancellationToken cancellationToken) { - IScheduler scheduler = await _schedulerFactory.GetScheduler(cancellationToken); - IReadOnlyCollection jobs = await scheduler.GetCurrentlyExecutingJobs(cancellationToken); - bool roleMaintenanceIsRunning = jobs.Any(j => Equals(j.JobDetail.Key, RoleMaintenanceJob.Key)); + GuildMemberUpdatedEventArgs args = notification.EventArgs; + List addedRoles = args.RolesAfter.ExceptBy(args.RolesBefore.Select(r => r.Id), x => x.Id).ToList(); + List removedRoles = + args.RolesBefore.ExceptBy(args.RolesAfter.Select(r => r.Id), x => x.Id).ToList(); - if (roleMaintenanceIsRunning) - { - _logger.LogDebug("Role maintenance job is currently running, skipping role integrity check"); - return; - } + int roleChangesCount = addedRoles.Count + removedRoles.Count; - bool memberRolesChanged = notification.EventArgs.RolesBefore.Count != notification.EventArgs.RolesAfter.Count; + if (roleChangesCount == 0) return; - if (!memberRolesChanged) return; + DiscordMember member = args.Member ?? throw new Exception(nameof(member)); + DiscordGuild guild = args.Guild; ulong[] memberRoleIds = _options.MemberRoleIds; ulong memberRoleId = _options.MemberRoleId; ulong ghostRoleId = _options.GhostRoleId; + ulong quarantineRoleId = _options.QuarantineRoleId; - DiscordMember? member = notification.EventArgs.Member; + await QuarantineIfSpammer(member, addedRoles); - if (member is null) throw new Exception(nameof(member)); + // TODO handle this in a more robust way. + // Either pause the job, if possible, or just let it run + // and make sure it doesn't clash with this notification handler. + IScheduler scheduler = await _schedulerFactory.GetScheduler(cancellationToken); + IReadOnlyCollection jobs = await scheduler.GetCurrentlyExecutingJobs(cancellationToken); + bool roleMaintenanceIsRunning = jobs.Any(j => Equals(j.JobDetail.Key, RoleMaintenanceJob.Key)); - DiscordGuild guild = notification.EventArgs.Guild; + if (roleMaintenanceIsRunning) + { + _logger.LogDebug("Role maintenance job is currently running, skipping role integrity check"); + return; + } - await EnsureMemberRole(guild, memberRoleId, member, memberRoleIds); + await MaintainMemberRole(guild, memberRoleId, member, memberRoleIds, quarantineRoleId); - await EnsureGhostRole(guild, ghostRoleId, member); + await MaintainGhostRole(guild, ghostRoleId, member); } - private static async Task EnsureMemberRole( + /// + /// Ensures that the member role is added if the user has any of the member roles, + /// and removed if the user has none of the member roles + /// + private static async Task MaintainMemberRole( DiscordGuild guild, ulong memberRoleId, DiscordMember member, - ulong[] memberRoleIds) + ulong[] memberRoleIds, + ulong quarantineRoleId) { DiscordRole memberRole = guild.Roles[memberRoleId] ?? throw new Exception($"Could not find member role with id {memberRoleId}"); - bool userShouldHaveMemberRole = member.Roles.Any(r => memberRoleIds.Contains(r.Id)); + bool userHasMandatoryRoles = member.Roles.Any(r => memberRoleIds.Contains(r.Id)); bool userHasMemberRole = member.Roles.Any(r => r.Id == memberRoleId); + bool userHasQuarantineRole = member.Roles.Any(r => r.Id == quarantineRoleId); + + bool userIsEligibleForMemberRole = userHasMandatoryRoles && !userHasQuarantineRole; - if (userShouldHaveMemberRole && !userHasMemberRole) + if (!userHasMemberRole && userIsEligibleForMemberRole) { await member.GrantRoleAsync(memberRole); } - else if (!userShouldHaveMemberRole && userHasMemberRole) + else if (userHasMemberRole && !userIsEligibleForMemberRole) { await member.RevokeRoleAsync(memberRole); } } - private static async Task EnsureGhostRole(DiscordGuild guild, ulong ghostRoleId, DiscordMember member) + /// + /// Ensures that the ghost role is added if the user has no roles, + /// and removed if the user has any other roles + /// + private static async Task MaintainGhostRole(DiscordGuild guild, ulong ghostRoleId, DiscordMember member) { DiscordRole ghostRole = guild.Roles[ghostRoleId] ?? throw new Exception($"Could not find ghost role with id {ghostRoleId}"); @@ -103,4 +131,23 @@ private static async Task EnsureGhostRole(DiscordGuild guild, ulong ghostRoleId, await member.RevokeRoleAsync(ghostRole); } } + + private async Task QuarantineIfSpammer(DiscordMember member, List addedRoles) + { + ulong spammerRoleId = _options.SpammerRoleId; + + TimeSpan memberJoinedAgo = DateTimeOffset.UtcNow - member.JoinedAt; + DiscordRole? addedSpammerRole = addedRoles.FirstOrDefault(r => r.Id == spammerRoleId); + bool hasChosenSpammerRole = addedSpammerRole is not null; + const int maxJoinAgeForAutomatedQuarantineDays = 7; + + bool shouldQuarantineSpammer = hasChosenSpammerRole + && memberJoinedAgo < TimeSpan.FromDays(maxJoinAgeForAutomatedQuarantineDays); + + if (!shouldQuarantineSpammer) return; + + var quarantineReason = $"User is a **{addedSpammerRole!.Name}**"; + DiscordMember botMember = _discordResolver.GetBotMember(); + await _quarantineService.QuarantineMember(member, botMember, quarantineReason); + } } diff --git a/src/Nellebot/NotificationHandlers/MemberVerificationHandler.cs b/src/Nellebot/NotificationHandlers/MemberVerificationHandler.cs new file mode 100644 index 00000000..31e4db20 --- /dev/null +++ b/src/Nellebot/NotificationHandlers/MemberVerificationHandler.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.Entities; +using DSharpPlus.EventArgs; +using MediatR; +using Microsoft.Extensions.Options; +using Nellebot.Services; +using Nellebot.Utils; +using Nellebot.Workers; + +namespace Nellebot.NotificationHandlers; + +public class MemberVerificationHandler : INotificationHandler +{ + private const int ApproveDelayDurationSeconds = 10; + private const int SuspiciousAccountAgeThresholdDays = 2; + + private readonly DiscordResolver _discordResolver; + private readonly EventQueueChannel _eventQueueChannel; + private readonly QuarantineService _quarantineService; + private readonly BotOptions _botOptions; + + public MemberVerificationHandler( + DiscordResolver discordResolver, + EventQueueChannel eventQueueChannel, + QuarantineService quarantineService, + IOptions botOptions) + { + _discordResolver = discordResolver; + _eventQueueChannel = eventQueueChannel; + _quarantineService = quarantineService; + _botOptions = botOptions.Value; + } + + public async Task Handle(GuildMemberAddedNotification notification, CancellationToken cancellationToken) + { + GuildMemberAddedEventArgs args = notification.EventArgs; + DiscordMember member = args.Member; + + // Check if user needs to be quarantined for having a brand-new account + TimeSpan memberAccountAge = DateTimeOffset.UtcNow - member.Id.GetSnowflakeTime(); + + DiscordMember botMember = _discordResolver.GetBotMember(); + + if (memberAccountAge < TimeSpan.FromDays(SuspiciousAccountAgeThresholdDays)) + { + var quarantineReason = $"User account is less than {SuspiciousAccountAgeThresholdDays} days old."; + await _quarantineService.QuarantineMember(member, botMember, quarantineReason); + } + else + { + // Wait for any post-join quarantine actions + await Task.Delay(TimeSpan.FromSeconds(ApproveDelayDurationSeconds), cancellationToken); + + DiscordMember? updatedMember = await _discordResolver.ResolveGuildMember(member.Id); + + // If the member is null for some reason, approve them anyway + if (updatedMember is not null) + { + bool memberIsQuarantined = member.Roles.Any(r => r.Id == _botOptions.QuarantineRoleId); + + if (memberIsQuarantined) + { + // No greet for you! + return; + } + } + + await _eventQueueChannel.Writer.WriteAsync( + new MemberApprovedNotification(member, botMember), + cancellationToken); + } + } +} diff --git a/src/Nellebot/Program.cs b/src/Nellebot/Program.cs index 80ce05f7..2dfab3ae 100644 --- a/src/Nellebot/Program.cs +++ b/src/Nellebot/Program.cs @@ -110,6 +110,7 @@ static void AddInternalServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } static void AddChannels(IServiceCollection services) diff --git a/src/Nellebot/Services/BotSettingsService.cs b/src/Nellebot/Services/BotSettingsService.cs index d619f63c..dec57caf 100644 --- a/src/Nellebot/Services/BotSettingsService.cs +++ b/src/Nellebot/Services/BotSettingsService.cs @@ -7,9 +7,11 @@ namespace Nellebot.Services; public class BotSettingsService { - private static readonly string GreetingMessageKey = "GreetingMessage"; - private static readonly string GreetingMessageUserVariable = "$USER"; - private static readonly string LastHeartbeatKey = "LastHeartbeat"; + private const string GreetingMessageKey = "GreetingMessage"; + private const string QuarantineMessageKey = "QuarantineMessage"; + private const string MessageTemplateUserVariable = "$USER"; + private const string MessageTemplateReasonVariable = "$REASON"; + private const string LastHeartbeatKey = "LastHeartbeat"; private readonly BotSettingsRepository _botSettingsRepo; private readonly SharedCache _cache; @@ -27,7 +29,7 @@ public async Task SetGreetingMessage(string message) _cache.FlushCache(SharedCacheKeys.GreetingMessage); } - public async Task GetGreetingsMessage(string userMention) + public async Task GetGreetingMessage(string userMention) { string? messageTemplate = await _cache.LoadFromCacheAsync( SharedCacheKeys.GreetingMessage, @@ -36,9 +38,31 @@ public async Task SetGreetingMessage(string message) .GreetingMessage), TimeSpan.FromMinutes(5)); + string? message = messageTemplate?.Replace(MessageTemplateUserVariable, userMention); + + return message; + } + + public async Task SetQuarantineMessage(string message) + { + await _botSettingsRepo.SaveBotSetting(QuarantineMessageKey, message); + + _cache.FlushCache(SharedCacheKeys.QuarantineMessage); + } + + public async Task GetQuarantineMessage(string userMention, string reason) + { + string? messageTemplate = await _cache.LoadFromCacheAsync( + SharedCacheKeys.QuarantineMessage, + () => _botSettingsRepo.GetBotSetting( + SharedCacheKeys + .QuarantineMessage), + TimeSpan.FromMinutes(5)); + if (messageTemplate == null) return null; - string message = messageTemplate.Replace(GreetingMessageUserVariable, userMention); + string message = messageTemplate.Replace(MessageTemplateUserVariable, userMention); + message = message.Replace(MessageTemplateReasonVariable, reason); return message; } diff --git a/src/Nellebot/Services/GoodbyeMessageBuffer.cs b/src/Nellebot/Services/GoodbyeMessageBuffer.cs index 7e740acf..06b30e03 100644 --- a/src/Nellebot/Services/GoodbyeMessageBuffer.cs +++ b/src/Nellebot/Services/GoodbyeMessageBuffer.cs @@ -13,13 +13,13 @@ public class GoodbyeMessageBuffer { private const int DelayInMs = 5000; - private readonly MessageBuffer _buffer; + private readonly BatchingBuffer _buffer; private readonly IDiscordErrorLogger _discordErrorLogger; private readonly NotificationPublisher _notificationPublisher; public GoodbyeMessageBuffer(IDiscordErrorLogger discordErrorLogger, NotificationPublisher notificationPublisher) { - _buffer = new MessageBuffer(DelayInMs, PublishBufferedEvent); + _buffer = new BatchingBuffer(DelayInMs, PublishBufferedEvent); _discordErrorLogger = discordErrorLogger; _notificationPublisher = notificationPublisher; } diff --git a/src/Nellebot/Services/Loggers/DiscordLogger.cs b/src/Nellebot/Services/Loggers/DiscordLogger.cs index 859e7de6..f3eb53e0 100644 --- a/src/Nellebot/Services/Loggers/DiscordLogger.cs +++ b/src/Nellebot/Services/Loggers/DiscordLogger.cs @@ -23,6 +23,11 @@ public void LogGreetingMessage(string message) LogMessageCore(message, _options.GreetingsChannelId); } + public void LogQuarantineMessage(string message) + { + LogMessageCore(message, _options.QuarantineChannelId); + } + public void LogActivityMessage(string message) { LogMessageCore(message, _options.ActivityLogChannelId, suppressNotifications: true); diff --git a/src/Nellebot/Services/QuarantineService.cs b/src/Nellebot/Services/QuarantineService.cs new file mode 100644 index 00000000..2b3ce626 --- /dev/null +++ b/src/Nellebot/Services/QuarantineService.cs @@ -0,0 +1,66 @@ +using System.Threading.Tasks; +using DSharpPlus.Entities; +using Microsoft.Extensions.Options; +using Nellebot.NotificationHandlers; +using Nellebot.Services.Loggers; +using Nellebot.Utils; +using Nellebot.Workers; + +namespace Nellebot.Services; + +public class QuarantineService +{ + private readonly IDiscordErrorLogger _discordErrorLogger; + private readonly DiscordResolver _discordResolver; + private readonly EventQueueChannel _eventQueueChannel; + private readonly BotOptions _botOptions; + + public QuarantineService( + IDiscordErrorLogger discordErrorLogger, + DiscordResolver discordResolver, + EventQueueChannel eventQueueChannel, + IOptions botOptions) + { + _discordErrorLogger = discordErrorLogger; + _discordResolver = discordResolver; + _eventQueueChannel = eventQueueChannel; + _botOptions = botOptions.Value; + } + + public async Task QuarantineMember(DiscordMember member, DiscordMember memberResponsible, string quarantineReason) + { + string memberIdentifier = member.GetDetailedMemberIdentifier(); + ulong quarantineRoleId = _botOptions.QuarantineRoleId; + DiscordRole? quarantineRole = _discordResolver.ResolveRole(quarantineRoleId); + + if (quarantineRole is null) + { + _discordErrorLogger.LogError( + $"Attempted to quarantine member {memberIdentifier}, but was unable to resolve quarantine role"); + return; + } + + await member.GrantRoleAsync(quarantineRole, quarantineReason); + + await _eventQueueChannel.Writer.WriteAsync( + new MemberQuarantinedNotification(member, memberResponsible, quarantineReason)); + } + + public async Task ApproveMember(DiscordMember member, DiscordMember memberResponsible) + { + string memberIdentifier = member.GetDetailedMemberIdentifier(); + ulong quarantineRoleId = _botOptions.QuarantineRoleId; + DiscordRole? quarantineRole = _discordResolver.ResolveRole(quarantineRoleId); + + if (quarantineRole is null) + { + _discordErrorLogger.LogError( + $"Attempted to approve member {memberIdentifier}, but was unable to resolve quarantine role"); + return; + } + + await member.RevokeRoleAsync(quarantineRole, $"Approved by {memberResponsible.Username}"); + + await _eventQueueChannel.Writer.WriteAsync(new MemberApprovedNotification(member, memberResponsible)); + } +} diff --git a/src/Nellebot/Utils/MessageBuffer.cs b/src/Nellebot/Utils/BatchingBuffer.cs similarity index 58% rename from src/Nellebot/Utils/MessageBuffer.cs rename to src/Nellebot/Utils/BatchingBuffer.cs index 824da0d7..40ac0222 100644 --- a/src/Nellebot/Utils/MessageBuffer.cs +++ b/src/Nellebot/Utils/BatchingBuffer.cs @@ -6,24 +6,24 @@ namespace Nellebot.Utils; -public class MessageBuffer +public class BatchingBuffer { - private readonly Func, Task> _callback; + private readonly Func, Task> _callback; private readonly int _delayMillis; private readonly object _lockObject; - private readonly ConcurrentQueue _messageQueue; + private readonly ConcurrentQueue _messageQueue; private readonly Timer _timer; - public MessageBuffer(int delayMillis, Func, Task> callback) + public BatchingBuffer(int delayMillis, Func, Task> callback) { - _messageQueue = new ConcurrentQueue(); + _messageQueue = new ConcurrentQueue(); _delayMillis = delayMillis; _callback = callback; _lockObject = new object(); - _timer = new Timer(InvokeCallback, null, Timeout.Infinite, Timeout.Infinite); + _timer = new Timer(InvokeCallback, state: null, Timeout.Infinite, Timeout.Infinite); } - public void AddMessage(string message) + public void AddMessage(T message) { _messageQueue.Enqueue(message); _timer.Change(_delayMillis, Timeout.Infinite); @@ -33,9 +33,9 @@ private void InvokeCallback(object? state) { lock (_lockObject) { - var allMessages = new List(); + var allMessages = new List(); - while (_messageQueue.TryDequeue(out string? message)) + while (_messageQueue.TryDequeue(out T? message)) { allMessages.Add(message); } @@ -44,7 +44,7 @@ private void InvokeCallback(object? state) } } - private async Task InvokeCallbackAsync(IEnumerable messages) + private async Task InvokeCallbackAsync(IEnumerable messages) { await _callback.Invoke(messages).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); } diff --git a/src/Nellebot/Utils/DiscordConstants.cs b/src/Nellebot/Utils/DiscordConstants.cs index c57bbc05..997bdef4 100644 --- a/src/Nellebot/Utils/DiscordConstants.cs +++ b/src/Nellebot/Utils/DiscordConstants.cs @@ -7,6 +7,7 @@ public static class DiscordConstants public const int MaxMessageLength = 2000; public const int MaxEmbedContentLength = 4096; public const int MaxThreadTitleLength = 100; + public const int MaxAuditReasonLength = 512; public const int DefaultEmbedColor = 2346204; // #23ccdc public const int ErrorEmbedColor = 14431557; // #dc3545 public const int WarningEmbedColor = 16612884; // #fd7e14 diff --git a/src/Nellebot/Utils/DiscordExtensions.cs b/src/Nellebot/Utils/DiscordExtensions.cs index 31425a17..b624bfd7 100644 --- a/src/Nellebot/Utils/DiscordExtensions.cs +++ b/src/Nellebot/Utils/DiscordExtensions.cs @@ -3,6 +3,8 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; +using DSharpPlus.Commands; +using DSharpPlus.Commands.Processors.SlashCommands; using DSharpPlus.Entities; namespace Nellebot.Utils; @@ -51,7 +53,7 @@ public static string GetQuotedContent(this DiscordMessage message) return string.Join(DiscordConstants.NewLineChar, quotedLines); } - public static string NullOrWhiteSpaceTo(this string input, string fallback) + public static string NullOrWhiteSpaceTo(this string? input, string fallback) { return !string.IsNullOrWhiteSpace(input) ? input : fallback; } @@ -70,14 +72,12 @@ public static bool IsImageAttachment(this DiscordAttachment attachment) public static Task SendSuppressedMessageAsync(this DiscordChannel channel, string content) { - return channel.SendMessageAsync( - x => { x.WithContent(content).SuppressNotifications(); }); + return channel.SendMessageAsync(x => { x.WithContent(content).SuppressNotifications(); }); } public static Task SendSuppressedMessageAsync(this DiscordChannel channel, DiscordEmbed embed) { - return channel.SendMessageAsync( - x => { x.AddEmbed(embed).SuppressNotifications(); }); + return channel.SendMessageAsync(x => { x.AddEmbed(embed).SuppressNotifications(); }); } public static Task SendSuppressedMessageAsync( @@ -85,8 +85,7 @@ public static Task SendSuppressedMessageAsync( string content, DiscordEmbed embed) { - return channel.SendMessageAsync( - x => { x.WithContent(content).AddEmbed(embed).SuppressNotifications(); }); + return channel.SendMessageAsync(x => { x.WithContent(content).AddEmbed(embed).SuppressNotifications(); }); } public static Task SendSuppressedMessageAsync( @@ -95,4 +94,42 @@ public static Task SendSuppressedMessageAsync( { return channel.SendMessageAsync(builder.SuppressNotifications()); } + + public static bool IsUserAssignable(this DiscordRole role) + { + return role.Flags.HasFlag(DiscordRoleFlags.InPrompt); + } + + public static async Task TryRespondEphemeral( + this CommandContext ctx, + string successMessage, + DiscordInteraction? modalInteraction) + { + if (ctx is SlashCommandContext slashCtx) + { + if (modalInteraction is null) + { + await slashCtx.RespondAsync(successMessage, ephemeral: true); + } + else + { + DiscordFollowupMessageBuilder followupBuilder = new DiscordFollowupMessageBuilder() + .WithContent(successMessage) + .AsEphemeral(); + + await modalInteraction.CreateFollowupMessageAsync(followupBuilder); + } + } + else + { + await ctx.RespondAsync(successMessage); + } + } + + public static Task TryRespondEphemeral( + this CommandContext ctx, + string successMessage) + { + return Task.FromResult(ctx.TryRespondEphemeral(successMessage, modalInteraction: null)); + } } diff --git a/src/Nellebot/Utils/DiscordResolvers.cs b/src/Nellebot/Utils/DiscordResolvers.cs index 31a860d1..71eaa14a 100644 --- a/src/Nellebot/Utils/DiscordResolvers.cs +++ b/src/Nellebot/Utils/DiscordResolvers.cs @@ -46,6 +46,20 @@ public TryResolveResult TryResolveRoleByName(DiscordGuild guild, st return TryResolveResult.FromValue(discordRole); } + public DiscordRole? ResolveRole(ulong discordRoleId) + { + DiscordGuild guild = ResolveGuild(); + + if (guild.Roles.TryGetValue(discordRoleId, out DiscordRole? role)) + { + return role; + } + + _discordErrorLogger.LogError($"Couldn't resolve role with ID {discordRoleId}"); + + return null; + } + public DiscordThreadChannel? ResolveThread(ulong threadId) { DiscordGuild guild = ResolveGuild(); @@ -138,7 +152,7 @@ public async Task> TryResolveMessage(DiscordCha Func predicate) where T : DiscordAuditLogEntry { - await foreach (DiscordAuditLogEntry entry in guild.GetAuditLogsAsync(50, null!, logType)) + await foreach (DiscordAuditLogEntry entry in guild.GetAuditLogsAsync(limit: 50, null!, logType)) { if (entry is T tEntry && predicate(tEntry)) { @@ -156,7 +170,7 @@ public async Task> TryResolveAuditLogEntry( int maxAgeMinutes = 1) where T : DiscordAuditLogEntry { - await foreach (DiscordAuditLogEntry entry in guild.GetAuditLogsAsync(50, null!, logType)) + await foreach (DiscordAuditLogEntry entry in guild.GetAuditLogsAsync(limit: 50, null!, logType)) { if (entry.CreationTimestamp < DateTimeOffset.UtcNow.AddMinutes(-maxAgeMinutes)) continue; @@ -165,4 +179,10 @@ public async Task> TryResolveAuditLogEntry( return TryResolveResult.FromError("Audit log entry not found"); } + + public DiscordMember GetBotMember() + { + DiscordGuild guild = ResolveGuild(); + return guild.CurrentMember; + } } diff --git a/src/Nellebot/appsettings.Development.json b/src/Nellebot/appsettings.Development.json index 1f138b5d..c968ecad 100644 --- a/src/Nellebot/appsettings.Development.json +++ b/src/Nellebot/appsettings.Development.json @@ -36,6 +36,8 @@ 825149115338981401 ], "GhostRoleId": 1267826295816060988, + "QuarantineRoleId": 1412826444152967389, + "QuarantineChannelId": 1412828639724175410, "AutoPopulateMessagesOnReadyEnabled": false, "AutoCreateUserLogsEnabled": true, "ModmailChannelId": 1093990997073080563, diff --git a/src/Nellebot/appsettings.json b/src/Nellebot/appsettings.json index 1e18c604..70c70232 100644 --- a/src/Nellebot/appsettings.json +++ b/src/Nellebot/appsettings.json @@ -45,6 +45,8 @@ 622788175491891231 ], "GhostRoleId": 1267940960126369833, + "QuarantineRoleId": 1412827243516006442, + "QuarantineChannelId": 1412829017903595662, "AutoPopulateMessagesOnReadyEnabled": true, "AutoCreateUserLogsEnabled": true, "ModmailChannelId": 1099753674714120293,