// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; 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 IResult = Remora.Results.IResult; namespace Catalogger.Backend.Bot.Commands; [Group("invites")] [Description("Manage server invites.")] [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] public class InviteCommands( ILogger logger, InviteRepository inviteRepository, 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 inviteRepository.GetGuildInvitesAsync(guildId); 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(); if (fields.Count == 0) return await feedbackService.ReplyAsync( "No invites found for this server.", isEphemeral: true ); 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}" ); await inviteRepository.SetInviteNameAsync(guildId, inviteResult.Entity.Code, name); 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 inviteRepository.GetInviteAsync(guildId, invite); if (namedInvite == null && name == null) return await feedbackService.ReplyAsync($"Invite `{invite}` already has no name."); if (name == null) { await inviteRepository.DeleteInviteAsync(guildId, invite); return await feedbackService.ReplyAsync($"Removed the name for `{invite}`."); } await inviteRepository.SetInviteNameAsync(guildId, invite, name); return await feedbackService.ReplyAsync( $"New name set! The invite `{invite}` will now show up as **{name}** in logs." ); } } public class InviteAutocompleteProvider( ILogger logger, InviteRepository inviteRepository, 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 []; } // We're filtering and ordering on the client side because a guild won't have infinite invites // (the maximum on Discord's end is 1500-ish) // and this way we don't need an index on (guild_id, name) for this *one* case. var namedInvites = (await inviteRepository.GetGuildInvitesAsync(guildId)) .Where(i => i.Name.StartsWith(userInput, StringComparison.InvariantCultureIgnoreCase)) .OrderBy(i => i.Name) .ToList(); 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(); } }