// 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" ), }; }