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, IDiscordRestGuildAPI guildApi, 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 guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); 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.Uses, i.Channel, 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:** **Uses:** {i.Uses} **Channel:** {(i.Channel != null ? $"<#{i.Channel.ID.Value}>" : "*(unknown)*")} **Created by:** {i.CreatedBy.OrDefault()?.Tag() ?? "*(unknown)*"} """, IsInline: false )) .ToList(); return await feedbackService.SendContextualPaginatedMessageAsync( userId, DiscordUtils.PaginateFields( fields, title: $"Invites for {guild.Name} ({fields.Count})", fieldsPerPage: 5 ) ); } private record struct PartialNamedInvite( string Code, string? Name, int Uses, IPartialChannel? Channel, 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")] string? name = null ) { 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")] string? name = null ) { 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(); } }