diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 085fcbc..d2b1b96 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,14 +3,14 @@ "isRoot": true, "tools": { "csharpier": { - "version": "0.30.1", + "version": "0.30.6", "commands": [ "dotnet-csharpier" ], "rollForward": false }, "husky": { - "version": "0.7.1", + "version": "0.7.2", "commands": [ "husky" ], diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs index 2dab68f..7f2ec0d 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs @@ -43,6 +43,7 @@ public class ChannelCommands( Config config, GuildRepository guildRepository, GuildCache guildCache, + GuildFetchService guildFetchService, ChannelCache channelCache, IMemberCache memberCache, IFeedbackService feedbackService, @@ -68,8 +69,11 @@ public class ChannelCommands( public async Task CheckPermissionsAsync() { var (userId, guildId) = contextInjection.GetUserAndGuild(); + if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + { + return CataloggerError.Result($"Guild {guildId} not in cache"); + } var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}"); @@ -78,8 +82,18 @@ public class ChannelCommands( DiscordSnowflake.New(config.Discord.ApplicationId) ); var currentUser = await memberCache.TryGetAsync(guildId, userId); + if (botUser == null || currentUser == null) - throw new CataloggerError("Bot member or invoking member not found in cache"); + { + // 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 @@ -204,7 +218,7 @@ public class ChannelCommands( { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs index 9867e7d..1104c82 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs @@ -50,15 +50,15 @@ public class ChannelCommandsComponents( public async Task OnMenuSelectionAsync(IReadOnlyList values) { if (contextInjection.Context is not IInteractionCommandContext ctx) - throw new CataloggerError("No context"); + return CataloggerError.Result("No context"); if (!ctx.TryGetUserID(out var userId)) - throw new CataloggerError("No user ID in context"); + return CataloggerError.Result("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - throw new CataloggerError("No message ID in context"); + return CataloggerError.Result("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - throw new CataloggerError("No guild ID in context"); + return CataloggerError.Result("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -76,7 +76,7 @@ public class ChannelCommandsComponents( var state = values[0]; if (!Enum.TryParse(state, out var logChannelType)) - throw new CataloggerError($"Invalid config-channels state {state}"); + return CataloggerError.Result($"Invalid config-channels state {state}"); var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType); string? channelMention; @@ -147,15 +147,15 @@ public class ChannelCommandsComponents( public async Task OnButtonPressedAsync(string state) { if (contextInjection.Context is not IInteractionCommandContext ctx) - throw new CataloggerError("No context"); + return CataloggerError.Result("No context"); if (!ctx.TryGetUserID(out var userId)) - throw new CataloggerError("No user ID in context"); + return CataloggerError.Result("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - throw new CataloggerError("No message ID in context"); + return CataloggerError.Result("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - throw new CataloggerError("No guild ID in context"); + return CataloggerError.Result("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -179,9 +179,9 @@ public class ChannelCommandsComponents( ); case "reset": if (lease.Data.CurrentPage == null) - throw new CataloggerError("CurrentPage was null in reset button callback"); + return CataloggerError.Result("CurrentPage was null in reset button callback"); if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - throw new CataloggerError( + return CataloggerError.Result( $"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'" ); @@ -281,15 +281,15 @@ public class ChannelCommandsComponents( public async Task OnMenuSelectionAsync(IReadOnlyList channels) { if (contextInjection.Context is not IInteractionCommandContext ctx) - throw new CataloggerError("No context"); + return CataloggerError.Result("No context"); if (!ctx.TryGetUserID(out var userId)) - throw new CataloggerError("No user ID in context"); + return CataloggerError.Result("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - throw new CataloggerError("No message ID in context"); + return CataloggerError.Result("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - throw new CataloggerError("No guild ID in context"); + return CataloggerError.Result("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); var channelId = channels[0].ID.ToUlong(); @@ -305,7 +305,7 @@ public class ChannelCommandsComponents( } if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - throw new CataloggerError( + return CataloggerError.Result( $"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'" ); diff --git a/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs b/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs index 5ffc7a9..7e8987c 100644 --- a/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs +++ b/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs @@ -98,7 +98,7 @@ public class IgnoreEntitiesCommands : CommandGroup { var (_, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); @@ -201,14 +201,14 @@ public class IgnoreEntitiesCommands : CommandGroup { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); var member = await memberCache.TryGetAsync(guildId, userId); if (member == null) - throw new CataloggerError("Executing member not found"); + return CataloggerError.Result("Executing member not found"); var ignoredChannels = guildConfig .IgnoredChannels.Select(id => diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs index 69b225e..b61fabc 100644 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs @@ -110,14 +110,14 @@ public partial class IgnoreMessageCommands : CommandGroup { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); var member = await memberCache.TryGetAsync(guildId, userId); if (member == null) - throw new CataloggerError("Executing member not found"); + return CataloggerError.Result("Executing member not found"); var ignoredChannels = guildConfig .Messages.IgnoredChannels.Select(id => diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs index cf3cb10..2cc46b7 100644 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs @@ -90,7 +90,7 @@ public partial class IgnoreMessageCommands { var (_, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs index d89f487..15ae280 100644 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs @@ -94,7 +94,7 @@ public partial class IgnoreMessageCommands { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild was not cached"); + return CataloggerError.Result("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/InviteCommands.cs b/Catalogger.Backend/Bot/Commands/InviteCommands.cs index 7ffa031..6ec6991 100644 --- a/Catalogger.Backend/Bot/Commands/InviteCommands.cs +++ b/Catalogger.Backend/Bot/Commands/InviteCommands.cs @@ -59,7 +59,7 @@ public class InviteCommands( var (userId, guildId) = contextInjection.GetUserAndGuild(); var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs index 1ebc458..dd2ff90 100644 --- a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs +++ b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs @@ -45,7 +45,7 @@ public class KeyRoleCommands( { var (_, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildRoles = roleCache.GuildRoles(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -85,7 +85,7 @@ public class KeyRoleCommands( var (_, guildId) = contextInjection.GetUserAndGuild(); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); if (role == null) - throw new CataloggerError("Role is not cached"); + return CataloggerError.Result("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.Any(id => role.ID.Value == id)) @@ -111,7 +111,7 @@ public class KeyRoleCommands( var (_, guildId) = contextInjection.GetUserAndGuild(); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); if (role == null) - throw new CataloggerError("Role is not cached"); + return CataloggerError.Result("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.All(id => role.ID != id)) diff --git a/Catalogger.Backend/Bot/Commands/MetaCommands.cs b/Catalogger.Backend/Bot/Commands/MetaCommands.cs index 1887507..8b543af 100644 --- a/Catalogger.Backend/Bot/Commands/MetaCommands.cs +++ b/Catalogger.Backend/Bot/Commands/MetaCommands.cs @@ -218,7 +218,7 @@ public class MetaCommands( await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); } - // TODO: add more checks around response format, configurable prometheus endpoint + // TODO: add more checks around response format private async Task MessagesRate() { if (!config.Logging.EnableMetrics) @@ -227,7 +227,8 @@ public class MetaCommands( try { var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); - var resp = await _client.GetAsync($"http://localhost:9090/api/v1/query?query={query}"); + var prometheusUrl = config.Logging.PrometheusUrl ?? "http://localhost:9090"; + var resp = await _client.GetAsync($"{prometheusUrl}/api/v1/query?query={query}"); resp.EnsureSuccessStatusCode(); var data = await resp.Content.ReadFromJsonAsync(); diff --git a/Catalogger.Backend/Bot/Commands/RedirectCommands.cs b/Catalogger.Backend/Bot/Commands/RedirectCommands.cs index 586deb3..3864c54 100644 --- a/Catalogger.Backend/Bot/Commands/RedirectCommands.cs +++ b/Catalogger.Backend/Bot/Commands/RedirectCommands.cs @@ -141,7 +141,7 @@ public class RedirectCommands( { var (userId, guildId) = contextInjectionService.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild was not cached"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs index b47a583..c92a886 100644 --- a/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs +++ b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs @@ -83,7 +83,7 @@ public class WatchlistCommands( { var (userId, guildId) = contextInjectionService.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild was not cached"); + return CataloggerError.Result("Guild not in cache"); var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId); if (watchlist.Count == 0) diff --git a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs index c07dc6d..1fd3b9e 100644 --- a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs +++ b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs @@ -19,17 +19,21 @@ using Remora.Commands.Services; using Remora.Commands.Tokenization; using Remora.Commands.Trees; using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; using Remora.Discord.Commands.Responders; using Remora.Discord.Commands.Services; using Remora.Discord.Gateway.Responders; +using Remora.Rest.Core; using Remora.Results; using Serilog.Context; namespace Catalogger.Backend.Bot.Responders; /// -/// Wrapper for Remora.Discord's default interaction responder, that ignores all events if test mode is enabled. +/// Wrapper for Remora.Discord's default interaction responder, that ignores all events if test mode is enabled, +/// and handles results returned by commands. /// public class CustomInteractionResponder( Config config, @@ -87,6 +91,38 @@ public class CustomInteractionResponder( true ); - return await _inner.RespondAsync(evt, ct); + var result = await _inner.RespondAsync(evt, ct); + if (result.Error is not CataloggerError cataloggerError) + return result; + + return await interactionAPI.CreateInteractionResponseAsync( + evt.ID, + evt.Token, + new InteractionResponse( + Type: InteractionCallbackType.ChannelMessageWithSource, + Data: new Optional>( + new InteractionMessageCallbackData( + Embeds: new Optional>( + [ + new Embed( + Colour: DiscordUtils.Red, + Title: "Something went wrong", + Description: $""" + Something went wrong while running this command. + > {cataloggerError.Message} + Please try again later. + """ + ), + ] + ) + ) + ) + ), + ct: ct + ); } } diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs index 5098cd2..e54d12b 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs @@ -77,7 +77,7 @@ public class MessageCreateResponder( return Result.Success; } - await messageRepository.SaveMessageAsync(msg, ct); + await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct); return Result.Success; } } @@ -144,6 +144,19 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) await using var messageRepository = scope.ServiceProvider.GetRequiredService(); + if (await messageRepository.IsMessageIgnoredAsync(originalId)) + { + _logger.Debug( + "Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored", + msgId, + originalId + ); + + await messageRepository.IgnoreMessageAsync(originalId); + await messageRepository.IgnoreMessageAsync(msgId); + return; + } + _logger.Debug( "Setting proxy data for {MessageId} and ignoring {OriginalId}", msgId, @@ -195,6 +208,19 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) pkMessage.Original ); + if (await messageRepository.IsMessageIgnoredAsync(pkMessage.Original)) + { + _logger.Debug( + "Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored", + pkMessage.Id, + pkMessage.Original + ); + + await messageRepository.IgnoreMessageAsync(pkMessage.Original); + await messageRepository.IgnoreMessageAsync(msgId); + return; + } + await messageRepository.SetProxiedMessageDataAsync( msgId, pkMessage.Original, diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs index 315daec..55a5a68 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs @@ -18,7 +18,6 @@ using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; -using NodaTime; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; @@ -27,7 +26,6 @@ using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; using Remora.Rest.Core; using Remora.Results; -using Serilog.Context; namespace Catalogger.Backend.Bot.Responders.Messages; @@ -38,7 +36,6 @@ public class MessageDeleteResponder( WebhookExecutorService webhookExecutor, ChannelCache channelCache, UserCache userCache, - IClock clock, PluralkitApiService pluralkitApi ) : IResponder { @@ -81,8 +78,8 @@ public class MessageDeleteResponder( new Embed( Title: "Message deleted", Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).", - Footer: new EmbedFooter(Text: $"ID: {evt.ID}"), - Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() + Footer: new EmbedFooter(Text: $"ID: {evt.ID} | Original sent at"), + Timestamp: evt.ID.Timestamp ) ); @@ -124,7 +121,7 @@ public class MessageDeleteResponder( .WithTitle("Message deleted") .WithDescription(msg.Content) .WithColour(DiscordUtils.Red) - .WithFooter($"ID: {msg.Id}") + .WithFooter($"ID: {msg.Id} | Original sent at") .WithTimestamp(evt.ID); if (user != null) diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs index fa1e75d..0bf2c28 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs @@ -20,7 +20,6 @@ using Catalogger.Backend.Services; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Gateway.Events; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; @@ -40,13 +39,9 @@ public class MessageUpdateResponder( { private readonly ILogger _logger = logger.ForContext(); - public async Task RespondAsync(IMessageUpdate evt, CancellationToken ct = default) + public async Task RespondAsync(IMessageUpdate msg, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - - // Discord only *very* recently changed message update events to have all fields, - // so we convert the event to a MessageCreate to avoid having to unwrap every single field - var msg = ConvertToMessageCreate(evt); + using var _ = LogUtils.Enrich(msg); if (!msg.GuildID.IsDefined()) { @@ -134,7 +129,7 @@ public class MessageUpdateResponder( if (oldMessage is { System: not null, Member: not null }) { embedBuilder.WithTitle($"Message by {msg.Author.Username} edited"); - embedBuilder.AddField("\u200b", "**PluralKit information**", false); + embedBuilder.AddField("\u200b", "**PluralKit information**"); embedBuilder.AddField("System ID", oldMessage.System, true); embedBuilder.AddField("Member ID", oldMessage.Member, true); } @@ -174,7 +169,7 @@ public class MessageUpdateResponder( ) { if ( - !await messageRepository.SaveMessageAsync(msg, ct) + !await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct) && msg.ApplicationID.Is(DiscordUtils.PkUserId) ) { @@ -196,44 +191,6 @@ public class MessageUpdateResponder( } } - private static MessageCreate ConvertToMessageCreate(IMessageUpdate evt) => - new( - evt.GuildID, - evt.Member, - evt.Mentions.GetOrThrow(), - evt.ID.GetOrThrow(), - evt.ChannelID.GetOrThrow(), - evt.Author.GetOrThrow(), - evt.Content.GetOrThrow(), - evt.Timestamp.GetOrThrow(), - evt.EditedTimestamp.GetOrThrow(), - IsTTS: false, - evt.MentionsEveryone.GetOrThrow(), - evt.MentionedRoles.GetOrThrow(), - evt.MentionedChannels, - evt.Attachments.GetOrThrow(), - evt.Embeds.GetOrThrow(), - evt.Reactions, - evt.Nonce, - evt.IsPinned.GetOrThrow(), - evt.WebhookID, - evt.Type.GetOrThrow(), - evt.Activity, - evt.Application, - evt.ApplicationID, - evt.MessageReference, - evt.Flags, - evt.ReferencedMessage, - evt.Interaction, - evt.Thread, - evt.Components, - evt.StickerItems, - evt.Position, - evt.Resolved, - evt.InteractionMetadata, - evt.Poll - ); - private static IEnumerable ChunksUpTo(string str, int maxChunkSize) { for (var i = 0; i < str.Length; i += maxChunkSize) diff --git a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs index dfa9694..0e08c1f 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs @@ -234,6 +234,7 @@ internal record RedisMember( User.ToRemoraUser(), Nickname, Avatar != null ? new ImageHash(Avatar) : null, + Banner: null, Roles.Select(DiscordSnowflake.New).ToList(), JoinedAt, PremiumSince, diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index 6d048ab..b377418 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -21,18 +21,18 @@ - + - + - + diff --git a/Catalogger.Backend/CataloggerError.cs b/Catalogger.Backend/CataloggerError.cs index 31abf6f..40322a4 100644 --- a/Catalogger.Backend/CataloggerError.cs +++ b/Catalogger.Backend/CataloggerError.cs @@ -13,6 +13,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using Remora.Results; +using RemoraResult = Remora.Results.Result; + namespace Catalogger.Backend; -public class CataloggerError(string message) : Exception(message) { } +public class CataloggerError(string message) : Exception(message), IResultError +{ + public static RemoraResult Result(string message) => + RemoraResult.FromError(new CataloggerError(message)); +} diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index 612b91e..831c439 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -33,6 +33,7 @@ public class Config public bool EnableMetrics { get; init; } = true; public string? SeqLogUrl { get; init; } + public string? PrometheusUrl { get; init; } } public class DatabaseConfig diff --git a/Catalogger.Backend/Database/DatabaseConnection.cs b/Catalogger.Backend/Database/DatabaseConnection.cs index f15c2c7..b2488f9 100644 --- a/Catalogger.Backend/Database/DatabaseConnection.cs +++ b/Catalogger.Backend/Database/DatabaseConnection.cs @@ -17,6 +17,7 @@ using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; using Npgsql; +using Serilog; namespace Catalogger.Backend.Database; @@ -49,11 +50,18 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa public new void Dispose() { - Close(); - inner.Dispose(); + Dispose(true); GC.SuppressFinalize(this); } + protected override void Dispose(bool disposing) + { + Log.Error("Called Dispose method on DbConnection, should call DisposeAsync!"); + Log.Warning("CloseAsync will be called synchronously."); + CloseAsync().Wait(); + inner.Dispose(); + } + public override async ValueTask DisposeAsync() { await CloseAsync(); @@ -62,13 +70,13 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa } protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => - inner.BeginTransaction(isolationLevel); + throw new SyncException(nameof(BeginDbTransaction)); public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); - public override void Close() => inner.Close(); + public override void Close() => throw new SyncException(nameof(Close)); - public override void Open() => inner.Open(); + public override void Open() => throw new SyncException(nameof(Open)); [AllowNull] public override string ConnectionString @@ -83,4 +91,6 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa public override string ServerVersion => inner.ServerVersion; protected override DbCommand CreateDbCommand() => inner.CreateCommand(); + + public class SyncException(string method) : Exception($"Tried to use sync method {method}"); } diff --git a/Catalogger.Backend/Database/Repositories/MessageRepository.cs b/Catalogger.Backend/Database/Repositories/MessageRepository.cs index 17925c3..fc45e4f 100644 --- a/Catalogger.Backend/Database/Repositories/MessageRepository.cs +++ b/Catalogger.Backend/Database/Repositories/MessageRepository.cs @@ -18,6 +18,7 @@ using Catalogger.Backend.Extensions; using Dapper; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Repositories; @@ -63,7 +64,11 @@ public class MessageRepository( /// /// Adds a new message. If the message is already in the database, updates the existing message instead. /// - public async Task SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) + public async Task SaveMessageAsync( + IMessage msg, + Optional guildId, + CancellationToken ct = default + ) { var content = await Task.Run( () => @@ -107,7 +112,9 @@ public class MessageRepository( Id = msg.ID.Value, UserId = msg.Author.ID.Value, ChannelId = msg.ChannelID.Value, - GuildId = msg.GuildID.Map(s => s.Value).OrDefault(), + GuildId = guildId.IsDefined(out var guildIdValue) + ? guildIdValue.Value + : (ulong?)null, Content = content, Username = username, Metadata = metadata, diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 7f948fc..6fbf6b3 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -66,8 +66,7 @@ builder | GatewayIntents.GuildMessages | GatewayIntents.GuildWebhooks | GatewayIntents.MessageContents - // Actually GUILD_EXPRESSIONS - | GatewayIntents.GuildEmojisAndStickers; + | GatewayIntents.GuildExpressions; // Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService. g.Presence = new UpdatePresence( @@ -97,12 +96,7 @@ builder .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() - .WithCommandGroup() - .WithCommandGroup() - .WithCommandGroup() .WithCommandGroup() - .WithCommandGroup() - .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() // End command tree diff --git a/Catalogger.Backend/Services/TimeoutService.cs b/Catalogger.Backend/Services/TimeoutService.cs index 6615f43..03d1831 100644 --- a/Catalogger.Backend/Services/TimeoutService.cs +++ b/Catalogger.Backend/Services/TimeoutService.cs @@ -25,7 +25,8 @@ public class TimeoutService( _logger.Information("Populating timeout service with existing database timeouts"); await using var scope = serviceProvider.CreateAsyncScope(); - var timeoutRepository = scope.ServiceProvider.GetRequiredService(); + await using var timeoutRepository = + scope.ServiceProvider.GetRequiredService(); var timeouts = await timeoutRepository.GetAllAsync(); foreach (var timeout in timeouts) @@ -53,8 +54,10 @@ public class TimeoutService( _logger.Information("Sending timeout log for {TimeoutId}", timeoutId); await using var scope = serviceProvider.CreateAsyncScope(); - var guildRepository = scope.ServiceProvider.GetRequiredService(); - var timeoutRepository = scope.ServiceProvider.GetRequiredService(); + await using var guildRepository = + scope.ServiceProvider.GetRequiredService(); + await using var timeoutRepository = + scope.ServiceProvider.GetRequiredService(); var timeout = await timeoutRepository.RemoveAsync(timeoutId); if (timeout == null) diff --git a/Catalogger.Backend/Services/WebhookExecutorService.cs b/Catalogger.Backend/Services/WebhookExecutorService.cs index 6b73cb4..8ca8cb3 100644 --- a/Catalogger.Backend/Services/WebhookExecutorService.cs +++ b/Catalogger.Backend/Services/WebhookExecutorService.cs @@ -43,7 +43,7 @@ public class WebhookExecutorService( private readonly ILogger _logger = logger.ForContext(); private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId); private readonly ConcurrentDictionary> _cache = new(); - private readonly ConcurrentDictionary _locks = new(); + private readonly ConcurrentDictionary _locks = new(); private readonly ConcurrentDictionary _timers = new(); private IUser? _selfUser; @@ -189,7 +189,7 @@ public class WebhookExecutorService( private List TakeFromQueue(ulong channelId) { var queue = _cache.GetOrAdd(channelId, []); - var channelLock = _locks.GetOrAdd(channelId, channelId); + var channelLock = _locks.GetOrAdd(channelId, new Lock()); lock (channelLock) { var totalContentLength = 0; @@ -293,10 +293,10 @@ public class WebhookExecutorService( roleIds != null && logChannelType is LogChannelType.GuildMemberUpdate; if (isMessageLog) - return GetMessageLogChannel(guild, logChannelType, channelId, userId); + return GetLogChannelForMessageEvent(guild, logChannelType, channelId, userId); if (isChannelLog) - return GetChannelLogChannel(guild, logChannelType, channelId!.Value); + return GetLogChannelForChannelEvent(guild, logChannelType, channelId!.Value); if (isRoleLog && guild.IgnoredRoles.Contains(roleId!.Value.Value)) return null; @@ -305,77 +305,11 @@ public class WebhookExecutorService( if (isMemberRoleUpdateLog && roleIds!.All(r => guild.IgnoredRoles.Contains(r.Value))) return null; - // If nothing is ignored, return the correct log channel! + // If nothing is ignored, and this isn't a message or channel event, return the default log channel. return GetDefaultLogChannel(guild, logChannelType); } - private ulong? GetChannelLogChannel( - Guild guild, - LogChannelType logChannelType, - Snowflake channelId - ) - { - _logger.Verbose( - "Getting log channel for event {Event} in guild {GuildId} and channel {ChannelId}", - logChannelType, - guild.Id, - channelId - ); - - if (!channelCache.TryGet(channelId, out var channel)) - { - _logger.Verbose( - "Channel with ID {ChannelId} is not cached, returning default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - Snowflake? categoryId; - if ( - channel.Type - is ChannelType.AnnouncementThread - or ChannelType.PrivateThread - or ChannelType.PublicThread - ) - { - // parent_id should always have a value for threads - channelId = channel.ParentID.Value!.Value; - if (!channelCache.TryGet(channelId, out var parentChannel)) - { - _logger.Verbose( - "Parent channel for thread {ChannelId} is not in cache, returning the default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - categoryId = parentChannel.ParentID.Value; - } - else - { - channelId = channel.ID; - categoryId = channel.ParentID.Value; - } - - // Check if the channel or its category is ignored - if ( - guild.IgnoredChannels.Contains(channelId.Value) - || (categoryId != null && guild.IgnoredChannels.Contains(categoryId.Value.Value)) - ) - { - _logger.Verbose( - "Channel {ChannelId} or its parent {CategoryId} is ignored", - channelId, - categoryId - ); - return null; - } - - _logger.Verbose("Returning default log channel for {EventType}", logChannelType); - return GetDefaultLogChannel(guild, logChannelType); - } - - private ulong? GetMessageLogChannel( + private ulong? GetLogChannelForMessageEvent( Guild guild, LogChannelType logChannelType, Snowflake? channelId = null, @@ -415,41 +349,24 @@ public class WebhookExecutorService( return GetDefaultLogChannel(guild, logChannelType); } - Snowflake? categoryId; - if ( - channel.Type - is ChannelType.AnnouncementThread - or ChannelType.PrivateThread - or ChannelType.PublicThread - ) + if (!GetChannelAndParentId(channel, out var actualChannelId, out var categoryId)) { - // parent_id should always have a value for threads - channelId = channel.ParentID.Value!.Value; - if (!channelCache.TryGet(channelId.Value, out var parentChannel)) - { - _logger.Verbose( - "Parent channel for thread {ChannelId} is not in cache, returning the default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - categoryId = parentChannel.ParentID.Value; - } - else - { - channelId = channel.ID; - categoryId = channel.ParentID.Value; + _logger.Verbose( + "Could not get root channel and category ID for channel {ChannelId}, returning default log channel", + channelId + ); + return GetDefaultLogChannel(guild, logChannelType); } // Check if the channel or its category is ignored if ( - guild.Messages.IgnoredChannels.Contains(channelId.Value.Value) + guild.Messages.IgnoredChannels.Contains(actualChannelId.Value) || categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value) ) { _logger.Verbose( "Channel {ChannelId} or its parent {CategoryId} is ignored", - channelId, + actualChannelId, categoryId ); return null; @@ -459,8 +376,10 @@ public class WebhookExecutorService( { // Check the channel-local and category-local ignored users var channelIgnoredUsers = - guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value) + guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(actualChannelId.Value) ?? []; + + // Obviously, we can only check for category-level ignored users if we actually got a category ID. var categoryIgnoredUsers = ( categoryId != null @@ -469,6 +388,8 @@ public class WebhookExecutorService( ) : [] ) ?? []; + + // Combine the ignored users in the channel and category, then check if the user is in there. if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value)) { _logger.Verbose( @@ -482,7 +403,7 @@ public class WebhookExecutorService( } // These three events can be redirected to other channels. Redirects can be on a channel or category level. - // The events are only redirected if they're supposed to be logged in the first place. + // The events are only redirected if they're supposed to be logged in the first place (i.e. GetDefaultLogChannel doesn't return 0) if (GetDefaultLogChannel(guild, logChannelType) == 0) { _logger.Verbose( @@ -492,21 +413,21 @@ public class WebhookExecutorService( return null; } - var categoryRedirect = - categoryId != null - ? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value) - : 0; - - if (guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect)) + if (guild.Channels.Redirects.TryGetValue(actualChannelId.Value, out var channelRedirect)) { _logger.Verbose( "Messages from channel {ChannelId} should be redirected to {RedirectId}", - channelId, + actualChannelId, channelRedirect ); return channelRedirect; } + var categoryRedirect = + categoryId != null + ? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value) + : 0; + if (categoryRedirect != 0) { _logger.Verbose( @@ -514,6 +435,7 @@ public class WebhookExecutorService( categoryId, categoryRedirect ); + return categoryRedirect; } _logger.Verbose( @@ -523,6 +445,92 @@ public class WebhookExecutorService( return GetDefaultLogChannel(guild, logChannelType); } + private ulong? GetLogChannelForChannelEvent( + Guild guild, + LogChannelType logChannelType, + Snowflake channelId + ) + { + _logger.Verbose( + "Getting log channel for event {Event} in guild {GuildId} and channel {ChannelId}", + logChannelType, + guild.Id, + channelId + ); + + if (!channelCache.TryGet(channelId, out var channel)) + { + _logger.Verbose( + "Channel with ID {ChannelId} is not cached, returning default log channel", + channelId + ); + return GetDefaultLogChannel(guild, logChannelType); + } + + if (!GetChannelAndParentId(channel, out channelId, out var categoryId)) + { + _logger.Verbose( + "Could not get root channel and category ID for channel {ChannelId}, returning default log channel", + channelId + ); + return GetDefaultLogChannel(guild, logChannelType); + } + + // Check if the channel or its category is ignored + if ( + guild.IgnoredChannels.Contains(channelId.Value) + || (categoryId != null && guild.IgnoredChannels.Contains(categoryId.Value.Value)) + ) + { + _logger.Verbose( + "Channel {ChannelId} or its parent {CategoryId} is ignored", + channelId, + categoryId + ); + return null; + } + + _logger.Verbose("Returning default log channel for {EventType}", logChannelType); + return GetDefaultLogChannel(guild, logChannelType); + } + + private bool GetChannelAndParentId( + IChannel channel, + out Snowflake channelId, + out Snowflake? categoryId + ) + { + if ( + channel.Type + is ChannelType.AnnouncementThread + or ChannelType.PrivateThread + or ChannelType.PublicThread + ) + { + // parent_id should always have a value for threads + channelId = channel.ParentID.Value!.Value; + if (!channelCache.TryGet(channelId, out var parentChannel)) + { + _logger.Verbose( + "Parent channel for thread {ChannelId} is not in cache, returning the default log channel", + channelId + ); + + channelId = Snowflake.CreateTimestampSnowflake(); + categoryId = null; + return false; + } + categoryId = parentChannel.ParentID.Value; + } + else + { + channelId = channel.ID; + categoryId = channel.ParentID.Value; + } + + return true; + } + public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) => logChannelType switch { diff --git a/Catalogger.Backend/config.example.ini b/Catalogger.Backend/config.example.ini index 9b74c55..3f12c79 100644 --- a/Catalogger.Backend/config.example.ini +++ b/Catalogger.Backend/config.example.ini @@ -7,6 +7,9 @@ LogQueries = false SeqLogUrl = http://localhost:5341 # Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often. EnableMetrics = false +# The URL for the Prometheus server. Used for message rate if metrics are enabled. +# Defaults to http://localhost:9090, should be changed if Prometheus is on another server. +PrometheusUrl = http://localhost:9090 [Database] Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres diff --git a/README.md b/README.md index 9b9603c..e33918a 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,6 @@ Command-line tools for this project can be installed with `dotnet tool restore`. - We use [CSharpier][csharpier] for formatting .NET code. It can be called with `dotnet csharpier .`, but is automatically run by Husky pre-commit. -### Nuget - -We currently use Remora's GitHub packages as the releases on nuget.org are missing some key features. -Add these with `dotnet nuget add source --username --password --store-password-in-clear-text --name Remora "https://nuget.pkg.github.com/Remora/index.json"` - -You must generate a personal access token (classic) [here](personal-access-token). Only give it the `read:packages` permission. - ## Deploying Catalogger yourself The bot itself should run on any server with .NET 8 and PostgreSQL 15 or later.