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,