diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs index 5315591..5f24b2d 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs @@ -50,6 +50,16 @@ public class ChannelCommands( { private readonly ILogger _logger = logger.ForContext(); + [Command("check-permissions")] + [Description( + "Check for any permission issues that would prevent Catalogger from sending logs." + )] + [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] + public async Task CheckPermissionsAsync() + { + throw new NotImplementedException(); + } + [Command("configure-channels")] [Description("Configure log channels for this server.")] [DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] diff --git a/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs b/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs new file mode 100644 index 0000000..d59174c --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs @@ -0,0 +1,213 @@ +// 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; +using Catalogger.Backend.Database.Queries; +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.Commands.Attributes; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Commands.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; +using IResult = Remora.Results.IResult; + +namespace Catalogger.Backend.Bot.Commands; + +[Group("ignored-channels")] +[Description("Manage channels ignored for logging.")] +[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] +public class IgnoreChannelCommands( + ILogger logger, + DatabaseContext db, + IMemberCache memberCache, + GuildCache guildCache, + ChannelCache channelCache, + PermissionResolverService permissionResolver, + ContextInjectionService contextInjection, + FeedbackService feedbackService +) : CommandGroup +{ + private readonly ILogger _logger = logger.ForContext(); + + [Command("add")] + [Description("Add a channel to the list of ignored channels.")] + public async Task AddIgnoredChannelAsync( + [ChannelTypes( + ChannelType.GuildCategory, + ChannelType.GuildText, + ChannelType.GuildAnnouncement, + ChannelType.GuildForum, + ChannelType.GuildMedia, + ChannelType.GuildVoice, + ChannelType.GuildStageVoice + )] + [Description("The channel to ignore")] + IChannel channel + ) + { + var (_, guildId) = contextInjection.GetUserAndGuild(); + var guildConfig = await db.GetGuildAsync(guildId); + + if (guildConfig.Channels.IgnoredChannels.Contains(channel.ID.Value)) + return await feedbackService.ReplyAsync( + "That channel is already being ignored.", + isEphemeral: true + ); + + guildConfig.Channels.IgnoredChannels.Add(channel.ID.Value); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync( + $"Successfully added {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} to the list of ignored channels." + ); + } + + [Command("remove")] + [Description("Remove a channel from the list of ignored channels.")] + public async Task RemoveIgnoredChannelAsync( + [Description("The channel to stop ignoring")] IChannel channel + ) + { + var (_, guildId) = contextInjection.GetUserAndGuild(); + var guildConfig = await db.GetGuildAsync(guildId); + + if (!guildConfig.Channels.IgnoredChannels.Contains(channel.ID.Value)) + return await feedbackService.ReplyAsync( + "That channel is already not ignored.", + isEphemeral: true + ); + + guildConfig.Channels.IgnoredChannels.Remove(channel.ID.Value); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return await feedbackService.ReplyAsync( + $"Successfully removed {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} from the list of ignored channels." + ); + } + + [Command("list")] + [Description("List channels ignored for logging.")] + public async Task ListIgnoredChannelsAsync() + { + var (userId, guildId) = contextInjection.GetUserAndGuild(); + if (!guildCache.TryGet(guildId, out var guild)) + throw new CataloggerError("Guild not in cache"); + + var guildChannels = channelCache.GuildChannels(guildId).ToList(); + var guildConfig = await db.GetGuildAsync(guildId); + + var member = await memberCache.TryGetAsync(guildId, userId); + if (member == null) + throw new CataloggerError("Executing member not found"); + + var ignoredChannels = guildConfig + .Channels.IgnoredChannels.Select(id => + { + var channel = guildChannels.FirstOrDefault(c => c.ID.Value == id); + if (channel == null) + return new IgnoredChannel(IgnoredChannelType.Unknown, DiscordSnowflake.New(id)); + + var type = channel.Type switch + { + ChannelType.GuildCategory => IgnoredChannelType.Category, + _ => IgnoredChannelType.Base, + }; + + return new IgnoredChannel( + type, + channel.ID, + permissionResolver + .GetChannelPermissions(guildId, member, channel) + .HasPermission(DiscordPermission.ViewChannel) + ); + }) + .ToList(); + + foreach (var ch in ignoredChannels) + { + _logger.Debug("Channel: {ChannelId}, type: {Type}", ch.Id, ch.Type); + } + + var embed = new EmbedBuilder() + .WithTitle($"Ignored channels in {guild.Name}") + .WithColour(DiscordUtils.Purple); + + var nonVisibleCategories = ignoredChannels.Count(c => + c is { Type: IgnoredChannelType.Category, CanSee: false } + ); + var visibleCategories = ignoredChannels + .Where(c => c is { Type: IgnoredChannelType.Category, CanSee: true }) + .ToList(); + + if (nonVisibleCategories != 0 || visibleCategories.Count != 0) + { + var value = string.Join("\n", visibleCategories.Select(c => $"<#{c.Id}>")); + if (nonVisibleCategories != 0) + value += + $"\n\n{nonVisibleCategories} channel(s) were ignored as you do not have access to them."; + + embed.AddField("Categories", value); + } + + var nonVisibleBase = ignoredChannels.Count(c => + c is { Type: IgnoredChannelType.Base, CanSee: false } + ); + var visibleBase = ignoredChannels + .Where(c => c is { Type: IgnoredChannelType.Base, CanSee: true }) + .ToList(); + + if (nonVisibleBase != 0 || visibleBase.Count != 0) + { + var value = string.Join("\n", visibleBase.Select(c => $"<#{c.Id}>")); + if (nonVisibleBase != 0) + value += + $"\n\n{nonVisibleBase} channel(s) were ignored as you do not have access to them."; + + embed.AddField("Channels", value); + } + + var unknownChannels = string.Join( + "\n", + ignoredChannels + .Where(c => c.Type == IgnoredChannelType.Unknown) + .Select(c => $"{c.Id} <#{c.Id}>") + ); + if (!string.IsNullOrWhiteSpace(unknownChannels)) + { + embed.AddField("Unknown", unknownChannels); + } + + return await feedbackService.ReplyAsync(embeds: [embed.Build().GetOrThrow()]); + } + + private record struct IgnoredChannel(IgnoredChannelType Type, Snowflake Id, bool CanSee = true); + + private enum IgnoredChannelType + { + Unknown, + Base, + Category, + } +} diff --git a/Catalogger.Backend/Bot/Commands/MetaCommands.cs b/Catalogger.Backend/Bot/Commands/MetaCommands.cs index cf96522..aa8b59d 100644 --- a/Catalogger.Backend/Bot/Commands/MetaCommands.cs +++ b/Catalogger.Backend/Bot/Commands/MetaCommands.cs @@ -37,6 +37,7 @@ using IResult = Remora.Results.IResult; namespace Catalogger.Backend.Bot.Commands; [Group("catalogger")] +[Description("Commands for information about the bot itself.")] public class MetaCommands( ILogger logger, IClock clock, diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index 1ec5f2a..c040cf4 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -114,6 +114,7 @@ public static class StartupExtensions .AddSingleton() .AddSingleton(InMemoryDataService.Instance) .AddSingleton() + .AddTransient() // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => serviceProvider.GetRequiredService() diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index d082783..bdd712a 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -87,6 +87,7 @@ builder .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() + .WithCommandGroup() // End command tree .Finish() .AddPagination() diff --git a/Catalogger.Backend/Services/PermissionResolverService.cs b/Catalogger.Backend/Services/PermissionResolverService.cs new file mode 100644 index 0000000..49de442 --- /dev/null +++ b/Catalogger.Backend/Services/PermissionResolverService.cs @@ -0,0 +1,93 @@ +// 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 Catalogger.Backend.Cache; +using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Extensions; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Objects; +using Remora.Rest.Core; + +namespace Catalogger.Backend.Services; + +public class PermissionResolverService( + IMemberCache memberCache, + RoleCache roleCache, + ChannelCache channelCache +) +{ + public async Task GetGuildPermissionsAsync( + Snowflake guildId, + Snowflake userId + ) + { + var member = await memberCache.TryGetAsync(guildId, userId); + return member == null ? DiscordPermissionSet.Empty : GetGuildPermissions(guildId, member); + } + + public async Task GetChannelPermissionsAsync( + Snowflake guildId, + Snowflake userId, + Snowflake channelId + ) + { + if (!channelCache.TryGet(channelId, out var channel)) + return DiscordPermissionSet.Empty; + return await GetChannelPermissionsAsync(guildId, userId, channel); + } + + public async Task GetChannelPermissionsAsync( + Snowflake guildId, + Snowflake userId, + IChannel channel + ) + { + var member = await memberCache.TryGetAsync(guildId, userId); + return member == null + ? DiscordPermissionSet.Empty + : GetChannelPermissions(guildId, member, channel); + } + + public IDiscordPermissionSet GetGuildPermissions(Snowflake guildId, IGuildMember member) + { + var roles = roleCache.GuildRoles(guildId).ToList(); + var everyoneRole = roles.First(r => r.ID == guildId); + var memberRoles = roles.Where(r => member.Roles.Contains(r.ID)).ToList(); + + return DiscordPermissionSet.ComputePermissions( + member.User.GetOrThrow().ID, + everyoneRole, + memberRoles + ); + } + + public IDiscordPermissionSet GetChannelPermissions( + Snowflake guildId, + IGuildMember member, + IChannel channel + ) + { + var roles = roleCache.GuildRoles(guildId).ToList(); + var everyoneRole = roles.First(r => r.ID == guildId); + var memberRoles = roles.Where(r => member.Roles.Contains(r.ID)).ToList(); + + return DiscordPermissionSet.ComputePermissions( + member.User.GetOrThrow().ID, + everyoneRole, + memberRoles, + channel.PermissionOverwrites.OrDefault() + ); + } +}