Compare commits

..

8 commits

27 changed files with 287 additions and 229 deletions

View file

@ -3,14 +3,14 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"csharpier": { "csharpier": {
"version": "0.30.1", "version": "0.30.6",
"commands": [ "commands": [
"dotnet-csharpier" "dotnet-csharpier"
], ],
"rollForward": false "rollForward": false
}, },
"husky": { "husky": {
"version": "0.7.1", "version": "0.7.2",
"commands": [ "commands": [
"husky" "husky"
], ],

View file

@ -43,6 +43,7 @@ public class ChannelCommands(
Config config, Config config,
GuildRepository guildRepository, GuildRepository guildRepository,
GuildCache guildCache, GuildCache guildCache,
GuildFetchService guildFetchService,
ChannelCache channelCache, ChannelCache channelCache,
IMemberCache memberCache, IMemberCache memberCache,
IFeedbackService feedbackService, IFeedbackService feedbackService,
@ -68,8 +69,11 @@ public class ChannelCommands(
public async Task<IResult> CheckPermissionsAsync() public async Task<IResult> CheckPermissionsAsync()
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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}"); var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}");
@ -78,8 +82,18 @@ public class ChannelCommands(
DiscordSnowflake.New(config.Discord.ApplicationId) DiscordSnowflake.New(config.Discord.ApplicationId)
); );
var currentUser = await memberCache.TryGetAsync(guildId, userId); var currentUser = await memberCache.TryGetAsync(guildId, userId);
if (botUser == null || currentUser == null) 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 // We don't want to check categories or threads
var guildChannels = channelCache var guildChannels = channelCache
@ -204,7 +218,7 @@ public class ChannelCommands(
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);

View file

@ -50,15 +50,15 @@ public class ChannelCommandsComponents(
public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<string> values) public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<string> values)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) if (contextInjection.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); return CataloggerError.Result("No context");
if (!ctx.TryGetUserID(out var userId)) 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)) 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)) 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)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
@ -76,7 +76,7 @@ public class ChannelCommandsComponents(
var state = values[0]; var state = values[0];
if (!Enum.TryParse<LogChannelType>(state, out var logChannelType)) if (!Enum.TryParse<LogChannelType>(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); var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType);
string? channelMention; string? channelMention;
@ -147,15 +147,15 @@ public class ChannelCommandsComponents(
public async Task<Result> OnButtonPressedAsync(string state) public async Task<Result> OnButtonPressedAsync(string state)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) if (contextInjection.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); return CataloggerError.Result("No context");
if (!ctx.TryGetUserID(out var userId)) 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)) 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)) 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)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
@ -179,9 +179,9 @@ public class ChannelCommandsComponents(
); );
case "reset": case "reset":
if (lease.Data.CurrentPage == null) 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<LogChannelType>(lease.Data.CurrentPage, out var channelType)) if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType))
throw new CataloggerError( return CataloggerError.Result(
$"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'" $"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'"
); );
@ -281,15 +281,15 @@ public class ChannelCommandsComponents(
public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels) public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) if (contextInjection.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); return CataloggerError.Result("No context");
if (!ctx.TryGetUserID(out var userId)) 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)) 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)) 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)) 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 guildConfig = await guildRepository.GetAsync(guildId);
var channelId = channels[0].ID.ToUlong(); var channelId = channels[0].ID.ToUlong();
@ -305,7 +305,7 @@ public class ChannelCommandsComponents(
} }
if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType)) if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType))
throw new CataloggerError( return CataloggerError.Result(
$"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'" $"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'"
); );

View file

@ -98,7 +98,7 @@ public class IgnoreEntitiesCommands : CommandGroup
{ {
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildConfig = await guildRepository.GetAsync(guildId);
@ -201,14 +201,14 @@ public class IgnoreEntitiesCommands : CommandGroup
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
var member = await memberCache.TryGetAsync(guildId, userId); var member = await memberCache.TryGetAsync(guildId, userId);
if (member == null) if (member == null)
throw new CataloggerError("Executing member not found"); return CataloggerError.Result("Executing member not found");
var ignoredChannels = guildConfig var ignoredChannels = guildConfig
.IgnoredChannels.Select(id => .IgnoredChannels.Select(id =>

View file

@ -110,14 +110,14 @@ public partial class IgnoreMessageCommands : CommandGroup
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
var member = await memberCache.TryGetAsync(guildId, userId); var member = await memberCache.TryGetAsync(guildId, userId);
if (member == null) if (member == null)
throw new CataloggerError("Executing member not found"); return CataloggerError.Result("Executing member not found");
var ignoredChannels = guildConfig var ignoredChannels = guildConfig
.Messages.IgnoredChannels.Select(id => .Messages.IgnoredChannels.Select(id =>

View file

@ -90,7 +90,7 @@ public partial class IgnoreMessageCommands
{ {
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildConfig = await guildRepository.GetAsync(guildId);

View file

@ -94,7 +94,7 @@ public partial class IgnoreMessageCommands
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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); var guildConfig = await guildRepository.GetAsync(guildId);

View file

@ -59,7 +59,7 @@ public class InviteCommands(
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow();
if (!guildCache.TryGet(guildId, out var guild)) 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); var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId);

View file

@ -45,7 +45,7 @@ public class KeyRoleCommands(
{ {
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildRoles = roleCache.GuildRoles(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
@ -85,7 +85,7 @@ public class KeyRoleCommands(
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId);
if (role == null) if (role == null)
throw new CataloggerError("Role is not cached"); return CataloggerError.Result("Role is not cached");
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.KeyRoles.Any(id => role.ID.Value == id)) if (guildConfig.KeyRoles.Any(id => role.ID.Value == id))
@ -111,7 +111,7 @@ public class KeyRoleCommands(
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId);
if (role == null) if (role == null)
throw new CataloggerError("Role is not cached"); return CataloggerError.Result("Role is not cached");
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.KeyRoles.All(id => role.ID != id)) if (guildConfig.KeyRoles.All(id => role.ID != id))

View file

@ -218,7 +218,7 @@ public class MetaCommands(
await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); 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<double?> MessagesRate() private async Task<double?> MessagesRate()
{ {
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
@ -227,7 +227,8 @@ public class MetaCommands(
try try
{ {
var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); 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(); resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync<PrometheusResponse>(); var data = await resp.Content.ReadFromJsonAsync<PrometheusResponse>();

View file

@ -141,7 +141,7 @@ public class RedirectCommands(
{ {
var (userId, guildId) = contextInjectionService.GetUserAndGuild(); var (userId, guildId) = contextInjectionService.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);

View file

@ -83,7 +83,7 @@ public class WatchlistCommands(
{ {
var (userId, guildId) = contextInjectionService.GetUserAndGuild(); var (userId, guildId) = contextInjectionService.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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); var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId);
if (watchlist.Count == 0) if (watchlist.Count == 0)

View file

@ -19,17 +19,21 @@ using Remora.Commands.Services;
using Remora.Commands.Tokenization; using Remora.Commands.Tokenization;
using Remora.Commands.Trees; using Remora.Commands.Trees;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Responders; using Remora.Discord.Commands.Responders;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using Serilog.Context; using Serilog.Context;
namespace Catalogger.Backend.Bot.Responders; namespace Catalogger.Backend.Bot.Responders;
/// <summary> /// <summary>
/// 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 <see cref="CataloggerError" /> results returned by commands.
/// </summary> /// </summary>
public class CustomInteractionResponder( public class CustomInteractionResponder(
Config config, Config config,
@ -87,6 +91,38 @@ public class CustomInteractionResponder(
true 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<OneOf.OneOf<
IInteractionMessageCallbackData,
IInteractionAutocompleteCallbackData,
IInteractionModalCallbackData
>>(
new InteractionMessageCallbackData(
Embeds: new Optional<IReadOnlyList<IEmbed>>(
[
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
);
} }
} }

View file

@ -77,7 +77,7 @@ public class MessageCreateResponder(
return Result.Success; return Result.Success;
} }
await messageRepository.SaveMessageAsync(msg, ct); await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct);
return Result.Success; return Result.Success;
} }
} }
@ -144,6 +144,19 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
await using var messageRepository = await using var messageRepository =
scope.ServiceProvider.GetRequiredService<MessageRepository>(); scope.ServiceProvider.GetRequiredService<MessageRepository>();
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( _logger.Debug(
"Setting proxy data for {MessageId} and ignoring {OriginalId}", "Setting proxy data for {MessageId} and ignoring {OriginalId}",
msgId, msgId,
@ -195,6 +208,19 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
pkMessage.Original 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( await messageRepository.SetProxiedMessageDataAsync(
msgId, msgId,
pkMessage.Original, pkMessage.Original,

View file

@ -18,7 +18,6 @@ using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Humanizer; using Humanizer;
using NodaTime;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -27,7 +26,6 @@ using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core; using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using Serilog.Context;
namespace Catalogger.Backend.Bot.Responders.Messages; namespace Catalogger.Backend.Bot.Responders.Messages;
@ -38,7 +36,6 @@ public class MessageDeleteResponder(
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
ChannelCache channelCache, ChannelCache channelCache,
UserCache userCache, UserCache userCache,
IClock clock,
PluralkitApiService pluralkitApi PluralkitApiService pluralkitApi
) : IResponder<IMessageDelete> ) : IResponder<IMessageDelete>
{ {
@ -81,8 +78,8 @@ public class MessageDeleteResponder(
new Embed( new Embed(
Title: "Message deleted", Title: "Message deleted",
Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).", Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).",
Footer: new EmbedFooter(Text: $"ID: {evt.ID}"), Footer: new EmbedFooter(Text: $"ID: {evt.ID} | Original sent at"),
Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() Timestamp: evt.ID.Timestamp
) )
); );
@ -124,7 +121,7 @@ public class MessageDeleteResponder(
.WithTitle("Message deleted") .WithTitle("Message deleted")
.WithDescription(msg.Content) .WithDescription(msg.Content)
.WithColour(DiscordUtils.Red) .WithColour(DiscordUtils.Red)
.WithFooter($"ID: {msg.Id}") .WithFooter($"ID: {msg.Id} | Original sent at")
.WithTimestamp(evt.ID); .WithTimestamp(evt.ID);
if (user != null) if (user != null)

View file

@ -20,7 +20,6 @@ using Catalogger.Backend.Services;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Gateway.Events;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
@ -40,13 +39,9 @@ public class MessageUpdateResponder(
{ {
private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>();
public async Task<Result> RespondAsync(IMessageUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageUpdate msg, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt); using var _ = LogUtils.Enrich(msg);
// 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);
if (!msg.GuildID.IsDefined()) if (!msg.GuildID.IsDefined())
{ {
@ -134,7 +129,7 @@ public class MessageUpdateResponder(
if (oldMessage is { System: not null, Member: not null }) if (oldMessage is { System: not null, Member: not null })
{ {
embedBuilder.WithTitle($"Message by {msg.Author.Username} edited"); 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("System ID", oldMessage.System, true);
embedBuilder.AddField("Member ID", oldMessage.Member, true); embedBuilder.AddField("Member ID", oldMessage.Member, true);
} }
@ -174,7 +169,7 @@ public class MessageUpdateResponder(
) )
{ {
if ( if (
!await messageRepository.SaveMessageAsync(msg, ct) !await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct)
&& msg.ApplicationID.Is(DiscordUtils.PkUserId) && 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<string> ChunksUpTo(string str, int maxChunkSize) private static IEnumerable<string> ChunksUpTo(string str, int maxChunkSize)
{ {
for (var i = 0; i < str.Length; i += maxChunkSize) for (var i = 0; i < str.Length; i += maxChunkSize)

View file

@ -234,6 +234,7 @@ internal record RedisMember(
User.ToRemoraUser(), User.ToRemoraUser(),
Nickname, Nickname,
Avatar != null ? new ImageHash(Avatar) : null, Avatar != null ? new ImageHash(Avatar) : null,
Banner: null,
Roles.Select(DiscordSnowflake.New).ToList(), Roles.Select(DiscordSnowflake.New).ToList(),
JoinedAt, JoinedAt,
PremiumSince, PremiumSince,

View file

@ -21,18 +21,18 @@
<PackageReference Include="Humanizer.Core" Version="2.14.1"/> <PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="LazyCache" Version="2.4.0"/> <PackageReference Include="LazyCache" Version="2.4.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.2.0" /> <PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0"/>
<PackageReference Include="Npgsql" Version="9.0.0" /> <PackageReference Include="Npgsql" Version="9.0.0" />
<PackageReference Include="Npgsql.NodaTime" Version="9.0.0" /> <PackageReference Include="Npgsql.NodaTime" Version="9.0.0" />
<PackageReference Include="Polly.Core" Version="8.5.0" /> <PackageReference Include="Polly.Core" Version="8.5.2" />
<PackageReference Include="Polly.RateLimiting" Version="8.5.0" /> <PackageReference Include="Polly.RateLimiting" Version="8.5.0" />
<PackageReference Include="prometheus-net" Version="8.2.1"/> <PackageReference Include="prometheus-net" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Remora.Sdk" Version="3.1.2"/> <PackageReference Include="Remora.Sdk" Version="3.1.2"/>
<PackageReference Include="Remora.Discord" Version="2024.3.0-github11168366508"/> <PackageReference Include="Remora.Discord" Version="2025.1.0" />
<PackageReference Include="Serilog" Version="4.1.0" /> <PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/> <PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/>

View file

@ -13,6 +13,13 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Remora.Results;
using RemoraResult = Remora.Results.Result;
namespace Catalogger.Backend; 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));
}

View file

@ -33,6 +33,7 @@ public class Config
public bool EnableMetrics { get; init; } = true; public bool EnableMetrics { get; init; } = true;
public string? SeqLogUrl { get; init; } public string? SeqLogUrl { get; init; }
public string? PrometheusUrl { get; init; }
} }
public class DatabaseConfig public class DatabaseConfig

View file

@ -17,6 +17,7 @@ using System.Data;
using System.Data.Common; using System.Data.Common;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Npgsql; using Npgsql;
using Serilog;
namespace Catalogger.Backend.Database; namespace Catalogger.Backend.Database;
@ -49,11 +50,18 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
public new void Dispose() public new void Dispose()
{ {
Close(); Dispose(true);
inner.Dispose();
GC.SuppressFinalize(this); 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() public override async ValueTask DisposeAsync()
{ {
await CloseAsync(); await CloseAsync();
@ -62,13 +70,13 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
} }
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => 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 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] [AllowNull]
public override string ConnectionString public override string ConnectionString
@ -83,4 +91,6 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
public override string ServerVersion => inner.ServerVersion; public override string ServerVersion => inner.ServerVersion;
protected override DbCommand CreateDbCommand() => inner.CreateCommand(); protected override DbCommand CreateDbCommand() => inner.CreateCommand();
public class SyncException(string method) : Exception($"Tried to use sync method {method}");
} }

View file

@ -18,6 +18,7 @@ using Catalogger.Backend.Extensions;
using Dapper; using Dapper;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Database.Repositories; namespace Catalogger.Backend.Database.Repositories;
@ -63,7 +64,11 @@ public class MessageRepository(
/// <summary> /// <summary>
/// Adds a new message. If the message is already in the database, updates the existing message instead. /// Adds a new message. If the message is already in the database, updates the existing message instead.
/// </summary> /// </summary>
public async Task<bool> SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) public async Task<bool> SaveMessageAsync(
IMessage msg,
Optional<Snowflake> guildId,
CancellationToken ct = default
)
{ {
var content = await Task.Run( var content = await Task.Run(
() => () =>
@ -107,7 +112,9 @@ public class MessageRepository(
Id = msg.ID.Value, Id = msg.ID.Value,
UserId = msg.Author.ID.Value, UserId = msg.Author.ID.Value,
ChannelId = msg.ChannelID.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, Content = content,
Username = username, Username = username,
Metadata = metadata, Metadata = metadata,

View file

@ -66,8 +66,7 @@ builder
| GatewayIntents.GuildMessages | GatewayIntents.GuildMessages
| GatewayIntents.GuildWebhooks | GatewayIntents.GuildWebhooks
| GatewayIntents.MessageContents | GatewayIntents.MessageContents
// Actually GUILD_EXPRESSIONS | GatewayIntents.GuildExpressions;
| GatewayIntents.GuildEmojisAndStickers;
// Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService. // Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService.
g.Presence = new UpdatePresence( g.Presence = new UpdatePresence(
@ -97,12 +96,7 @@ builder
.WithCommandGroup<KeyRoleCommands>() .WithCommandGroup<KeyRoleCommands>()
.WithCommandGroup<InviteCommands>() .WithCommandGroup<InviteCommands>()
.WithCommandGroup<IgnoreMessageCommands>() .WithCommandGroup<IgnoreMessageCommands>()
.WithCommandGroup<IgnoreMessageCommands.Channels>()
.WithCommandGroup<IgnoreMessageCommands.Users>()
.WithCommandGroup<IgnoreMessageCommands.Roles>()
.WithCommandGroup<IgnoreEntitiesCommands>() .WithCommandGroup<IgnoreEntitiesCommands>()
.WithCommandGroup<IgnoreEntitiesCommands.Channels>()
.WithCommandGroup<IgnoreEntitiesCommands.Roles>()
.WithCommandGroup<RedirectCommands>() .WithCommandGroup<RedirectCommands>()
.WithCommandGroup<WatchlistCommands>() .WithCommandGroup<WatchlistCommands>()
// End command tree // End command tree

View file

@ -25,7 +25,8 @@ public class TimeoutService(
_logger.Information("Populating timeout service with existing database timeouts"); _logger.Information("Populating timeout service with existing database timeouts");
await using var scope = serviceProvider.CreateAsyncScope(); await using var scope = serviceProvider.CreateAsyncScope();
var timeoutRepository = scope.ServiceProvider.GetRequiredService<TimeoutRepository>(); await using var timeoutRepository =
scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
var timeouts = await timeoutRepository.GetAllAsync(); var timeouts = await timeoutRepository.GetAllAsync();
foreach (var timeout in timeouts) foreach (var timeout in timeouts)
@ -53,8 +54,10 @@ public class TimeoutService(
_logger.Information("Sending timeout log for {TimeoutId}", timeoutId); _logger.Information("Sending timeout log for {TimeoutId}", timeoutId);
await using var scope = serviceProvider.CreateAsyncScope(); await using var scope = serviceProvider.CreateAsyncScope();
var guildRepository = scope.ServiceProvider.GetRequiredService<GuildRepository>(); await using var guildRepository =
var timeoutRepository = scope.ServiceProvider.GetRequiredService<TimeoutRepository>(); scope.ServiceProvider.GetRequiredService<GuildRepository>();
await using var timeoutRepository =
scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
var timeout = await timeoutRepository.RemoveAsync(timeoutId); var timeout = await timeoutRepository.RemoveAsync(timeoutId);
if (timeout == null) if (timeout == null)

View file

@ -43,7 +43,7 @@ public class WebhookExecutorService(
private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>(); private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>();
private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId); private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId);
private readonly ConcurrentDictionary<ulong, ConcurrentQueue<IEmbed>> _cache = new(); private readonly ConcurrentDictionary<ulong, ConcurrentQueue<IEmbed>> _cache = new();
private readonly ConcurrentDictionary<ulong, object> _locks = new(); private readonly ConcurrentDictionary<ulong, Lock> _locks = new();
private readonly ConcurrentDictionary<ulong, Timer> _timers = new(); private readonly ConcurrentDictionary<ulong, Timer> _timers = new();
private IUser? _selfUser; private IUser? _selfUser;
@ -189,7 +189,7 @@ public class WebhookExecutorService(
private List<IEmbed> TakeFromQueue(ulong channelId) private List<IEmbed> TakeFromQueue(ulong channelId)
{ {
var queue = _cache.GetOrAdd(channelId, []); var queue = _cache.GetOrAdd(channelId, []);
var channelLock = _locks.GetOrAdd(channelId, channelId); var channelLock = _locks.GetOrAdd(channelId, new Lock());
lock (channelLock) lock (channelLock)
{ {
var totalContentLength = 0; var totalContentLength = 0;
@ -293,10 +293,10 @@ public class WebhookExecutorService(
roleIds != null && logChannelType is LogChannelType.GuildMemberUpdate; roleIds != null && logChannelType is LogChannelType.GuildMemberUpdate;
if (isMessageLog) if (isMessageLog)
return GetMessageLogChannel(guild, logChannelType, channelId, userId); return GetLogChannelForMessageEvent(guild, logChannelType, channelId, userId);
if (isChannelLog) if (isChannelLog)
return GetChannelLogChannel(guild, logChannelType, channelId!.Value); return GetLogChannelForChannelEvent(guild, logChannelType, channelId!.Value);
if (isRoleLog && guild.IgnoredRoles.Contains(roleId!.Value.Value)) if (isRoleLog && guild.IgnoredRoles.Contains(roleId!.Value.Value))
return null; return null;
@ -305,77 +305,11 @@ public class WebhookExecutorService(
if (isMemberRoleUpdateLog && roleIds!.All(r => guild.IgnoredRoles.Contains(r.Value))) if (isMemberRoleUpdateLog && roleIds!.All(r => guild.IgnoredRoles.Contains(r.Value)))
return null; 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); return GetDefaultLogChannel(guild, logChannelType);
} }
private ulong? GetChannelLogChannel( private ulong? GetLogChannelForMessageEvent(
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(
Guild guild, Guild guild,
LogChannelType logChannelType, LogChannelType logChannelType,
Snowflake? channelId = null, Snowflake? channelId = null,
@ -415,41 +349,24 @@ public class WebhookExecutorService(
return GetDefaultLogChannel(guild, logChannelType); return GetDefaultLogChannel(guild, logChannelType);
} }
Snowflake? categoryId; if (!GetChannelAndParentId(channel, out var actualChannelId, out var 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.Value, out var parentChannel))
{ {
_logger.Verbose( _logger.Verbose(
"Parent channel for thread {ChannelId} is not in cache, returning the default log channel", "Could not get root channel and category ID for channel {ChannelId}, returning default log channel",
channelId channelId
); );
return GetDefaultLogChannel(guild, logChannelType); 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 // Check if the channel or its category is ignored
if ( if (
guild.Messages.IgnoredChannels.Contains(channelId.Value.Value) guild.Messages.IgnoredChannels.Contains(actualChannelId.Value)
|| categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value) || categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value)
) )
{ {
_logger.Verbose( _logger.Verbose(
"Channel {ChannelId} or its parent {CategoryId} is ignored", "Channel {ChannelId} or its parent {CategoryId} is ignored",
channelId, actualChannelId,
categoryId categoryId
); );
return null; return null;
@ -459,8 +376,10 @@ public class WebhookExecutorService(
{ {
// Check the channel-local and category-local ignored users // Check the channel-local and category-local ignored users
var channelIgnoredUsers = 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 = var categoryIgnoredUsers =
( (
categoryId != null 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)) if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value))
{ {
_logger.Verbose( _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. // 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) if (GetDefaultLogChannel(guild, logChannelType) == 0)
{ {
_logger.Verbose( _logger.Verbose(
@ -492,21 +413,21 @@ public class WebhookExecutorService(
return null; return null;
} }
var categoryRedirect = if (guild.Channels.Redirects.TryGetValue(actualChannelId.Value, out var channelRedirect))
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0;
if (guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect))
{ {
_logger.Verbose( _logger.Verbose(
"Messages from channel {ChannelId} should be redirected to {RedirectId}", "Messages from channel {ChannelId} should be redirected to {RedirectId}",
channelId, actualChannelId,
channelRedirect channelRedirect
); );
return channelRedirect; return channelRedirect;
} }
var categoryRedirect =
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0;
if (categoryRedirect != 0) if (categoryRedirect != 0)
{ {
_logger.Verbose( _logger.Verbose(
@ -514,6 +435,7 @@ public class WebhookExecutorService(
categoryId, categoryId,
categoryRedirect categoryRedirect
); );
return categoryRedirect;
} }
_logger.Verbose( _logger.Verbose(
@ -523,6 +445,92 @@ public class WebhookExecutorService(
return GetDefaultLogChannel(guild, logChannelType); 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) => public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) =>
logChannelType switch logChannelType switch
{ {

View file

@ -7,6 +7,9 @@ LogQueries = false
SeqLogUrl = http://localhost:5341 SeqLogUrl = http://localhost:5341
# Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often. # Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often.
EnableMetrics = false 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] [Database]
Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres

View file

@ -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. - We use [CSharpier][csharpier] for formatting .NET code.
It can be called with `dotnet csharpier .`, but is automatically run by Husky pre-commit. 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 <githubUsername> --password <githubToken> --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 ## Deploying Catalogger yourself
The bot itself should run on any server with .NET 8 and PostgreSQL 15 or later. The bot itself should run on any server with .NET 8 and PostgreSQL 15 or later.