// 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 Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Commands.Attributes;
using Remora.Commands.Groups;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Attributes;
using Remora.Discord.Commands.Feedback.Messages;
using Remora.Discord.Commands.Feedback.Services;
using Remora.Discord.Commands.Services;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Interactivity;
using Remora.Discord.Interactivity.Services;
using Remora.Rest.Core;
using Remora.Results;
using DbGuild = Catalogger.Backend.Database.Models.Guild;
using IResult = Remora.Results.IResult;
namespace Catalogger.Backend.Bot.Commands;
public class ChannelCommands(
ILogger logger,
Config config,
GuildRepository guildRepository,
GuildCache guildCache,
GuildFetchService guildFetchService,
ChannelCache channelCache,
IMemberCache memberCache,
IFeedbackService feedbackService,
ContextInjectionService contextInjection,
InMemoryDataService dataService,
PermissionResolverService permissionResolver
) : CommandGroup
{
private readonly ILogger _logger = logger.ForContext();
private static readonly DiscordPermission[] RequiredGuildPermissions =
[
DiscordPermission.ManageGuild,
DiscordPermission.ViewAuditLog,
];
// TODO: i hate this
[Command("check-permissions")]
[Description(
"Check for any permission issues that would prevent Catalogger from sending logs."
)]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
public async Task CheckPermissionsAsync()
{
var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild))
{
return CataloggerError.Result($"Guild {guildId} not in cache");
}
var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}");
var botUser = await memberCache.TryGetAsync(
guildId,
DiscordSnowflake.New(config.Discord.ApplicationId)
);
var currentUser = await memberCache.TryGetAsync(guildId, userId);
if (botUser == null || currentUser == null)
{
// If this happens, something has gone wrong when fetching members. Refetch the guild's members.
guildFetchService.EnqueueGuild(guildId);
_logger.Error(
"Either our own user {BotId} or the invoking user {UserId} is not in cache, aborting permission check",
config.Discord.ApplicationId,
userId
);
return CataloggerError.Result("Bot member or invoking member not found in cache");
}
// We don't want to check categories or threads
var guildChannels = channelCache
.GuildChannels(guildId)
.Where(c =>
c.Type
is not (
ChannelType.GuildCategory
or ChannelType.PublicThread
or ChannelType.PrivateThread
or ChannelType.AnnouncementThread
)
)
.ToList();
// We'll only check channels the user can see, to not leak any information.
var checkChannels = guildChannels
.Where(c =>
{
var perms = permissionResolver.GetChannelPermissions(guildId, currentUser, c);
return perms.HasPermission(DiscordPermission.ViewChannel)
|| perms.HasPermission(DiscordPermission.Administrator);
})
.ToList();
var ignoredChannels = guildChannels.Count - checkChannels.Count;
if (ignoredChannels != 0)
embed = embed.WithFooter(
$"{ignoredChannels} channel(s) were ignored as you do not have access to them"
);
var guildPerms = permissionResolver.GetGuildPermissions(guildId, botUser);
// If the bot has admin perms, we can ignore the rest--we'll never get permission errors
if (guildPerms.HasPermission(DiscordPermission.Administrator))
{
return await feedbackService.ReplyAsync(
embeds:
[
embed
.WithColour(DiscordUtils.Green)
.WithDescription("No issues found, all channels can be logged to and from!")
.Build()
.GetOrThrow(),
]
);
}
var missingGuildPerms = string.Join(
", ",
RequiredGuildPermissions.Where(p => !guildPerms.HasPermission(p))
);
if (!string.IsNullOrWhiteSpace(missingGuildPerms))
embed.AddField("Server-level permissions", missingGuildPerms);
var missingManageChannel = new List();
var missingSendMessages = new List();
var missingViewChannel = new List();
var missingReadMessageHistory = new List();
var missingManageWebhooks = new List();
foreach (var channel in checkChannels)
{
var channelPerms = permissionResolver.GetChannelPermissions(guildId, botUser, channel);
if (!channelPerms.HasPermission(DiscordPermission.ManageChannels))
missingManageChannel.Add(channel.ID);
if (!channelPerms.HasPermission(DiscordPermission.SendMessages))
missingSendMessages.Add(channel.ID);
if (!channelPerms.HasPermission(DiscordPermission.ViewChannel))
missingViewChannel.Add(channel.ID);
if (!channelPerms.HasPermission(DiscordPermission.ReadMessageHistory))
missingReadMessageHistory.Add(channel.ID);
if (!channelPerms.HasPermission(DiscordPermission.ManageWebhooks))
missingManageWebhooks.Add(channel.ID);
}
if (missingManageChannel.Count != 0)
embed.AddField(
"Manage Channel",
string.Join("\n", missingManageChannel.Select(id => $"<#{id}>"))
);
if (missingSendMessages.Count != 0)
embed.AddField(
"Send Messages",
string.Join("\n", missingSendMessages.Select(id => $"<#{id}>"))
);
if (missingReadMessageHistory.Count != 0)
embed.AddField(
"Read Message History",
string.Join("\n", missingReadMessageHistory.Select(id => $"<#{id}>"))
);
if (missingViewChannel.Count != 0)
embed.AddField(
"View Channel",
string.Join("\n", missingViewChannel.Select(id => $"<#{id}>"))
);
if (missingManageWebhooks.Count != 0)
embed.AddField(
"Manage Webhooks",
string.Join("\n", missingManageWebhooks.Select(id => $"<#{id}>"))
);
if (embed.Fields.Count == 0)
{
embed = embed
.WithColour(DiscordUtils.Green)
.WithDescription("No issues found, all channels can be logged to and from!");
}
else
{
embed = embed
.WithColour(DiscordUtils.Red)
.WithDescription("Permission issues found, please fix them and try again.");
}
return await feedbackService.ReplyAsync(embeds: [embed.Build().GetOrThrow()]);
}
[Command("configure-channels")]
[Description("Configure log channels for this server.")]
[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
public async Task ConfigureChannelsAsync()
{
var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild))
return CataloggerError.Result("Guild not in cache");
var guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId);
var (embeds, components) = BuildRootMenu(guildChannels, guild, guildConfig);
var msg = await feedbackService
.SendContextualAsync(
embeds: embeds,
options: new FeedbackMessageOptions(MessageComponents: components)
)
.GetOrThrow();
dataService.TryAddData(msg.ID, new ChannelCommandData(userId, CurrentPage: null));
return Result.Success;
}
public static IStringSelectComponent LogTypeSelect =>
new StringSelectComponent(
CustomID: CustomIDHelpers.CreateSelectMenuID("select-log-type"),
MinValues: 1,
MaxValues: 1,
Options:
[
new SelectOption(
Label: "Server changes",
Value: nameof(LogChannelType.GuildUpdate)
),
new SelectOption(
Label: "Emoji changes",
Value: nameof(LogChannelType.GuildEmojisUpdate)
),
new SelectOption(Label: "New roles", Value: nameof(LogChannelType.GuildRoleCreate)),
new SelectOption(
Label: "Edited roles",
Value: nameof(LogChannelType.GuildRoleUpdate)
),
new SelectOption(
Label: "Deleted roles",
Value: nameof(LogChannelType.GuildRoleDelete)
),
new SelectOption(
Label: "New channels",
Value: nameof(LogChannelType.ChannelCreate)
),
new SelectOption(
Label: "Edited channels",
Value: nameof(LogChannelType.ChannelUpdate)
),
new SelectOption(
Label: "Deleted channels",
Value: nameof(LogChannelType.ChannelDelete)
),
new SelectOption(
Label: "Members joining",
Value: nameof(LogChannelType.GuildMemberAdd)
),
new SelectOption(
Label: "Members leaving",
Value: nameof(LogChannelType.GuildMemberRemove)
),
new SelectOption(
Label: "Member role changes",
Value: nameof(LogChannelType.GuildMemberUpdate)
),
new SelectOption(
Label: "Key role changes",
Value: nameof(LogChannelType.GuildKeyRoleUpdate)
),
new SelectOption(
Label: "Member name changes",
Value: nameof(LogChannelType.GuildMemberNickUpdate)
),
new SelectOption(
Label: "Member avatar changes",
Value: nameof(LogChannelType.GuildMemberAvatarUpdate)
),
new SelectOption(
Label: "Timeouts",
Value: nameof(LogChannelType.GuildMemberTimeout)
),
new SelectOption(Label: "Kicks", Value: nameof(LogChannelType.GuildMemberKick)),
new SelectOption(Label: "Bans", Value: nameof(LogChannelType.GuildBanAdd)),
new SelectOption(Label: "Unbans", Value: nameof(LogChannelType.GuildBanRemove)),
new SelectOption(Label: "New invites", Value: nameof(LogChannelType.InviteCreate)),
new SelectOption(
Label: "Deleted invites",
Value: nameof(LogChannelType.InviteDelete)
),
new SelectOption(
Label: "Edited messages",
Value: nameof(LogChannelType.MessageUpdate)
),
new SelectOption(
Label: "Deleted messages",
Value: nameof(LogChannelType.MessageDelete)
),
new SelectOption(
Label: "Bulk deleted messages",
Value: nameof(LogChannelType.MessageDeleteBulk)
),
]
);
public static (List, List) BuildRootMenu(
List guildChannels,
IGuild guild,
DbGuild guildConfig
)
{
List embeds =
[
new Embed(
Title: $"Log channels for {guild.Name}",
Description: "Press one of the buttons below to change the channel for that log type.",
Colour: DiscordUtils.Purple,
Fields: new[]
{
new EmbedField(
"Server changes",
PrettyChannelString(guildConfig.Channels.GuildUpdate),
true
),
new EmbedField(
"Emoji changes",
PrettyChannelString(guildConfig.Channels.GuildEmojisUpdate),
true
),
new EmbedField(
"New roles",
PrettyChannelString(guildConfig.Channels.GuildRoleCreate),
true
),
new EmbedField(
"Edited roles",
PrettyChannelString(guildConfig.Channels.GuildRoleUpdate),
true
),
new EmbedField(
"Deleted roles",
PrettyChannelString(guildConfig.Channels.GuildRoleDelete),
true
),
new EmbedField(
"New channels",
PrettyChannelString(guildConfig.Channels.ChannelCreate),
true
),
new EmbedField(
"Edited channels",
PrettyChannelString(guildConfig.Channels.ChannelUpdate),
true
),
new EmbedField(
"Deleted channels",
PrettyChannelString(guildConfig.Channels.ChannelDelete),
true
),
new EmbedField(
"Members joining",
PrettyChannelString(guildConfig.Channels.GuildMemberAdd),
true
),
new EmbedField(
"Members leaving",
PrettyChannelString(guildConfig.Channels.GuildMemberRemove),
true
),
new EmbedField(
"Member role changes",
PrettyChannelString(guildConfig.Channels.GuildMemberUpdate),
true
),
new EmbedField(
"Key role changes",
PrettyChannelString(guildConfig.Channels.GuildKeyRoleUpdate),
true
),
new EmbedField(
"Member name changes",
PrettyChannelString(guildConfig.Channels.GuildMemberNickUpdate),
true
),
new EmbedField(
"Member avatar changes",
PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate),
true
),
new EmbedField(
"Timeouts",
PrettyChannelString(guildConfig.Channels.GuildMemberTimeout),
true
),
new EmbedField(
"Kicks",
PrettyChannelString(guildConfig.Channels.GuildMemberKick),
true
),
new EmbedField(
"Bans",
PrettyChannelString(guildConfig.Channels.GuildBanAdd),
true
),
new EmbedField(
"Unbans",
PrettyChannelString(guildConfig.Channels.GuildBanRemove),
true
),
new EmbedField(
"New invites",
PrettyChannelString(guildConfig.Channels.InviteCreate),
true
),
new EmbedField(
"Deleted invites",
PrettyChannelString(guildConfig.Channels.InviteDelete),
true
),
new EmbedField(
"Edited messages",
PrettyChannelString(guildConfig.Channels.MessageUpdate),
true
),
new EmbedField(
"Deleted messages",
PrettyChannelString(guildConfig.Channels.MessageDelete),
true
),
new EmbedField(
"Bulk deleted messages",
PrettyChannelString(guildConfig.Channels.MessageDeleteBulk),
true
),
}
),
];
List components =
[
new ActionRowComponent([LogTypeSelect]),
new ActionRowComponent(
[
new ButtonComponent(
ButtonComponentStyle.Secondary,
Label: "Close",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"close"
)
),
]
),
];
return (embeds, components);
string PrettyChannelString(ulong id)
{
if (id == 0)
return "Not set";
if (guildChannels.All(c => c.ID != id))
return $"unknown channel {id}";
return $"<#{id}>";
}
}
public static string PrettyLogTypeName(LogChannelType type) =>
type switch
{
LogChannelType.GuildUpdate => "Server changes",
LogChannelType.GuildEmojisUpdate => "Emoji changes",
LogChannelType.GuildRoleCreate => "New roles",
LogChannelType.GuildRoleUpdate => "Edited roles",
LogChannelType.GuildRoleDelete => "Deleted roles",
LogChannelType.ChannelCreate => "New channels",
LogChannelType.ChannelUpdate => "Edited channels",
LogChannelType.ChannelDelete => "Deleted channels",
LogChannelType.GuildMemberAdd => "Members joining",
LogChannelType.GuildMemberUpdate => "Member role changes",
LogChannelType.GuildKeyRoleUpdate => "Key role changes",
LogChannelType.GuildMemberNickUpdate => "Member name changes",
LogChannelType.GuildMemberAvatarUpdate => "Member avatar changes",
LogChannelType.GuildMemberTimeout => "Timeouts",
LogChannelType.GuildMemberRemove => "Members leaving",
LogChannelType.GuildMemberKick => "Kicks",
LogChannelType.GuildBanAdd => "Bans",
LogChannelType.GuildBanRemove => "Unbans",
LogChannelType.InviteCreate => "New invites",
LogChannelType.InviteDelete => "Deleted invites",
LogChannelType.MessageUpdate => "Edited messages",
LogChannelType.MessageDelete => "Deleted messages",
LogChannelType.MessageDeleteBulk => "Bulk deleted messages",
_ => throw new ArgumentOutOfRangeException(
nameof(type),
type,
"Invalid LogChannelType value"
),
};
}