Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2ddf878
Update DSharp to latest nightly 02541
selfdocumentingcode Sep 3, 2025
df92f54
Add QuarantineRoleId to options
selfdocumentingcode Sep 3, 2025
eab164f
Remove unused UserLog types
selfdocumentingcode Sep 3, 2025
1e4a17f
Add User log types for quarantine logs
selfdocumentingcode Sep 3, 2025
063a860
Add DiscordRole extension for checking InPrompt flag
selfdocumentingcode Sep 3, 2025
fba70a9
Skip greeting quarantined users
selfdocumentingcode Sep 3, 2025
8179cd7
Assign quarantine role to brand-new accounts
selfdocumentingcode Sep 3, 2025
8d55aba
Rewrite MessageBuffer to generic BatchingBuffer
selfdocumentingcode Sep 4, 2025
6182080
Add GetBotMember to DiscordResolvers
selfdocumentingcode Sep 4, 2025
49daabb
Create MemberApproved and MemberQuarantined notification types
selfdocumentingcode Sep 4, 2025
6cbe291
Handle MemberApproved and MemberQuarantine events in ActivityLogHandler
selfdocumentingcode Sep 4, 2025
7daa1e0
Fix bug with checking for removed quarantine role
selfdocumentingcode Sep 4, 2025
7739cc9
Rename Unquarantined user log type to Approved
selfdocumentingcode Sep 4, 2025
51b1981
Handle MemberApprovedNotification instead of GuildMemberAddedNotifica…
selfdocumentingcode Sep 4, 2025
0223612
Move brand-new account quarantine logic from ActivityLogHandler to ne…
selfdocumentingcode Sep 4, 2025
f5f3a1a
Skip saying goodbye to new and quarantined users
selfdocumentingcode Sep 6, 2025
ed93f58
Improve messages related to quarantine
selfdocumentingcode Sep 6, 2025
e435f20
Quarantine new members who have chosen the spammer role
selfdocumentingcode Sep 6, 2025
2f6888a
Remove alert message based on number of added roles
selfdocumentingcode Sep 6, 2025
34531d7
Tingle
selfdocumentingcode Sep 5, 2025
73a0872
Change MemberUpdated handler in ActivityLog to count types of changes…
selfdocumentingcode Sep 7, 2025
cc1cccd
Add configurable message template for quarantine messages
selfdocumentingcode Sep 7, 2025
078b997
Send quarantine "greeting" message to quarantined users
selfdocumentingcode Sep 7, 2025
3c59ec2
Move QuarantineMember command to new QuarantineService
selfdocumentingcode Sep 7, 2025
80c3fde
fixup! Add configurable message template for quarantine messages
selfdocumentingcode Sep 7, 2025
ad31025
Add quarantine and approve bot commands
selfdocumentingcode Sep 7, 2025
d17327f
fixup! Send quarantine "greeting" message to quarantined users
selfdocumentingcode Sep 7, 2025
79c068c
Add support for quarantine reason variable in quarantine message temp…
selfdocumentingcode Sep 7, 2025
03ae60d
Wait and check for quarantine before approving new members
selfdocumentingcode Sep 19, 2025
9725bea
Register VKick, Quarantine and Approve command as user menu commands
selfdocumentingcode Sep 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Nellebot.Common/Models/UserLogs/UserLogType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
4 changes: 2 additions & 2 deletions src/Nellebot.Common/Models/UserLogs/UserLogTypesMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
};
}
4 changes: 4 additions & 0 deletions src/Nellebot/BotOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public class BotOptions

public ulong GhostRoleId { get; init; }

public ulong QuarantineRoleId { get; init; }

public ulong QuarantineChannelId { get; init; }

/// <summary>
/// Gets a value indicating whether feature flag for populating message refs on Ready event.
/// </summary>
Expand Down
61 changes: 61 additions & 0 deletions src/Nellebot/CommandHandlers/ApproveUser.cs
Original file line number Diff line number Diff line change
@@ -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<ApproveUserCommand>
{
private readonly QuarantineService _quarantineService;
private readonly BotOptions _options;

public ApproveUserHandler(IOptions<BotOptions> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SetQuarantineMessageCommand>
{
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());
}
}
116 changes: 116 additions & 0 deletions src/Nellebot/CommandHandlers/QuarantineUser.cs
Original file line number Diff line number Diff line change
@@ -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<QuarantineUserCommand>
{
private const string ModalTextInputId = "modal-text-input";
private readonly InteractivityExtension _interactivityExtension;
private readonly BotOptions _options;
private readonly QuarantineService _quarantineService;

public QuarantineUserHandler(
IOptions<BotOptions> 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<ModalSubmittedEventArgs> 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<ModalSubmittedEventArgs> modalSubmission =
await _interactivityExtension.WaitForModalAsync(modalId, DiscordConstants.MaxDeferredInteractionWait);

return modalSubmission.Result;
}
}
65 changes: 52 additions & 13 deletions src/Nellebot/CommandHandlers/ValhallKickUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValhallKickUserCommand>
{
private readonly InteractivityExtension _interactivityExtension;
private const string ModalTextInputId = "modal-text-input";
private readonly BotOptions _options;

public ValhallKickUserHandler(IOptions<BotOptions> options)
public ValhallKickUserHandler(IOptions<BotOptions> options, InteractivityExtension interactivityExtension)
{
_interactivityExtension = interactivityExtension;
_options = options.Value;
}

Expand All @@ -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;
}

Expand All @@ -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<ModalSubmittedEventArgs> 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<ModalSubmittedEventArgs> modalSubmission =
await _interactivityExtension.WaitForModalAsync(modalId, DiscordConstants.MaxDeferredInteractionWait);

return modalSubmission.Result;
}
}
6 changes: 6 additions & 0 deletions src/Nellebot/CommandModules/AdminModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading