Catalogger.NET/Catalogger.Backend/Bot/Commands/InviteCommands.cs

309 lines
11 KiB
C#
Raw Permalink Normal View History

// 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;
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,
InviteRepository inviteRepository,
2024-10-14 00:26:17 +02:00
GuildCache guildCache,
IInviteCache inviteCache,
UserCache userCache,
2024-10-14 00:26:17 +02:00
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))
return CataloggerError.Result("Guild not in cache");
2024-10-14 00:26:17 +02: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,
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()}>
**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)*"}
""",
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,
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,
int Uses,
IPartialChannel? Channel,
2024-10-14 00:26:17 +02:00
Optional<IUser> CreatedBy,
DateTimeOffset CreatedAt
);
[Command("create")]
[Description("Create a new invite.")]
2024-10-14 00:26:17 +02:00
public async Task<IResult> 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,
[Description("How long the invite should be valid for")] InviteDuration? duration = null
2024-10-14 00:26:17 +02:00
)
{
var (userId, guildId) = contextInjection.GetUserAndGuild();
var inviteResult = await channelApi.CreateChannelInviteAsync(
channel.ID,
maxAge: duration?.ToTimespan() ?? TimeSpan.Zero,
2024-10-14 00:26:17 +02:00
isUnique: true,
reason: $"Create invite command by {await userCache.TryFormatUserAsync(userId, addMention: false)}"
2024-10-14 00:26:17 +02:00
);
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?"
);
}
var durationText =
duration != null ? $"\nThis invite {duration.ToHumanString()}." : string.Empty;
2024-10-14 00:26:17 +02:00
if (name == null)
return await feedbackService.ReplyAsync(
$"Created a new invite in <#{channel.ID}>!"
+ $"\nLink: https://discord.gg/{inviteResult.Entity.Code}{durationText}"
2024-10-14 00:26:17 +02: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}{durationText}"
2024-10-14 00:26:17 +02:00
);
}
[Command("name")]
[Description("Name or rename an invite.")]
public async Task<IResult> NameInviteAsync(
[Description("The invite to rename")]
[AutocompleteProvider("autocomplete:invite-provider")]
string invite,
[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
);
}
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)
{
await inviteRepository.DeleteInviteAsync(guildId, invite);
2024-10-14 00:26:17 +02:00
return await feedbackService.ReplyAsync($"Removed the name for `{invite}`.");
}
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,
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 [];
}
// 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)
.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();
}
}
public enum InviteDuration
{
[Description("30 minutes")]
ThirtyMinutes,
[Description("1 hour")]
OneHour,
[Description("6 hours")]
SixHours,
[Description("12 hours")]
TwelveHours,
[Description("1 day")]
OneDay,
[Description("1 week")]
OneWeek,
}
internal static class InviteEnumExtensions
{
internal static TimeSpan ToTimespan(this InviteDuration dur) =>
dur switch
{
InviteDuration.ThirtyMinutes => TimeSpan.FromMinutes(30),
InviteDuration.OneHour => TimeSpan.FromHours(1),
InviteDuration.SixHours => TimeSpan.FromHours(6),
InviteDuration.TwelveHours => TimeSpan.FromHours(12),
InviteDuration.OneDay => TimeSpan.FromDays(1),
InviteDuration.OneWeek => TimeSpan.FromDays(7),
_ => TimeSpan.Zero,
};
internal static string ToHumanString(this InviteDuration? dur) =>
dur switch
{
InviteDuration.ThirtyMinutes => "expires after 30 minutes",
InviteDuration.OneHour => "expires after 1 hour",
InviteDuration.SixHours => "expires after 6 hours",
InviteDuration.TwelveHours => "expires after 12 hours",
InviteDuration.OneDay => "expires after 1 day",
InviteDuration.OneWeek => "expires after 1 week",
_ => "does not expire",
};
}