// 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.InMemoryCache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Rest.Core; using Remora.Results; namespace Catalogger.Backend.Bot.Responders.Channels; public class ChannelUpdateResponder( ILogger logger, DatabaseContext db, ChannelCache channelCache, RoleCache roleCache, UserCache userCache, WebhookExecutorService webhookExecutor ) : IResponder { private readonly ILogger _logger = logger.ForContext(); public async Task RespondAsync(IChannelUpdate evt, CancellationToken ct = default) { try { if (!channelCache.TryGet(evt.ID, out var oldChannel)) { _logger.Debug("Updated channel {ChannelId} wasn't in the cache", evt.ID); return Result.Success; } var guildConfig = await db.GetGuildAsync(evt.GuildID.Value, ct); var builder = new EmbedBuilder() .WithTitle( evt.Type switch { ChannelType.GuildVoice => "Voice channel edited", ChannelType.GuildCategory => "Category channel edited", ChannelType.GuildAnnouncement or ChannelType.GuildText => "Text channel edited", _ => "Channel edited", } ) .WithColour(DiscordUtils.Blue) .WithFooter($"ID: {evt.ID} | Name: {evt.Name}") .WithCurrentTimestamp(); if (oldChannel.ParentID != evt.ParentID) { var categoryUpdate = CategoryUpdate( oldChannel.ParentID.OrDefault(), evt.ParentID.OrDefault() ); if (!string.IsNullOrWhiteSpace(categoryUpdate)) builder.AddField("Category", categoryUpdate); } if (oldChannel.Name != evt.Name) builder.AddField("Name", $"**Before:** {oldChannel.Name}\n**After:** {evt.Name}"); if (oldChannel.Topic != evt.Topic) { var oldTopic = oldChannel.Topic.OrDefault() ?? "(none)"; var newTopic = evt.Topic.OrDefault() ?? "(none)"; var topicField = $"**Before:** {oldTopic}\n\n**After:** {newTopic}"; if (topicField.Length > 1000) topicField = topicField[..1000] + "…"; builder.AddField("Description", topicField); } var oldOverrides = oldChannel.PermissionOverwrites.OrDefault() ?? []; var newOverrides = evt.PermissionOverwrites.OrDefault() ?? []; var addedOverrides = newOverrides .Where(o => oldOverrides.All(o2 => o.ID != o2.ID)) .ToList(); var removedOverrides = oldOverrides .Where(o => newOverrides.All(o2 => o.ID != o2.ID)) .ToList(); // Overrides filtered to ones that exist in both lists, but have different allow or deny values var editedOverrides = newOverrides.Where(o => oldOverrides.Any(o2 => o.ID == o2.ID && (o.Allow.Value != o2.Allow.Value || o.Deny.Value != o2.Deny.Value) ) ); if (addedOverrides.Count != 0) { var addedOverrideNames = new List(); foreach (var o in addedOverrides) { if (o.Type is PermissionOverwriteType.Member) { var user = await userCache.GetUserAsync(o.ID); addedOverrideNames.Add(user != null ? $"<@{user.ID}>" : $"user {o.ID}"); } else { addedOverrideNames.Add( roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}" ); break; } builder.AddField("Added overrides", string.Join(", ", addedOverrideNames)); } } if (removedOverrides.Count != 0) { var removedOverrideNames = new List(); foreach (var o in removedOverrides) { if (o.Type is PermissionOverwriteType.Member) { var user = await userCache.GetUserAsync(o.ID); removedOverrideNames.Add(user != null ? $"<@{user.ID}>" : $"user {o.ID}"); } else { removedOverrideNames.Add( roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}" ); break; } } builder.AddField("Removed overrides", string.Join(", ", removedOverrideNames)); } foreach (var overwrite in editedOverrides) { var perms = string.Join( "\n", PermissionUpdate(oldOverrides.First(o => o.ID == overwrite.ID), overwrite) ); if (string.IsNullOrWhiteSpace(perms)) continue; builder.AddField(await OverwriteName(overwrite), perms.Trim()); } foreach (var overwrite in addedOverrides) { var embedFieldValue = ""; if (overwrite.Allow.GetPermissions().Count != 0) embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}"; if (overwrite.Deny.GetPermissions().Count != 0) embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}"; if (string.IsNullOrWhiteSpace(embedFieldValue)) continue; builder.AddField(await OverwriteName(overwrite), embedFieldValue.Trim()); } // Sometimes we get channel update events for channels that didn't actually have anything loggable change. // If that happens, there will be no embed fields, so just check for that if (builder.Fields.Count == 0) return Result.Success; webhookExecutor.QueueLog( guildConfig, LogChannelType.ChannelUpdate, builder.Build().GetOrThrow() ); return Result.Success; } finally { channelCache.Set(evt); } } private string CategoryUpdate(Snowflake? oldCategory, Snowflake? newCategory) { var value = ""; if (oldCategory != null && channelCache.TryGet(oldCategory.Value, out var oldChannel)) value += $"**Before:** {oldChannel.Name}"; if (newCategory != null && channelCache.TryGet(newCategory.Value, out var newChannel)) value += $"\n**After:** {newChannel.Name}"; return value.Trim(); } private async Task OverwriteName(IPermissionOverwrite overwrite) { switch (overwrite.Type) { case PermissionOverwriteType.Role: return roleCache.TryGet(overwrite.ID, out var role) ? $"Override for {role.Name}" : $"Override for role {overwrite.ID}"; case PermissionOverwriteType.Member: var user = await userCache.GetUserAsync(overwrite.ID); return user != null ? $"Override for {user.Tag()}" : $"Override for user {overwrite.ID}"; default: throw new ArgumentOutOfRangeException( nameof(overwrite), overwrite.Type, "Invalid PermissionOverwriteType" ); } } private static IEnumerable PermissionUpdate( IPermissionOverwrite oldOverwrite, IPermissionOverwrite newOverwrite ) { foreach (var perm in Enum.GetValues()) { if ( newOverwrite.Allow.HasPermission(perm) && !oldOverwrite.Allow.HasPermission(perm) && !oldOverwrite.Deny.HasPermission(perm) ) { yield return $"\u2b1c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}"; } else if ( newOverwrite.Deny.HasPermission(perm) && !oldOverwrite.Allow.HasPermission(perm) && !oldOverwrite.Deny.HasPermission(perm) ) { yield return $"\u2b1c \u279c \u274c {perm.Humanize(LetterCasing.Title)}"; } else if ( newOverwrite.Allow.HasPermission(perm) && oldOverwrite.Deny.HasPermission(perm) ) { yield return $"\u274c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}"; } else if ( newOverwrite.Deny.HasPermission(perm) && oldOverwrite.Allow.HasPermission(perm) ) { yield return $"\u2705 \u279c \u274c {perm.Humanize(LetterCasing.Title)}"; } else if ( !newOverwrite.Allow.HasPermission(perm) && !newOverwrite.Deny.HasPermission(perm) && oldOverwrite.Allow.HasPermission(perm) ) { yield return $"\u2705 \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}"; } else if ( !newOverwrite.Allow.HasPermission(perm) && !newOverwrite.Deny.HasPermission(perm) && oldOverwrite.Allow.HasPermission(perm) ) { yield return $"\u274c \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}"; } } } }