From 5a2bd7388c87e5dd82395ff38c33b6b4d1c7490d Mon Sep 17 00:00:00 2001 From: sam Date: Mon, 14 Oct 2024 00:26:17 +0200 Subject: [PATCH] feat: invite management commands --- .../Bot/Commands/ChannelCommandsComponents.cs | 8 +- .../Bot/Commands/InviteCommands.cs | 253 ++++++++++++++++++ .../Bot/Commands/KeyRoleCommands.cs | 21 +- Catalogger.Backend/Bot/DiscordUtils.cs | 17 ++ .../Extensions/DiscordRestExtensions.cs | 22 ++ Catalogger.Backend/Program.cs | 2 + 6 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 Catalogger.Backend/Bot/Commands/InviteCommands.cs diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs index ca025c9..5626736 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs @@ -54,9 +54,9 @@ public class ChannelCommandsComponents( if (lease.Data.UserId != userId) { return (Result) - await feedbackService.SendContextualAsync( + await feedbackService.ReplyAsync( "This is not your configuration menu.", - options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral) + isEphemeral: true ); } @@ -253,9 +253,9 @@ public class ChannelCommandsComponents( if (lease.Data.UserId != userId) { return (Result) - await feedbackService.SendContextualAsync( + await feedbackService.ReplyAsync( "This is not your configuration menu.", - options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral) + isEphemeral: true ); } diff --git a/Catalogger.Backend/Bot/Commands/InviteCommands.cs b/Catalogger.Backend/Bot/Commands/InviteCommands.cs new file mode 100644 index 0000000..57c6752 --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/InviteCommands.cs @@ -0,0 +1,253 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using Catalogger.Backend.Cache; +using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Database; +using Catalogger.Backend.Extensions; +using Microsoft.EntityFrameworkCore; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Autocomplete; +using Remora.Discord.Commands.Extensions; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Commands.Services; +using Remora.Discord.Pagination.Extensions; +using Remora.Rest.Core; +using Invite = Catalogger.Backend.Database.Models.Invite; +using IResult = Remora.Results.IResult; + +namespace Catalogger.Backend.Bot.Commands; + +[Group("invites")] +[Description("Manage server invites.")] +[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] +public class InviteCommands( + ILogger logger, + DatabaseContext db, + GuildCache guildCache, + IInviteCache inviteCache, + IDiscordRestChannelAPI channelApi, + FeedbackService feedbackService, + ContextInjectionService contextInjection +) : CommandGroup +{ + private readonly ILogger _logger = logger.ForContext(); + + [Command("list")] + [Description("List this server's invites and their names.")] + public async Task ListInvitesAsync() + { + var (userId, guildId) = contextInjection.GetUserAndGuild(); + var guildInvites = await inviteCache.TryGetAsync(guildId); + if (!guildCache.TryGet(guildId, out var guild)) + throw new CataloggerError("Guild not in cache"); + + var dbInvites = await db.Invites.Where(i => i.GuildId == guildId.Value).ToListAsync(); + + var fields = guildInvites + .Select(i => new PartialNamedInvite( + i.Code, + dbInvites.FirstOrDefault(dbI => dbI.Code == i.Code)?.Name, + i.Inviter, + i.CreatedAt + )) + .OrderByDescending(i => i.Name != null) + .ThenBy(i => i.Name) + .ThenBy(i => i.Code) + .Select(i => new EmbedField( + Name: i.Code, + Value: $""" + **Name:** {i.Name ?? "*(unnamed)*"} + **Created at:** + **Created by:** {i.CreatedBy.OrDefault()?.Tag() ?? "*(unknown)*"} + """, + IsInline: true + )); + + return await feedbackService.SendContextualPaginatedMessageAsync( + userId, + DiscordUtils.PaginateFields(fields, title: $"Invites for {guild.Name}") + ); + } + + private record struct PartialNamedInvite( + string Code, + string? Name, + Optional CreatedBy, + DateTimeOffset CreatedAt + ); + + [Command("create")] + [Description("Create a new invite.`")] + public async Task CreateInviteAsync( + [Description("The channel to create the invite in")] + [ChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)] + IChannel channel, + [Description("What to name the new invite")] [Optional] string? name + ) + { + var (userId, guildId) = contextInjection.GetUserAndGuild(); + + var inviteResult = await channelApi.CreateChannelInviteAsync( + channel.ID, + maxAge: TimeSpan.Zero, + isUnique: true, + reason: $"Create invite command by {userId}" + ); + if (inviteResult.Error != null) + { + _logger.Error( + "Could not create an invite in channel {ChannelId} in {GuildId}: {Error}", + channel.ID, + guildId, + inviteResult.Error + ); + + return await feedbackService.ReplyAsync( + $"Could not create an invite in <#{channel.ID}>." + + "Are you sure I have the \"Create Invite\" permission in that channel?" + ); + } + + if (name == null) + return await feedbackService.ReplyAsync( + $"Created a new invite in <#{channel.ID}>!" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}" + ); + + var dbInvite = new Invite + { + GuildId = guildId.Value, + Code = inviteResult.Entity.Code, + Name = name, + }; + db.Add(dbInvite); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync( + $"Created a new invite in <#{channel.ID}> with the name **{name}**!" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}" + ); + } + + [Command("name")] + [Description("Name or rename an invite.")] + public async Task NameInviteAsync( + [Description("The invite to rename")] + [AutocompleteProvider("autocomplete:invite-provider")] + string invite, + [Description("The invite's new name")] [Optional] string? name + ) + { + var (_, guildId) = contextInjection.GetUserAndGuild(); + + var guildInvites = await inviteCache.TryGetAsync(guildId); + if (guildInvites.All(i => i.Code != invite)) + { + return await feedbackService.ReplyAsync( + $"No invite with the code `{invite}` found.", + isEphemeral: true + ); + } + + var namedInvite = await db + .Invites.Where(i => i.GuildId == guildId.Value && i.Code == invite) + .FirstOrDefaultAsync(); + if (namedInvite == null) + { + if (name == null) + return await feedbackService.ReplyAsync($"Invite `{invite}` already has no name."); + + namedInvite = new Invite + { + GuildId = guildId.Value, + Code = invite, + Name = name, + }; + db.Add(namedInvite); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync( + $"New name set! The invite `{invite}` will now show up as **{name}** in logs." + ); + } + + if (name == null) + { + db.Invites.Remove(namedInvite); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync($"Removed the name for `{invite}`."); + } + + namedInvite.Name = name; + db.Update(namedInvite); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync( + $"New name set! The invite `{invite}` will now show up as **{name}** in logs." + ); + } +} + +public class InviteAutocompleteProvider( + ILogger logger, + DatabaseContext db, + IInviteCache inviteCache, + ContextInjectionService contextInjection +) : IAutocompleteProvider +{ + private readonly ILogger _logger = logger.ForContext(); + + public string Identity => "autocomplete:invite-provider"; + + [SuppressMessage( + "Performance", + "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", + Justification = "StringComparison doesn't translate to SQL" + )] + public async ValueTask> GetSuggestionsAsync( + IReadOnlyList options, + string userInput, + CancellationToken ct = default + ) + { + if (contextInjection.Context == null) + { + _logger.Debug("GetSuggestionsAsync did not get a context at all"); + return []; + } + + if (!contextInjection.Context.TryGetGuildID(out var guildId)) + { + _logger.Debug("GetSuggestionsAsync did not get a context with a guild ID"); + return []; + } + + var namedInvites = await db + .Invites.Where(i => + i.GuildId == guildId.Value && i.Name.ToLower().StartsWith(userInput.ToLower()) + ) + .OrderBy(i => i.Name) + .Take(25) + .ToListAsync(ct); + + if (namedInvites.Count != 0) + { + return namedInvites + .Select(i => new ApplicationCommandOptionChoice(Name: i.Name, Value: i.Code)) + .ToList(); + } + + var invites = await inviteCache.TryGetAsync(guildId); + return invites + .Where(i => i.Code.StartsWith(userInput, StringComparison.InvariantCultureIgnoreCase)) + .Select(i => new ApplicationCommandOptionChoice(Name: i.Code, Value: i.Code)) + .ToList(); + } +} diff --git a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs index be456cf..459a8ea 100644 --- a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs +++ b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs @@ -36,8 +36,9 @@ public class KeyRoleCommands( var guildConfig = await db.GetGuildAsync(guildId); if (guildConfig.KeyRoles.Count == 0) - return await feedbackService.SendContextualAsync( - "There are no key roles to list. Add some with `/key-roles add`." + return await feedbackService.ReplyAsync( + "There are no key roles to list. Add some with `/key-roles add`.", + isEphemeral: true ); var description = string.Join( @@ -71,15 +72,16 @@ public class KeyRoleCommands( var guildConfig = await db.GetGuildAsync(guildId); if (guildConfig.KeyRoles.Any(id => role.ID == id)) - return await feedbackService.SendContextualAsync($"{role.Name} is already a key role."); + return await feedbackService.ReplyAsync( + $"{role.Name} is already a key role.", + isEphemeral: true + ); guildConfig.KeyRoles.Add(role.ID.Value); db.Update(guildConfig); await db.SaveChangesAsync(); - return await feedbackService.SendContextualAsync( - $"Added {role.Name} to this server's key roles!" - ); + return await feedbackService.ReplyAsync($"Added {role.Name} to this server's key roles!"); } [Command("remove")] @@ -95,15 +97,16 @@ public class KeyRoleCommands( var guildConfig = await db.GetGuildAsync(guildId); if (guildConfig.KeyRoles.All(id => role.ID != id)) - return await feedbackService.SendContextualAsync( - $"{role.Name} is already not a key role." + return await feedbackService.ReplyAsync( + $"{role.Name} is already not a key role.", + isEphemeral: true ); guildConfig.KeyRoles.Remove(role.ID.Value); db.Update(guildConfig); await db.SaveChangesAsync(); - return await feedbackService.SendContextualAsync( + return await feedbackService.ReplyAsync( $"Removed {role.Name} from this server's key roles!" ); } diff --git a/Catalogger.Backend/Bot/DiscordUtils.cs b/Catalogger.Backend/Bot/DiscordUtils.cs index 9110944..acb5ca9 100644 --- a/Catalogger.Backend/Bot/DiscordUtils.cs +++ b/Catalogger.Backend/Bot/DiscordUtils.cs @@ -1,6 +1,10 @@ using System.Drawing; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Pagination; +using Remora.Discord.Pagination.Extensions; using Remora.Rest.Core; namespace Catalogger.Backend.Bot; @@ -14,4 +18,17 @@ public static class DiscordUtils public static readonly Color Green = Color.FromArgb(46, 204, 113); public static readonly Color Blue = Color.FromArgb(52, 152, 219); public static readonly Color Orange = Color.FromArgb(230, 126, 34); + + public static List PaginateFields( + IEnumerable fields, + Optional title = default, + string description = "", + uint fieldsPerPage = 6 + ) => + PageFactory.FromFields( + fields, + fieldsPerPage, + description, + new Embed(Title: title, Colour: Purple) + ); } diff --git a/Catalogger.Backend/Extensions/DiscordRestExtensions.cs b/Catalogger.Backend/Extensions/DiscordRestExtensions.cs index a0a068c..233a60b 100644 --- a/Catalogger.Backend/Extensions/DiscordRestExtensions.cs +++ b/Catalogger.Backend/Extensions/DiscordRestExtensions.cs @@ -2,6 +2,8 @@ using OneOf; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; +using Remora.Discord.Commands.Feedback.Messages; +using Remora.Discord.Commands.Feedback.Services; using Remora.Rest.Core; using Remora.Results; @@ -28,4 +30,24 @@ public static class DiscordRestExtensions >(data) ) ); + + public static async Task> ReplyAsync( + this IFeedbackService feedbackService, + Optional content = default, + IEnumerable? embeds = null, + bool isEphemeral = false + ) => + await feedbackService.SendContextualAsync( + content, + embeds != null + ? new Optional>(embeds.ToList()) + : new Optional>(), + options: new FeedbackMessageOptions( + MessageFlags: isEphemeral ? MessageFlags.Ephemeral : 0, + AllowedMentions: new AllowedMentions( + Parse: new List(), + MentionRepliedUser: true + ) + ) + ); } diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 89bf6fd..d22d95f 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -71,11 +71,13 @@ builder .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() + .WithCommandGroup() // End command tree .Finish() .AddPagination() .AddInteractivity() .AddInteractionGroup() + .AddAutocompleteProvider() ); // Add metric server