diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs
new file mode 100644
index 0000000..4ee63f6
--- /dev/null
+++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs
@@ -0,0 +1,122 @@
+// 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 Remora.Discord.API;
+using Remora.Discord.API.Abstractions.Gateway.Events;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Gateway.Responders;
+using Remora.Results;
+
+namespace Catalogger.Backend.Bot.Responders.Guilds;
+
+public class GuildUpdateResponder(
+ ILogger logger,
+ DatabaseContext db,
+ GuildCache guildCache,
+ UserCache userCache,
+ WebhookExecutorService webhookExecutor
+) : IResponder
+{
+ private readonly ILogger _logger = logger.ForContext();
+
+ public async Task RespondAsync(IGuildUpdate evt, CancellationToken ct = default)
+ {
+ try
+ {
+ if (!guildCache.TryGet(evt.ID, out var oldGuild))
+ {
+ _logger.Warning(
+ "Guild {GuildId} not found in cache, ignoring event and adding it to cache",
+ evt.ID
+ );
+ return Result.Success;
+ }
+
+ var embed = new EmbedBuilder()
+ .WithTitle("Server updated")
+ .WithColour(DiscordUtils.Blue)
+ .WithCurrentTimestamp();
+
+ if (evt.Name != oldGuild.Name)
+ embed.AddField("Name", $"**Before:** {oldGuild.Name}\n**After:** {evt.Name}");
+
+ if (!Equals(evt.Icon, oldGuild.Icon))
+ {
+ if (evt.Icon != null)
+ embed.WithThumbnailUrl(
+ CDN.GetGuildIconUrl(evt, imageSize: 1024).GetOrThrow().ToString()
+ );
+
+ if (evt.Icon != null && oldGuild.Icon == null)
+ {
+ embed.AddField(
+ "Icon added",
+ $"[Link]({CDN.GetGuildIconUrl(evt, imageSize: 1024).GetOrThrow()})"
+ );
+ }
+ else if (evt.Icon != null && oldGuild.Icon != null)
+ {
+ embed.AddField(
+ "Icon changed",
+ $"[Link]({CDN.GetGuildIconUrl(evt, imageSize: 1024).GetOrThrow()})"
+ );
+ }
+ else
+ {
+ embed.AddField("Icon removed", "*(old icon no longer available, sorry)*");
+ }
+ }
+
+ if (evt.OwnerID != oldGuild.OwnerID)
+ {
+ embed.AddField(
+ "Ownership transferred",
+ $"""
+ **Before:** {userCache.TryFormatUserAsync(oldGuild.OwnerID)}
+ **After:** {userCache.TryFormatUserAsync(evt.OwnerID)}
+ """
+ );
+ }
+
+ if (embed.Fields.Count != 0)
+ {
+ var guildConfig = await db.GetGuildAsync(evt.ID, ct);
+ webhookExecutor.QueueLog(
+ guildConfig,
+ LogChannelType.GuildUpdate,
+ embed.Build().GetOrThrow()
+ );
+ }
+ else
+ {
+ _logger.Debug(
+ "Guild update event for {GuildId} had nothing we want to log, not sending embed",
+ evt.ID
+ );
+ }
+
+ return Result.Success;
+ }
+ finally
+ {
+ guildCache.Set(evt);
+ }
+ }
+}
diff --git a/Catalogger.Backend/Extensions/DiscordExtensions.cs b/Catalogger.Backend/Extensions/DiscordExtensions.cs
index 78500b1..02dc1d0 100644
--- a/Catalogger.Backend/Extensions/DiscordExtensions.cs
+++ b/Catalogger.Backend/Extensions/DiscordExtensions.cs
@@ -36,6 +36,7 @@ public static class DiscordExtensions
: $"{user.Username.Value}#{discriminator:0000}";
}
+ // TODO: replace these avatar URL methods with the built-in CDN.* methods?
public static string AvatarUrl(this IUser user, int size = 256)
{
if (user.Avatar != null)