2024-10-14 14:56:40 +02:00
|
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
2024-10-14 00:26:17 +02:00
|
|
|
using System.ComponentModel;
|
|
|
|
|
using System.Diagnostics.CodeAnalysis;
|
|
|
|
|
using Catalogger.Backend.Cache;
|
|
|
|
|
using Catalogger.Backend.Cache.InMemoryCache;
|
2024-10-28 14:04:55 +01:00
|
|
|
using Catalogger.Backend.Database.Repositories;
|
2024-10-14 00:26:17 +02:00
|
|
|
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,
|
2024-10-27 23:30:02 +01:00
|
|
|
InviteRepository inviteRepository,
|
2024-10-14 00:26:17 +02:00
|
|
|
GuildCache guildCache,
|
|
|
|
|
IInviteCache inviteCache,
|
|
|
|
|
IDiscordRestChannelAPI channelApi,
|
2024-10-14 14:19:14 +02:00
|
|
|
IDiscordRestGuildAPI guildApi,
|
2024-10-14 00:26:17 +02:00
|
|
|
FeedbackService feedbackService,
|
|
|
|
|
ContextInjectionService contextInjection
|
|
|
|
|
) : CommandGroup
|
|
|
|
|
{
|
|
|
|
|
private readonly ILogger _logger = logger.ForContext<InviteCommands>();
|
|
|
|
|
|
|
|
|
|
[Command("list")]
|
|
|
|
|
[Description("List this server's invites and their names.")]
|
|
|
|
|
public async Task<IResult> ListInvitesAsync()
|
|
|
|
|
{
|
|
|
|
|
var (userId, guildId) = contextInjection.GetUserAndGuild();
|
2024-10-14 14:19:14 +02:00
|
|
|
var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow();
|
2024-10-14 00:26:17 +02:00
|
|
|
if (!guildCache.TryGet(guildId, out var guild))
|
|
|
|
|
throw new CataloggerError("Guild not in cache");
|
|
|
|
|
|
2024-10-27 23:30:02 +01:00
|
|
|
var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId);
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
var fields = guildInvites
|
|
|
|
|
.Select(i => new PartialNamedInvite(
|
|
|
|
|
i.Code,
|
|
|
|
|
dbInvites.FirstOrDefault(dbI => dbI.Code == i.Code)?.Name,
|
2024-10-14 03:14:28 +02:00
|
|
|
i.Uses,
|
|
|
|
|
i.Channel,
|
2024-10-14 00:26:17 +02:00
|
|
|
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:** <t:{i.CreatedAt.ToUnixTimeSeconds()}>
|
2024-10-14 03:14:28 +02:00
|
|
|
**Uses:** {i.Uses}
|
|
|
|
|
**Channel:** {(i.Channel != null ? $"<#{i.Channel.ID.Value}>" : "*(unknown)*")}
|
2024-10-14 00:26:17 +02:00
|
|
|
**Created by:** {i.CreatedBy.OrDefault()?.Tag() ?? "*(unknown)*"}
|
|
|
|
|
""",
|
2024-10-14 03:14:28 +02:00
|
|
|
IsInline: false
|
|
|
|
|
))
|
|
|
|
|
.ToList();
|
2024-10-14 00:26:17 +02:00
|
|
|
|
2024-10-16 14:53:30 +02:00
|
|
|
if (fields.Count == 0)
|
|
|
|
|
return await feedbackService.ReplyAsync(
|
|
|
|
|
"No invites found for this server.",
|
|
|
|
|
isEphemeral: true
|
|
|
|
|
);
|
|
|
|
|
|
2024-10-14 00:26:17 +02:00
|
|
|
return await feedbackService.SendContextualPaginatedMessageAsync(
|
|
|
|
|
userId,
|
2024-10-14 03:14:28 +02:00
|
|
|
DiscordUtils.PaginateFields(
|
|
|
|
|
fields,
|
|
|
|
|
title: $"Invites for {guild.Name} ({fields.Count})",
|
|
|
|
|
fieldsPerPage: 5
|
|
|
|
|
)
|
2024-10-14 00:26:17 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private record struct PartialNamedInvite(
|
|
|
|
|
string Code,
|
|
|
|
|
string? Name,
|
2024-10-14 03:14:28 +02:00
|
|
|
int Uses,
|
|
|
|
|
IPartialChannel? Channel,
|
2024-10-14 00:26:17 +02:00
|
|
|
Optional<IUser> CreatedBy,
|
|
|
|
|
DateTimeOffset CreatedAt
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
[Command("create")]
|
|
|
|
|
[Description("Create a new invite.`")]
|
|
|
|
|
public async Task<IResult> CreateInviteAsync(
|
|
|
|
|
[Description("The channel to create the invite in")]
|
|
|
|
|
[ChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)]
|
|
|
|
|
IChannel channel,
|
2024-10-14 03:14:28 +02:00
|
|
|
[Description("What to name the new invite")] string? name = null
|
2024-10-14 00:26:17 +02:00
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
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}"
|
|
|
|
|
);
|
|
|
|
|
|
2024-10-27 23:30:02 +01:00
|
|
|
await inviteRepository.SetInviteNameAsync(guildId, inviteResult.Entity.Code, name);
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
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<IResult> NameInviteAsync(
|
|
|
|
|
[Description("The invite to rename")]
|
|
|
|
|
[AutocompleteProvider("autocomplete:invite-provider")]
|
|
|
|
|
string invite,
|
2024-10-14 03:14:28 +02:00
|
|
|
[Description("The invite's new name")] string? name = null
|
2024-10-14 00:26:17 +02:00
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-27 23:30:02 +01:00
|
|
|
var namedInvite = await inviteRepository.GetInviteAsync(guildId, invite);
|
|
|
|
|
if (namedInvite == null && name == null)
|
|
|
|
|
return await feedbackService.ReplyAsync($"Invite `{invite}` already has no name.");
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
if (name == null)
|
|
|
|
|
{
|
2024-10-27 23:30:02 +01:00
|
|
|
await inviteRepository.DeleteInviteAsync(guildId, invite);
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
return await feedbackService.ReplyAsync($"Removed the name for `{invite}`.");
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-27 23:30:02 +01:00
|
|
|
await inviteRepository.SetInviteNameAsync(guildId, invite, name);
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
return await feedbackService.ReplyAsync(
|
|
|
|
|
$"New name set! The invite `{invite}` will now show up as **{name}** in logs."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public class InviteAutocompleteProvider(
|
|
|
|
|
ILogger logger,
|
2024-10-27 23:30:02 +01:00
|
|
|
InviteRepository inviteRepository,
|
2024-10-14 00:26:17 +02:00
|
|
|
IInviteCache inviteCache,
|
|
|
|
|
ContextInjectionService contextInjection
|
|
|
|
|
) : IAutocompleteProvider
|
|
|
|
|
{
|
|
|
|
|
private readonly ILogger _logger = logger.ForContext<InviteAutocompleteProvider>();
|
|
|
|
|
|
|
|
|
|
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<IReadOnlyList<IApplicationCommandOptionChoice>> GetSuggestionsAsync(
|
|
|
|
|
IReadOnlyList<IApplicationCommandInteractionDataOption> 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 [];
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-27 23:30:02 +01:00
|
|
|
// 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))
|
2024-10-14 00:26:17 +02:00
|
|
|
.OrderBy(i => i.Name)
|
2024-10-27 23:30:02 +01:00
|
|
|
.ToList();
|
2024-10-14 00:26:17 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|