Compare commits

..

No commits in common. "main" and "v2024.11.4" have entirely different histories.

58 changed files with 959 additions and 637 deletions

View file

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

View file

@ -43,7 +43,6 @@ 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,
@ -69,11 +68,8 @@ 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}");
@ -82,18 +78,8 @@ 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
@ -218,7 +204,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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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)
return CataloggerError.Result("No context"); throw new CataloggerError("No context");
if (!ctx.TryGetUserID(out var userId)) if (!ctx.TryGetUserID(out var userId))
return CataloggerError.Result("No user ID in context"); throw new CataloggerError("No user ID in context");
if (!ctx.Interaction.Message.TryGet(out var msg)) if (!ctx.Interaction.Message.TryGet(out var msg))
return CataloggerError.Result("No message ID in context"); throw new CataloggerError("No message ID in context");
if (!ctx.TryGetGuildID(out var guildId)) if (!ctx.TryGetGuildID(out var guildId))
return CataloggerError.Result("No guild ID in context"); throw new CataloggerError("No guild ID in context");
if (!guildCache.TryGet(guildId, out var guild)) if (!guildCache.TryGet(guildId, out var guild))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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))
return CataloggerError.Result($"Invalid config-channels state {state}"); throw new CataloggerError($"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)
return CataloggerError.Result("No context"); throw new CataloggerError("No context");
if (!ctx.TryGetUserID(out var userId)) if (!ctx.TryGetUserID(out var userId))
return CataloggerError.Result("No user ID in context"); throw new CataloggerError("No user ID in context");
if (!ctx.Interaction.Message.TryGet(out var msg)) if (!ctx.Interaction.Message.TryGet(out var msg))
return CataloggerError.Result("No message ID in context"); throw new CataloggerError("No message ID in context");
if (!ctx.TryGetGuildID(out var guildId)) if (!ctx.TryGetGuildID(out var guildId))
return CataloggerError.Result("No guild ID in context"); throw new CataloggerError("No guild ID in context");
if (!guildCache.TryGet(guildId, out var guild)) if (!guildCache.TryGet(guildId, out var guild))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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)
return CataloggerError.Result("CurrentPage was null in reset button callback"); throw new CataloggerError("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))
return CataloggerError.Result( throw new CataloggerError(
$"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)
return CataloggerError.Result("No context"); throw new CataloggerError("No context");
if (!ctx.TryGetUserID(out var userId)) if (!ctx.TryGetUserID(out var userId))
return CataloggerError.Result("No user ID in context"); throw new CataloggerError("No user ID in context");
if (!ctx.Interaction.Message.TryGet(out var msg)) if (!ctx.Interaction.Message.TryGet(out var msg))
return CataloggerError.Result("No message ID in context"); throw new CataloggerError("No message ID in context");
if (!ctx.TryGetGuildID(out var guildId)) if (!ctx.TryGetGuildID(out var guildId))
return CataloggerError.Result("No guild ID in context"); throw new CataloggerError("No guild ID in context");
if (!guildCache.TryGet(guildId, out var guild)) if (!guildCache.TryGet(guildId, out var guild))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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))
return CataloggerError.Result( throw new CataloggerError(
$"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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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)
return CataloggerError.Result("Executing member not found"); throw new CataloggerError("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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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)
return CataloggerError.Result("Executing member not found"); throw new CataloggerError("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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("Guild was not cached");
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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("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)
return CataloggerError.Result("Role is not cached"); throw new CataloggerError("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)
return CataloggerError.Result("Role is not cached"); throw new CataloggerError("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 // TODO: add more checks around response format, configurable prometheus endpoint
private async Task<double?> MessagesRate() private async Task<double?> MessagesRate()
{ {
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
@ -227,8 +227,7 @@ public class MetaCommands(
try try
{ {
var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])");
var prometheusUrl = config.Logging.PrometheusUrl ?? "http://localhost:9090"; var resp = await _client.GetAsync($"http://localhost:9090/api/v1/query?query={query}");
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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("Guild was not cached");
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))
return CataloggerError.Result("Guild not in cache"); throw new CataloggerError("Guild was not cached");
var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId); var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId);
if (watchlist.Count == 0) if (watchlist.Count == 0)

View file

@ -17,6 +17,7 @@ using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Microsoft.Extensions.Logging.Configuration;
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.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
@ -35,8 +36,6 @@ public class ChannelCreateResponder(
{ {
public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(ch);
if (!ch.GuildID.IsDefined()) if (!ch.GuildID.IsDefined())
return Result.Success; return Result.Success;
channelCache.Set(ch); channelCache.Set(ch);

View file

@ -35,8 +35,6 @@ public class ChannelDeleteResponder(
public async Task<Result> RespondAsync(IChannelDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelDelete evt, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(evt);
if (!evt.GuildID.IsDefined()) if (!evt.GuildID.IsDefined())
{ {
_logger.Debug("Deleted channel {ChannelId} is not in a guild", evt.ID); _logger.Debug("Deleted channel {ChannelId} is not in a guild", evt.ID);

View file

@ -40,8 +40,6 @@ public class ChannelUpdateResponder(
public async Task<Result> RespondAsync(IChannelUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!channelCache.TryGet(evt.ID, out var oldChannel)) if (!channelCache.TryGet(evt.ID, out var oldChannel))

View file

@ -13,27 +13,21 @@
// 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 Catalogger.Backend.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Remora.Commands.Services; 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;
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,
@ -63,66 +57,21 @@ public class CustomInteractionResponder(
treeNameResolver treeNameResolver
); );
public async Task<Result> RespondAsync(IInteractionCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(
IInteractionCreate gatewayEvent,
CancellationToken ct = default
)
{ {
if (config.Discord.TestMode) if (config.Discord.TestMode)
{ {
_logger.Information( _logger.Information(
"Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled", "Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled",
evt.ID, gatewayEvent.ID,
evt.Channel.Map(c => c.ID).OrDefault() gatewayEvent.Channel.Map(c => c.ID).OrDefault()
); );
return Result.Success; return Result.Success;
} }
using var _ = LogUtils.PushProperties( return await _inner.RespondAsync(gatewayEvent, ct);
("Event", nameof(IInteractionCreate)),
("InteractionId", evt.ID),
("GuildId", evt.GuildID),
("UserId", evt.User.Map(u => u.ID)),
("MemberId", evt.Member.Map(m => m.User.Map(u => u.ID).OrDefault())),
("ChannelId", evt.Channel.Map(c => c.ID)),
("InteractionType", evt.Type)
);
using var __ = LogContext.PushProperty(
"InteractionData",
evt.Data.HasValue ? (object?)evt.Data.Value : null,
true
);
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

@ -14,7 +14,6 @@
// 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 Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Extensions;
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.Gateway.Responders; using Remora.Discord.Gateway.Responders;
@ -29,8 +28,6 @@ public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger)
public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (evt.TargetID == null || evt.UserID == null) if (evt.TargetID == null || evt.UserID == null)
return Task.FromResult(Result.Success); return Task.FromResult(Result.Success);

View file

@ -37,7 +37,6 @@ public class GuildBanAddResponder(
public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildConfig = await guildRepository.GetAsync(evt.GuildID); var guildConfig = await guildRepository.GetAsync(evt.GuildID);
// Delay 2 seconds for the audit log // Delay 2 seconds for the audit log

View file

@ -37,7 +37,6 @@ public class GuildBanRemoveResponder(
public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildConfig = await guildRepository.GetAsync(evt.GuildID); var guildConfig = await guildRepository.GetAsync(evt.GuildID);
// Delay 2 seconds for the audit log // Delay 2 seconds for the audit log

View file

@ -44,8 +44,6 @@ public class GuildCreateResponder(
public async Task<Result> RespondAsync(IGuildCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
ulong guildId; ulong guildId;
string? guildName = null; string? guildName = null;
if (evt.Guild.TryPickT0(out var guild, out var unavailableGuild)) if (evt.Guild.TryPickT0(out var guild, out var unavailableGuild))
@ -103,8 +101,6 @@ public class GuildCreateResponder(
public async Task<Result> RespondAsync(IGuildDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (evt.IsUnavailable.OrDefault(false)) if (evt.IsUnavailable.OrDefault(false))
{ {
_logger.Debug("Guild {GuildId} became unavailable", evt.ID); _logger.Debug("Guild {GuildId} became unavailable", evt.ID);

View file

@ -37,8 +37,6 @@ public class GuildEmojisUpdateResponder(
public async Task<Result> RespondAsync(IGuildEmojisUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildEmojisUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!emojiCache.TryGet(evt.GuildID, out var oldEmoji)) if (!emojiCache.TryGet(evt.GuildID, out var oldEmoji))

View file

@ -14,7 +14,6 @@
// 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 Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
@ -28,8 +27,6 @@ public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache
public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
_logger.Debug( _logger.Debug(
"Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}", "Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}",
evt.ChunkIndex + 1, evt.ChunkIndex + 1,

View file

@ -37,8 +37,6 @@ public class GuildUpdateResponder(
public async Task<Result> RespondAsync(IGuildUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!guildCache.TryGet(evt.ID, out var oldGuild)) if (!guildCache.TryGet(evt.ID, out var oldGuild))

View file

@ -37,7 +37,6 @@ public class InviteCreateResponder(
public async Task<Result> RespondAsync(IInviteCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IInviteCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildId = evt.GuildID.Value; var guildId = evt.GuildID.Value;
var invitesResult = await guildApi.GetGuildInvitesAsync(guildId, ct); var invitesResult = await guildApi.GetGuildInvitesAsync(guildId, ct);

View file

@ -38,7 +38,6 @@ public class InviteDeleteResponder(
public async Task<Result> RespondAsync(IInviteDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IInviteDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildId = evt.GuildID.Value; var guildId = evt.GuildID.Value;
var dbDeleteCount = await inviteRepository.DeleteInviteAsync(guildId, evt.Code); var dbDeleteCount = await inviteRepository.DeleteInviteAsync(guildId, evt.Code);

View file

@ -48,8 +48,6 @@ public class GuildMemberAddResponder(
public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(member);
await memberCache.SetAsync(member.GuildID, member); await memberCache.SetAsync(member.GuildID, member);
await memberCache.SetMemberNamesAsync(member.GuildID, [member]); await memberCache.SetMemberNamesAsync(member.GuildID, [member]);

View file

@ -39,8 +39,6 @@ public class GuildMemberRemoveResponder(
public async Task<Result> RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
var embed = new EmbedBuilder() var embed = new EmbedBuilder()

View file

@ -48,8 +48,6 @@ public class GuildMemberUpdateResponder(
CancellationToken ct = default CancellationToken ct = default
) )
{ {
using var _ = LogUtils.Enrich(newMember);
try try
{ {
var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID); var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID);

View file

@ -38,8 +38,6 @@ public class MessageCreateResponder(
public async Task<Result> RespondAsync(IMessageCreate msg, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageCreate msg, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(msg);
userCache.UpdateUser(msg.Author); userCache.UpdateUser(msg.Author);
CataloggerMetrics.MessagesReceived.Inc(); CataloggerMetrics.MessagesReceived.Inc();
@ -77,7 +75,7 @@ public class MessageCreateResponder(
return Result.Success; return Result.Success;
} }
await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct); await messageRepository.SaveMessageAsync(msg, ct);
return Result.Success; return Result.Success;
} }
} }
@ -144,19 +142,6 @@ 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,
@ -208,19 +193,6 @@ 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

@ -41,8 +41,6 @@ public class MessageDeleteBulkResponder(
public async Task<Result> RespondAsync(IMessageDeleteBulk evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageDeleteBulk evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guild = await guildRepository.GetAsync(evt.GuildID); var guild = await guildRepository.GetAsync(evt.GuildID);
if (guild.IsMessageIgnored(evt.ChannelID, null, null)) if (guild.IsMessageIgnored(evt.ChannelID, null, null))
return Result.Success; return Result.Success;

View file

@ -18,6 +18,7 @@ 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;
@ -36,6 +37,7 @@ 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>
{ {
@ -46,8 +48,6 @@ public class MessageDeleteResponder(
public async Task<Result> RespondAsync(IMessageDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (!evt.GuildID.IsDefined()) if (!evt.GuildID.IsDefined())
return Result.Success; return Result.Success;
@ -64,22 +64,20 @@ public class MessageDeleteResponder(
return Result.Success; return Result.Success;
var guild = await guildRepository.GetAsync(evt.GuildID); var guild = await guildRepository.GetAsync(evt.GuildID);
if (guild.IsMessageIgnored(evt.ChannelID, null, null))
return Result.Success;
var msg = await messageRepository.GetMessageAsync(evt.ID.Value, ct); var msg = await messageRepository.GetMessageAsync(evt.ID.Value, ct);
// Sometimes a message that *should* be logged isn't stored in the database, notify the user of that // Sometimes a message that *should* be logged isn't stored in the database, notify the user of that
if (msg == null) if (msg == null)
{ {
_logger.Debug(
"Deleted message {MessageId} should be logged but is not in the database",
evt.ID
);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, evt.ChannelID), webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, evt.ChannelID),
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} | Original sent at"), Footer: new EmbedFooter(Text: $"ID: {evt.ID}"),
Timestamp: evt.ID.Timestamp Timestamp: clock.GetCurrentInstant().ToDateTimeOffset()
) )
); );
@ -108,20 +106,15 @@ public class MessageDeleteResponder(
evt.ChannelID, evt.ChannelID,
msg.UserId msg.UserId
); );
if (logChannel is null or 0) if (logChannel == null)
{ return Result.Success;
_logger.Debug(
"Message {MessageId} should not be logged; either ignored or message delete logs are disabled",
evt.ID
);
}
var user = await userCache.GetUserAsync(DiscordSnowflake.New(msg.UserId)); var user = await userCache.GetUserAsync(DiscordSnowflake.New(msg.UserId));
var builder = new EmbedBuilder() var builder = new EmbedBuilder()
.WithTitle("Message deleted") .WithTitle("Message deleted")
.WithDescription(msg.Content) .WithDescription(msg.Content)
.WithColour(DiscordUtils.Red) .WithColour(DiscordUtils.Red)
.WithFooter($"ID: {msg.Id} | Original sent at") .WithFooter($"ID: {msg.Id}")
.WithTimestamp(evt.ID); .WithTimestamp(evt.ID);
if (user != null) if (user != null)

View file

@ -20,6 +20,7 @@ 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;
@ -39,9 +40,11 @@ public class MessageUpdateResponder(
{ {
private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>();
public async Task<Result> RespondAsync(IMessageUpdate msg, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageUpdate evt, CancellationToken ct = default)
{ {
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())
{ {
@ -129,7 +132,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**"); embedBuilder.AddField("\u200b", "**PluralKit information**", false);
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);
} }
@ -169,7 +172,7 @@ public class MessageUpdateResponder(
) )
{ {
if ( if (
!await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct) !await messageRepository.SaveMessageAsync(msg, ct)
&& msg.ApplicationID.Is(DiscordUtils.PkUserId) && msg.ApplicationID.Is(DiscordUtils.PkUserId)
) )
{ {
@ -191,6 +194,44 @@ 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

@ -26,19 +26,19 @@ public class ReadyResponder(ILogger logger, WebhookExecutorService webhookExecut
{ {
private readonly ILogger _logger = logger.ForContext<ReadyResponder>(); private readonly ILogger _logger = logger.ForContext<ReadyResponder>();
public Task<Result> RespondAsync(IReady evt, CancellationToken ct = default) public Task<Result> RespondAsync(IReady gatewayEvent, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt); var shardId = gatewayEvent.Shard.TryGet(out var shard)
? (shard.ShardID, shard.ShardCount)
var shardId = evt.Shard.TryGet(out var shard) ? (shard.ShardID, shard.ShardCount) : (0, 1); : (0, 1);
_logger.Information( _logger.Information(
"Ready as {User} on shard {ShardId}/{ShardCount}", "Ready as {User} on shard {ShardId}/{ShardCount}",
evt.User.Tag(), gatewayEvent.User.Tag(),
shardId.Item1, shardId.Item1,
shardId.Item2 shardId.Item2
); );
if (shardId.Item1 == 0) if (shardId.Item1 == 0)
webhookExecutorService.SetSelfUser(evt.User); webhookExecutorService.SetSelfUser(gatewayEvent.User);
return Task.FromResult(Result.Success); return Task.FromResult(Result.Success);
} }

View file

@ -35,8 +35,6 @@ public class RoleCreateResponder(
public async Task<Result> RespondAsync(IGuildRoleCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildRoleCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
_logger.Debug("Received new role {RoleId} in guild {GuildId}", evt.Role.ID, evt.GuildID); _logger.Debug("Received new role {RoleId} in guild {GuildId}", evt.Role.ID, evt.GuildID);
roleCache.Set(evt.Role, evt.GuildID); roleCache.Set(evt.Role, evt.GuildID);

View file

@ -35,8 +35,6 @@ public class RoleDeleteResponder(
public async Task<Result> RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(evt);
try try
{ {
if (!roleCache.TryGet(evt.RoleID, out var role)) if (!roleCache.TryGet(evt.RoleID, out var role))

View file

@ -37,8 +37,6 @@ public class RoleUpdateResponder(
public async Task<Result> RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
var newRole = evt.Role; var newRole = evt.Role;

View file

@ -40,7 +40,5 @@ public static class BuildInfo
Version = versionData[0]; Version = versionData[0];
if (versionData[1] != "0" || dirty) if (versionData[1] != "0" || dirty)
Version += $"+{versionData[2]}"; Version += $"+{versionData[2]}";
if (dirty)
Version += ".dirty";
} }
} }

View file

@ -234,7 +234,6 @@ 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

@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup> </PropertyGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
@ -21,18 +22,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.2" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<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.2" /> <PackageReference Include="Polly.Core" Version="8.5.0" />
<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="2025.1.0" /> <PackageReference Include="Remora.Discord" Version="2024.3.0-github11168366508"/>
<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,13 +13,6 @@
// 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), IResultError public class CataloggerError(string message) : Exception(message) { }
{
public static RemoraResult Result(string message) =>
RemoraResult.FromError(new CataloggerError(message));
}

View file

@ -29,11 +29,6 @@ public static class CataloggerMetrics
public static long MessageRateMinute { get; set; } public static long MessageRateMinute { get; set; }
public static readonly Gauge DatabaseConnections = Metrics.CreateGauge(
"catalogger_open_database_connections",
"Number of open database connections"
);
public static readonly Gauge GuildsCached = Metrics.CreateGauge( public static readonly Gauge GuildsCached = Metrics.CreateGauge(
"catalogger_cache_guilds", "catalogger_cache_guilds",
"Number of guilds in the cache" "Number of guilds in the cache"

View file

@ -33,7 +33,6 @@ 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,13 +17,17 @@ 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;
public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposable public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner)
: DbConnection,
IDisposable
{ {
public Guid ConnectionId => id;
public NpgsqlConnection Inner => inner; public NpgsqlConnection Inner => inner;
private readonly ILogger _logger = logger.ForContext<DatabaseConnection>();
private readonly DateTimeOffset _openTime = DateTimeOffset.UtcNow;
private bool _hasClosed; private bool _hasClosed;
@ -39,6 +43,8 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
} }
DatabasePool.DecrementConnections(); DatabasePool.DecrementConnections();
var openFor = DateTimeOffset.UtcNow - _openTime;
_logger.Verbose("Closing connection {ConnId}, open for {OpenFor}", ConnectionId, openFor);
_hasClosed = true; _hasClosed = true;
await inner.CloseAsync(); await inner.CloseAsync();
} }
@ -46,20 +52,17 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync( protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel, IsolationLevel isolationLevel,
CancellationToken cancellationToken CancellationToken cancellationToken
) => await inner.BeginTransactionAsync(isolationLevel, cancellationToken); )
{
_logger.Verbose("Beginning transaction on connection {ConnId}", ConnectionId);
return await inner.BeginTransactionAsync(isolationLevel, cancellationToken);
}
public new void Dispose() public new void Dispose()
{ {
Dispose(true); Close();
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(); inner.Dispose();
GC.SuppressFinalize(this);
} }
public override async ValueTask DisposeAsync() public override async ValueTask DisposeAsync()
@ -70,13 +73,13 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa
} }
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) =>
throw new SyncException(nameof(BeginDbTransaction)); inner.BeginTransaction(isolationLevel);
public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName);
public override void Close() => throw new SyncException(nameof(Close)); public override void Close() => inner.Close();
public override void Open() => throw new SyncException(nameof(Open)); public override void Open() => inner.Open();
[AllowNull] [AllowNull]
public override string ConnectionString public override string ConnectionString
@ -91,6 +94,4 @@ 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

@ -141,7 +141,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c
if (hasMigrationTable) if (hasMigrationTable)
{ {
return await conn.QuerySingleOrDefaultAsync<MigrationEntry>( return await conn.QuerySingleOrDefaultAsync<MigrationEntry>(
"SELECT * FROM migrations ORDER BY applied_at DESC, migration_name DESC LIMIT 1" "SELECT * FROM migrations ORDER BY applied_at DESC LIMIT 1"
); );
} }
@ -163,7 +163,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c
return await reader.ReadToEndAsync(); return await reader.ReadToEndAsync();
} }
private static IEnumerable<string> GetMigrationNames() => public static IEnumerable<string> GetMigrationNames() =>
typeof(DatabasePool) typeof(DatabasePool)
.Assembly.GetManifestResourceNames() .Assembly.GetManifestResourceNames()
.Where(s => s.StartsWith($"{RootPath}.Migrations")) .Where(s => s.StartsWith($"{RootPath}.Migrations"))

View file

@ -24,13 +24,18 @@ namespace Catalogger.Backend.Database;
public class DatabasePool public class DatabasePool
{ {
private readonly ILogger _rootLogger;
private readonly ILogger _logger;
private readonly NpgsqlDataSource _dataSource; private readonly NpgsqlDataSource _dataSource;
private static int _openConnections; private static int _openConnections;
public static int OpenConnections => _openConnections; public static int OpenConnections => _openConnections;
public DatabasePool(Config config, ILoggerFactory? loggerFactory) public DatabasePool(Config config, ILogger logger, ILoggerFactory? loggerFactory)
{ {
_rootLogger = logger;
_logger = logger.ForContext<DatabasePool>();
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{ {
Timeout = config.Database.Timeout ?? 5, Timeout = config.Database.Timeout ?? 5,
@ -46,14 +51,24 @@ public class DatabasePool
public async Task<DatabaseConnection> AcquireAsync(CancellationToken ct = default) public async Task<DatabaseConnection> AcquireAsync(CancellationToken ct = default)
{ {
IncrementConnections(); return new DatabaseConnection(
return new DatabaseConnection(await _dataSource.OpenConnectionAsync(ct)); LogOpen(),
_rootLogger,
await _dataSource.OpenConnectionAsync(ct)
);
} }
public DatabaseConnection Acquire() public DatabaseConnection Acquire()
{ {
return new DatabaseConnection(LogOpen(), _rootLogger, _dataSource.OpenConnection());
}
private Guid LogOpen()
{
var connId = Guid.NewGuid();
_logger.Verbose("Opening database connection {ConnId}", connId);
IncrementConnections(); IncrementConnections();
return new DatabaseConnection(_dataSource.OpenConnection()); return connId;
} }
public async Task ExecuteAsync( public async Task ExecuteAsync(

View file

@ -18,7 +18,6 @@ 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;
@ -64,11 +63,7 @@ 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( public async Task<bool> SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default)
IMessage msg,
Optional<Snowflake> guildId,
CancellationToken ct = default
)
{ {
var content = await Task.Run( var content = await Task.Run(
() => () =>
@ -112,9 +107,7 @@ 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 = guildId.IsDefined(out var guildIdValue) GuildId = msg.GuildID.Map(s => s.Value).OrDefault(),
? guildIdValue.Value
: (ulong?)null,
Content = content, Content = content,
Username = username, Username = username,
Metadata = metadata, Metadata = metadata,

View file

@ -1,152 +0,0 @@
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
using Serilog.Context;
namespace Catalogger.Backend.Extensions;
public static class LogUtils
{
public static IDisposable Enrich<T>(T evt)
where T : IGatewayEvent
{
var type = ("Event", typeof(T).Name);
return evt switch
{
IMessageDelete md => PushProperties(
type,
("GuildId", md.GuildID),
("ChannelId", md.ChannelID),
("MessageId", md.ID)
),
IMessageUpdate mu => PushProperties(
type,
("GuildId", mu.GuildID),
("ChannelId", mu.ChannelID),
("MessageId", mu.ID)
),
IMessageCreate mc => PushProperties(
type,
("GuildId", mc.GuildID),
("ChannelId", mc.ChannelID),
("MessageId", mc.ID)
),
IMessageDeleteBulk mdb => PushProperties(
type,
("GuildId", mdb.GuildID),
("ChannelId", mdb.ChannelID),
("MessageIds", mdb.IDs)
),
IGuildRoleCreate grc => PushProperties(
type,
("GuildId", grc.GuildID),
("RoleId", grc.Role.ID)
),
IGuildRoleUpdate gru => PushProperties(
type,
("GuildId", gru.GuildID),
("RoleId", gru.Role.ID)
),
IGuildRoleDelete grd => PushProperties(
type,
("GuildId", grd.GuildID),
("RoleId", grd.RoleID)
),
IGuildMemberAdd gma => PushProperties(
type,
("GuildId", gma.GuildID),
("UserId", gma.User.Map(u => u.ID))
),
IGuildMemberUpdate gmu => PushProperties(
type,
("GuildId", gmu.GuildID),
("UserId", gmu.User.ID)
),
IGuildMemberRemove gmr => PushProperties(
type,
("GuildId", gmr.GuildID),
("UserId", gmr.User.ID)
),
IInviteCreate ic => PushProperties(
type,
("GuildId", ic.GuildID),
("ChannelId", ic.ChannelID),
("InviteCode", ic.Code)
),
IInviteDelete id => PushProperties(
type,
("GuildId", id.GuildID),
("ChannelId", id.ChannelID),
("Code", id.Code)
),
IChannelCreate cc => PushProperties(
type,
("GuildId", cc.GuildID),
("ChannelId", cc.ID)
),
IChannelUpdate cu => PushProperties(
type,
("GuildId", cu.GuildID),
("ChannelId", cu.ID)
),
IChannelDelete cd => PushProperties(
type,
("GuildId", cd.GuildID),
("ChannelId", cd.ID)
),
IGuildAuditLogEntryCreate ale => PushProperties(
type,
("GuildId", ale.GuildID),
("AuditLogEntryId", ale.ID),
("ActionType", ale.ActionType)
),
IGuildBanAdd gba => PushProperties(
type,
("GuildId", gba.GuildID),
("UserId", gba.User.ID)
),
IGuildBanRemove gbr => PushProperties(
type,
("GuildId", gbr.GuildID),
("UserId", gbr.User.ID)
),
IGuildCreate gc => PushProperties(
type,
("GuildId", gc.Guild.Match(g => g.ID, g => g.ID))
),
IGuildDelete gd => PushProperties(type, ("GuildId", gd.ID)),
IGuildEmojisUpdate geu => PushProperties(type, ("GuildId", geu.GuildID)),
IGuildMembersChunk gmc => PushProperties(
type,
("GuildId", gmc.GuildID),
("MemberCount", gmc.Members.Count),
("ChunkIndex", gmc.ChunkIndex),
("ChunkCount", gmc.ChunkCount)
),
IGuildUpdate gu => PushProperties(type, ("GuildId", gu.ID)),
_ => PushProperties(type),
};
}
public static IDisposable PushProperties(params (string, object?)[] properties) =>
new MultiDisposable(
properties
.Select(p =>
{
if (p.Item2 is Optional<Snowflake> s)
return LogContext.PushProperty(p.Item1, s.IsDefined() ? s.Value : null);
return LogContext.PushProperty(p.Item1, p.Item2);
})
.ToArray()
);
private record MultiDisposable(IDisposable[] Entries) : IDisposable
{
public void Dispose()
{
foreach (var e in Entries)
e.Dispose();
}
}
}

View file

@ -51,22 +51,19 @@ public static class StartupExtensions
{ {
var logCfg = new LoggerConfiguration() var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.MinimumLevel.Verbose() .MinimumLevel.Is(config.Logging.LogEventLevel)
// Most Microsoft.* package logs are needlessly verbose, so we restrict them to INFO level and up
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
// ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead.
// Serilog doesn't disable the built-in logs, so we do it here. // Serilog doesn't disable the built-in logs, so we do it here.
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override(
"Microsoft.EntityFrameworkCore.Database.Command",
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal
)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
// Let's not put webhook tokens and even *full bot tokens* in the logs, thank you
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
// The default theme doesn't support light mode // The default theme doesn't support light mode
.WriteTo.Console( .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen, applyThemeToRedirectedOutput: true);
theme: AnsiConsoleTheme.Sixteen,
applyThemeToRedirectedOutput: true,
restrictedToMinimumLevel: config.Logging.LogEventLevel
);
if (config.Logging.SeqLogUrl != null) if (config.Logging.SeqLogUrl != null)
{ {

View file

@ -66,7 +66,8 @@ builder
| GatewayIntents.GuildMessages | GatewayIntents.GuildMessages
| GatewayIntents.GuildWebhooks | GatewayIntents.GuildWebhooks
| GatewayIntents.MessageContents | GatewayIntents.MessageContents
| GatewayIntents.GuildExpressions; // Actually GUILD_EXPRESSIONS
| 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(
@ -96,7 +97,12 @@ 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

@ -43,7 +43,6 @@ public class MetricsCollectionService(
var messageCount = await conn.ExecuteScalarAsync<int>("select count(id) from messages"); var messageCount = await conn.ExecuteScalarAsync<int>("select count(id) from messages");
CataloggerMetrics.DatabaseConnections.Set(DatabasePool.OpenConnections);
CataloggerMetrics.GuildsCached.Set(guildCache.Size); CataloggerMetrics.GuildsCached.Set(guildCache.Size);
CataloggerMetrics.ChannelsCached.Set(channelCache.Size); CataloggerMetrics.ChannelsCached.Set(channelCache.Size);
CataloggerMetrics.RolesCached.Set(roleCache.Size); CataloggerMetrics.RolesCached.Set(roleCache.Size);

View file

@ -34,9 +34,8 @@ public class NewsService(
private readonly ILogger _logger = logger.ForContext<NewsService>(); private readonly ILogger _logger = logger.ForContext<NewsService>();
private List<IMessage>? _messages; private List<IMessage>? _messages;
private Instant _lastUpdated = Instant.MinValue;
private readonly SemaphoreSlim _lock = new(1); private readonly SemaphoreSlim _lock = new(1);
private bool _isExpired => clock.GetCurrentInstant() > _lastUpdated + ExpiresAfter; private bool _isExpired => clock.GetCurrentInstant() > clock.GetCurrentInstant() + ExpiresAfter;
public async Task<IEnumerable<NewsMessage>> GetNewsAsync() public async Task<IEnumerable<NewsMessage>> GetNewsAsync()
{ {
@ -75,7 +74,6 @@ public class NewsService(
} }
finally finally
{ {
_lastUpdated = clock.GetCurrentInstant();
_lock.Release(); _lock.Release();
} }
} }

View file

@ -25,8 +25,7 @@ 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();
await using var timeoutRepository = var timeoutRepository = scope.ServiceProvider.GetRequiredService<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)
@ -54,10 +53,8 @@ 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();
await using var guildRepository = var guildRepository = scope.ServiceProvider.GetRequiredService<GuildRepository>();
scope.ServiceProvider.GetRequiredService<GuildRepository>(); var timeoutRepository = scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
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, Lock> _locks = new(); private readonly ConcurrentDictionary<ulong, object> _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, new Lock()); var channelLock = _locks.GetOrAdd(channelId, channelId);
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 GetLogChannelForMessageEvent(guild, logChannelType, channelId, userId); return GetMessageLogChannel(guild, logChannelType, channelId, userId);
if (isChannelLog) if (isChannelLog)
return GetLogChannelForChannelEvent(guild, logChannelType, channelId!.Value); return GetChannelLogChannel(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,201 +305,20 @@ 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, and this isn't a message or channel event, return the default log channel. // If nothing is ignored, return the correct log channel!
return GetDefaultLogChannel(guild, logChannelType); return GetDefaultLogChannel(guild, logChannelType);
} }
private ulong? GetLogChannelForMessageEvent( private ulong? GetChannelLogChannel(
Guild guild,
LogChannelType logChannelType,
Snowflake? channelId = null,
ulong? userId = null
)
{
_logger.Verbose(
"Getting log channel for event {Event}. Channel ID: {ChannelId}, user ID: {UserId}",
logChannelType,
channelId,
userId
);
// Check if the user is ignored globally
if (userId != null && guild.Messages.IgnoredUsers.Contains(userId.Value))
{
_logger.Verbose("User {UserId} is ignored globally", userId);
return null;
}
// If the user isn't ignored and we didn't get a channel ID, return the default log channel
if (channelId == null)
{
_logger.Verbose(
"No channel ID given so returning default channel for {Event}",
logChannelType
);
return GetDefaultLogChannel(guild, logChannelType);
}
if (!channelCache.TryGet(channelId.Value, 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 var actualChannelId, 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.Messages.IgnoredChannels.Contains(actualChannelId.Value)
|| categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value)
)
{
_logger.Verbose(
"Channel {ChannelId} or its parent {CategoryId} is ignored",
actualChannelId,
categoryId
);
return null;
}
if (userId != null)
{
// Check the channel-local and category-local ignored users
var channelIgnoredUsers =
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
? guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(
categoryId.Value.Value
)
: []
) ?? [];
// 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(
"User {UserId} is ignored in {ChannelId} or its category {CategoryId}",
userId,
channelId,
categoryId
);
return null;
}
}
// 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 (i.e. GetDefaultLogChannel doesn't return 0)
if (GetDefaultLogChannel(guild, logChannelType) == 0)
{
_logger.Verbose(
"No default log channel for event {EventType}, ignoring event",
logChannelType
);
return null;
}
if (guild.Channels.Redirects.TryGetValue(actualChannelId.Value, out var channelRedirect))
{
_logger.Verbose(
"Messages from channel {ChannelId} should be redirected to {RedirectId}",
actualChannelId,
channelRedirect
);
return channelRedirect;
}
var categoryRedirect =
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0;
if (categoryRedirect != 0)
{
_logger.Verbose(
"Messages from categoryId {CategoryId} should be redirected to {RedirectId}",
categoryId,
categoryRedirect
);
return categoryRedirect;
}
_logger.Verbose(
"No redirects or ignores for event {EventType}, returning default log channel",
logChannelType
);
return GetDefaultLogChannel(guild, logChannelType);
}
private ulong? GetLogChannelForChannelEvent(
Guild guild, Guild guild,
LogChannelType logChannelType, LogChannelType logChannelType,
Snowflake channelId 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)) 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); return GetDefaultLogChannel(guild, logChannelType);
}
if (!GetChannelAndParentId(channel, out channelId, out var categoryId)) Snowflake? 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 ( if (
channel.Type channel.Type
is ChannelType.AnnouncementThread is ChannelType.AnnouncementThread
@ -510,16 +329,7 @@ public class WebhookExecutorService(
// parent_id should always have a value for threads // parent_id should always have a value for threads
channelId = channel.ParentID.Value!.Value; channelId = channel.ParentID.Value!.Value;
if (!channelCache.TryGet(channelId, out var parentChannel)) if (!channelCache.TryGet(channelId, out var parentChannel))
{ return GetDefaultLogChannel(guild, logChannelType);
_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; categoryId = parentChannel.ParentID.Value;
} }
else else
@ -528,7 +338,94 @@ public class WebhookExecutorService(
categoryId = channel.ParentID.Value; categoryId = channel.ParentID.Value;
} }
return true; // Check if the channel or its category is ignored
if (
guild.IgnoredChannels.Contains(channelId.Value)
|| (categoryId != null && guild.IgnoredChannels.Contains(categoryId.Value.Value))
)
return null;
return GetDefaultLogChannel(guild, logChannelType);
}
private ulong? GetMessageLogChannel(
Guild guild,
LogChannelType logChannelType,
Snowflake? channelId = null,
ulong? userId = null
)
{
// Check if the user is ignored globally
if (userId != null && guild.Messages.IgnoredUsers.Contains(userId.Value))
return null;
// If the user isn't ignored and we didn't get a channel ID, return the default log channel
if (channelId == null)
return GetDefaultLogChannel(guild, logChannelType);
if (!channelCache.TryGet(channelId.Value, out var channel))
return null;
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.Value, out var parentChannel))
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.Messages.IgnoredChannels.Contains(channelId.Value.Value)
|| categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value)
)
return null;
if (userId != null)
{
// Check the channel-local and category-local ignored users
var channelIgnoredUsers =
guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value)
?? [];
var categoryIgnoredUsers =
(
categoryId != null
? guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(
categoryId.Value.Value
)
: []
) ?? [];
if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value))
return null;
}
// 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.
if (GetDefaultLogChannel(guild, logChannelType) == 0)
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))
return channelRedirect;
return categoryRedirect != 0
? categoryRedirect
: GetDefaultLogChannel(guild, logChannelType);
} }
public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) => public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) =>

View file

@ -7,9 +7,6 @@ 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

@ -0,0 +1,681 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"Dapper": {
"type": "Direct",
"requested": "[2.1.35, )",
"resolved": "2.1.35",
"contentHash": "YKRwjVfrG7GYOovlGyQoMvr1/IJdn+7QzNXJxyMh0YfFF5yvDmTYaJOVYWsckreNjGsGSEtrMTpnzxTUq/tZQw=="
},
"Humanizer.Core": {
"type": "Direct",
"requested": "[2.14.1, )",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"LazyCache": {
"type": "Direct",
"requested": "[2.4.0, )",
"resolved": "2.4.0",
"contentHash": "THig17vqe5PEs3wvTqFrNzorz2nD4Qz9F9C3YlAydU673CogAO8z1u8NNJD6x52I7oDCQ/N/HwJIZMBH8Y/Qiw==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "2.1.0",
"Microsoft.Extensions.Caching.Memory": "2.1.0"
}
},
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "pTFDEmZi3GheCSPrBxzyE63+d5unln2vYldo/nOm1xet/4rpEk2oJYcwpclPQ13E+LZBF9XixkgwYTUwqznlWg==",
"dependencies": {
"Microsoft.AspNetCore.JsonPatch": "9.0.0",
"Newtonsoft.Json": "13.0.3",
"Newtonsoft.Json.Bson": "1.0.2"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "9.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Newtonsoft.Json": {
"type": "Direct",
"requested": "[13.0.3, )",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"NodaTime": {
"type": "Direct",
"requested": "[3.2.0, )",
"resolved": "3.2.0",
"contentHash": "yoRA3jEJn8NM0/rQm78zuDNPA3DonNSZdsorMUj+dltc1D+/Lc5h9YXGqbEEZozMGr37lAoYkcSM/KjTVqD0ow=="
},
"NodaTime.Serialization.SystemTextJson": {
"type": "Direct",
"requested": "[1.2.0, )",
"resolved": "1.2.0",
"contentHash": "HNMQdHw6xCrNaHEEvJlBek+uUNI4uySEQhU3t8FibZT9ASMz40y5qkLIwhrHsnXhxUzOPP4tmAGy8PfBwc3zMg==",
"dependencies": {
"NodaTime": "[3.0.0, 4.0.0)"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "zu1nCRt0gWP/GR0reYgg0Bl5o8qyNV7mVAgzAbVLRiAd1CYXcf/9nrubPH0mt93u8iGTKmYqWaLVECEAcE6IfQ==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
"System.Text.Json": "9.0.0"
}
},
"Npgsql.NodaTime": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "kHVcgTeJ68MUufMs3HAboklhM5v+qHwVNqh4Tkko/BPP3wkijGXaUaHYffgaga9n+bIIHq1f1VdTl99Rz7XxFA==",
"dependencies": {
"NodaTime": "3.2.0",
"Npgsql": "9.0.0"
}
},
"Polly.Core": {
"type": "Direct",
"requested": "[8.5.0, )",
"resolved": "8.5.0",
"contentHash": "VYYMZNitZ85UEhwOKkTQI63WEMvzUqwQc74I2mm8h/DBVAMcBBxqYPni4DmuRtbCwngmuONuK2yBJfWNRKzI+A=="
},
"Polly.RateLimiting": {
"type": "Direct",
"requested": "[8.5.0, )",
"resolved": "8.5.0",
"contentHash": "ChVprxvWs0QU90Aiu9LwcEJCoDwYhx46zDFQkA5ODhCjDPTyLKKU/ODjHFlqkrSogZ0sZIIAS6FuM93yEKSAZQ==",
"dependencies": {
"Polly.Core": "8.5.0",
"System.Threading.RateLimiting": "8.0.0"
}
},
"prometheus-net": {
"type": "Direct",
"requested": "[8.2.1, )",
"resolved": "8.2.1",
"contentHash": "3wVgdEPOCBF752s2xps5T+VH+c9mJK8S8GKEDg49084P6JZMumTZI5Te6aJ9MQpX0sx7om6JOnBpIi7ZBmmiDQ==",
"dependencies": {
"Microsoft.Extensions.Http": "3.1.0",
"Microsoft.Extensions.ObjectPool": "7.0.0"
}
},
"prometheus-net.AspNetCore": {
"type": "Direct",
"requested": "[8.2.1, )",
"resolved": "8.2.1",
"contentHash": "/4TfTvbwIDqpaKTiWvEsjUywiHYF9zZvGZF5sK15avoDsUO/WPQbKsF8TiMaesuphdFQPK2z52P0zk6j26V0rQ==",
"dependencies": {
"prometheus-net": "8.2.1"
}
},
"Remora.Discord": {
"type": "Direct",
"requested": "[2024.3.0-github11168366508, )",
"resolved": "2024.3.0-github11168366508",
"contentHash": "tlqwVPeILmUmjEIsDgRQQChwCPnwAvpJTXSiYMruPDO+XVomfMjMUfS7EVIMUosHEC4bs4PS8m60lbTO2Lducw==",
"dependencies": {
"Remora.Discord.Caching": "39.0.0-github11168366508",
"Remora.Discord.Commands": "28.1.0-github11168366508",
"Remora.Discord.Extensions": "5.3.6-github11168366508",
"Remora.Discord.Hosting": "6.0.10-github11168366508",
"Remora.Discord.Interactivity": "5.0.0-github11168366508",
"Remora.Discord.Pagination": "4.0.1-github11168366508"
}
},
"Remora.Sdk": {
"type": "Direct",
"requested": "[3.1.2, )",
"resolved": "3.1.2",
"contentHash": "IjHGwOH9XZJu4sMPA25M/gMLJktq4CdtSvekn8sAF85bE/3uhxU9pqmuzc4N39ktY7aTkLBRDa6/oQJnmiI6CQ=="
},
"Serilog": {
"type": "Direct",
"requested": "[4.1.0, )",
"resolved": "4.1.0",
"contentHash": "u1aZI8HZ62LWlq5dZLFwm6jMax/sUwnWZSw5lkPsCt518cJBxFKoNmc7oSxe5aA5BgSkzy9rzwFGR/i/acnSPw=="
},
"Serilog.AspNetCore": {
"type": "Direct",
"requested": "[8.0.3, )",
"resolved": "8.0.3",
"contentHash": "Y5at41mc0OV982DEJslBKHd6uzcWO6POwR3QceJ6gtpMPxCzm4+FElGPF0RdaTD7MGsP6XXE05LMbSi0NO+sXg==",
"dependencies": {
"Microsoft.Extensions.Logging": "8.0.0",
"Serilog": "3.1.1",
"Serilog.Extensions.Hosting": "8.0.0",
"Serilog.Formatting.Compact": "2.0.0",
"Serilog.Settings.Configuration": "8.0.4",
"Serilog.Sinks.Console": "5.0.0",
"Serilog.Sinks.Debug": "2.0.0",
"Serilog.Sinks.File": "5.0.0"
}
},
"Serilog.Extensions.Hosting": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Hosting.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Serilog": "3.1.1",
"Serilog.Extensions.Logging": "8.0.0"
}
},
"Serilog.Sinks.Console": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Sinks.Seq": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "z5ig56/qzjkX6Fj4U/9m1g8HQaQiYPMZS4Uevtjg1I+WWzoGSf5t/E+6JbMP/jbZYhU63bA5NJN5y0x+qqx2Bw==",
"dependencies": {
"Serilog": "4.0.0",
"Serilog.Sinks.File": "5.0.0"
}
},
"StackExchange.Redis": {
"type": "Direct",
"requested": "[2.8.16, )",
"resolved": "2.8.16",
"contentHash": "WaoulkOqOC9jHepca3JZKFTqndCWab5uYS7qCzmiQDlrTkFaDN7eLSlEfHycBxipRnQY9ppZM7QSsWAwUEGblw==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "6.0.0",
"Pipelines.Sockets.Unofficial": "2.2.8"
}
},
"CommunityToolkit.HighPerformance": {
"type": "Transitive",
"resolved": "8.2.2",
"contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw=="
},
"FuzzySharp": {
"type": "Transitive",
"resolved": "2.0.2",
"contentHash": "sBKqWxw3g//peYxDZ8JipRlyPbIyBtgzqBVA5GqwHVeqtIrw75maGXAllztf+1aJhchD+drcQIgf2mFho8ZV8A=="
},
"JsonDocumentPath": {
"type": "Transitive",
"resolved": "1.0.3",
"contentHash": "4mgdlioVvfq6ZjftvsoKANWgpr/AU+UySiW68EjcbPbTfvcrZOlgS+6JkouRAN4TwI8dN2DUAVME7bklThk3KQ=="
},
"Microsoft.AspNetCore.JsonPatch": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "/4UONYoAIeexPoAmbzBPkVGA6KAY7t0BM+1sr0fKss2V1ERCdcM+Llub4X5Ma+LJ60oPp6KzM0e3j+Pp/JHCNw==",
"dependencies": {
"Microsoft.CSharp": "4.7.0",
"Newtonsoft.Json": "13.0.3"
}
},
"Microsoft.CSharp": {
"type": "Transitive",
"resolved": "4.7.0",
"contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA=="
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw=="
},
"Microsoft.Extensions.Diagnostics": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0"
}
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"System.Diagnostics.DiagnosticSource": "8.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Diagnostics": "8.0.0",
"Microsoft.Extensions.Logging": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0"
}
},
"Microsoft.Extensions.Http.Polly": {
"type": "Transitive",
"resolved": "8.0.6",
"contentHash": "vehhL2uDlr2ovIFMuYcQwXgOCu7QECXnjcRD37luN40Fjqm0C4PDiN0t0dHoyfJp6OgJ+sOYDev5jVMGz4lJnQ==",
"dependencies": {
"Microsoft.Extensions.Http": "8.0.0",
"Polly": "7.2.4",
"Polly.Extensions.Http": "3.0.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "udvKco0sAVgYGTBnHUb0tY9JQzJ/nPDiv/8PIyz69wl1AibeCDZOLVVI+6156dPfHmJH7ws5oUJRiW4ZmAvuuA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0",
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
},
"Newtonsoft.Json.Bson": {
"type": "Transitive",
"resolved": "1.0.2",
"contentHash": "QYFyxhaABwmq3p/21VrZNYvCg3DaEoN/wUuw5nmfAf0X3HLjgupwhkEWdgfb9nvGAUIv3osmZoD3kKl4jxEmYQ==",
"dependencies": {
"Newtonsoft.Json": "12.0.1"
}
},
"NGettext": {
"type": "Transitive",
"resolved": "0.6.7",
"contentHash": "gT6bf5PVayvTuEIuM2XSNqthrtn9W+LlCX4RD//Nb4hrT3agohHvPdjpROgNGgyXDkjwE74F+EwDwqUgJCJG8A=="
},
"OneOf": {
"type": "Transitive",
"resolved": "3.0.271",
"contentHash": "pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg=="
},
"Pipelines.Sockets.Unofficial": {
"type": "Transitive",
"resolved": "2.2.8",
"contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==",
"dependencies": {
"System.IO.Pipelines": "5.0.1"
}
},
"Polly": {
"type": "Transitive",
"resolved": "8.4.0",
"contentHash": "z2EeUutuy49jBQyZ5s2FUuTCGx3GCzJ0cJ2HbjWwks94TsC6bKTtAHKBkMZOa/DyYRl5yIX7MshvMTWl1J6RNg==",
"dependencies": {
"Polly.Core": "8.4.0"
}
},
"Polly.Contrib.WaitAndRetry": {
"type": "Transitive",
"resolved": "1.1.1",
"contentHash": "1MUQLiSo4KDkQe6nzQRhIU05lm9jlexX5BVsbuw0SL82ynZ+GzAHQxJVDPVBboxV37Po3SG077aX8DuSy8TkaA=="
},
"Polly.Extensions.Http": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==",
"dependencies": {
"Polly": "7.1.0"
}
},
"Remora.Commands": {
"type": "Transitive",
"resolved": "10.0.5",
"contentHash": "uvZ34ywhK9WxBBqHZiLz7GXJDPZrt0N+IhRs5+V53TTCvLlgA0S8zBCPCANnVpcbVJ8Vl9l3EkcL+PY0VT0TYw==",
"dependencies": {
"Microsoft.Extensions.Options": "8.0.0",
"Remora.Results": "7.4.1"
}
},
"Remora.Discord.API": {
"type": "Transitive",
"resolved": "78.0.0-github11168366508",
"contentHash": "yDH7x0XLbe4GPhHeK5Ju4tGXCPpSAo0Jd20jikVZOlFHLJkynt0NVWYTT69ZJyniibopwpeANPyAnX8KhZmBbA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "8.0.0",
"Microsoft.Extensions.Options": "8.0.2",
"Remora.Discord.API.Abstractions": "82.0.0-github11168366508",
"Remora.Rest": "3.4.0",
"System.Text.Json": "8.0.3"
}
},
"Remora.Discord.API.Abstractions": {
"type": "Transitive",
"resolved": "82.0.0-github11168366508",
"contentHash": "vUsvcaM8bSqha9uBhye0mRvARaRHYQgQcIre+CcEloGO4n2JzalLdCFlYIUF3yzcBMGWQnnXymMSzvxjipPglw==",
"dependencies": {
"OneOf": "3.0.271",
"Remora.Rest.Core": "2.2.1",
"Remora.Results": "7.4.1"
}
},
"Remora.Discord.Caching": {
"type": "Transitive",
"resolved": "39.0.0-github11168366508",
"contentHash": "LY6fROu/g+lcfV60OAM+7KC29nsKtJNUuhiGPI1Mb1w6uR5LoTWGaM29/nQeY8DzixD60np7lF5ZwZUlgoTp0g==",
"dependencies": {
"Remora.Discord.Caching.Abstractions": "1.1.4-github11168366508",
"Remora.Discord.Gateway": "12.0.2-github11168366508"
}
},
"Remora.Discord.Caching.Abstractions": {
"type": "Transitive",
"resolved": "1.1.4-github11168366508",
"contentHash": "ZDh/C/d0lJ2rYY/8UyRDf57XYg2ZVnTjwuqVXNYrGI/kkQCMI3R4WCbPOppBrycji6iX5pp+fx1j1pSdZsc3eA==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "8.0.0",
"Remora.Results": "7.4.1"
}
},
"Remora.Discord.Commands": {
"type": "Transitive",
"resolved": "28.1.0-github11168366508",
"contentHash": "SzYCnL4KEsnqvBaDLrXeAfkr45A3cHygJSO/VUSfQpTC6XoHDSMY181H7M2czgY+GiwSzrxYkeu/p89MFkzvxw==",
"dependencies": {
"FuzzySharp": "2.0.2",
"Humanizer.Core": "2.14.1",
"NGettext": "0.6.7",
"Remora.Commands": "10.0.5",
"Remora.Discord.Gateway": "12.0.2-github11168366508",
"Remora.Extensions.Options.Immutable": "1.0.8",
"System.ComponentModel.Annotations": "5.0.0"
}
},
"Remora.Discord.Extensions": {
"type": "Transitive",
"resolved": "5.3.6-github11168366508",
"contentHash": "xidy4VW5xS8m+crKKjZeN2p6H+TQOgl9Je79ykX1vckMrUOMGtSreKoCEzpVRMPyXotNr9K2xbj1dqNtr4afXw==",
"dependencies": {
"Remora.Discord.API": "78.0.0-github11168366508",
"Remora.Discord.Commands": "28.1.0-github11168366508",
"Remora.Discord.Gateway": "12.0.2-github11168366508",
"Remora.Discord.Interactivity": "5.0.0-github11168366508"
}
},
"Remora.Discord.Gateway": {
"type": "Transitive",
"resolved": "12.0.2-github11168366508",
"contentHash": "yleE7MHFc8JC6QDhCf6O9Xn2mQA06mmZtwph4tiBnehBTf6GY0ST6op7szEHEE4BI6LuvSo7TuKaHqFzAbxLHQ==",
"dependencies": {
"CommunityToolkit.HighPerformance": "8.2.2",
"Remora.Discord.Rest": "51.0.0-github11168366508",
"Remora.Extensions.Options.Immutable": "1.0.8",
"System.Threading.Channels": "8.0.0"
}
},
"Remora.Discord.Hosting": {
"type": "Transitive",
"resolved": "6.0.10-github11168366508",
"contentHash": "BCTbNq/sYvUeiuFSNt8Y0aFi0+g4Fnz1vcHEwzFPxczGsW1QaHNOJst8GDpV9fEfcBrs5EHgE+Y4vo0ed8B9zQ==",
"dependencies": {
"Microsoft.Extensions.Hosting.Abstractions": "8.0.0",
"Remora.Discord.Gateway": "12.0.2-github11168366508",
"Remora.Extensions.Options.Immutable": "1.0.8"
}
},
"Remora.Discord.Interactivity": {
"type": "Transitive",
"resolved": "5.0.0-github11168366508",
"contentHash": "vJOy/8//5+UcTHx8TV4iilQrYJEVfqfmuPNISIShLlgbEzbp/UjmN7QBiOJtpgUAPifeaQbmBXLPlYR0nKEDxg==",
"dependencies": {
"Remora.Discord.Commands": "28.1.0-github11168366508",
"Remora.Discord.Gateway": "12.0.2-github11168366508"
}
},
"Remora.Discord.Pagination": {
"type": "Transitive",
"resolved": "4.0.1-github11168366508",
"contentHash": "+JKA+GYTlAkX1MxElI+ICGGmZnteiODiVHN09+QeHsjHaWxSBkb7g3pk8OqWrLhyQlyGvI/37kHV+UjRT6Ua5A==",
"dependencies": {
"Remora.Discord.Interactivity": "5.0.0-github11168366508"
}
},
"Remora.Discord.Rest": {
"type": "Transitive",
"resolved": "51.0.0-github11168366508",
"contentHash": "4NImnAdU27K2Wkbjvw1Dyyib+dZwpKvl39vwnYNnpcYRgQ9mSiKWXq6y2rw/bXXn/l7V/EO6qZsgN1+Q5Yo65A==",
"dependencies": {
"Microsoft.Extensions.Caching.Memory": "8.0.0",
"Microsoft.Extensions.Http.Polly": "8.0.6",
"Polly": "8.4.0",
"Polly.Contrib.WaitAndRetry": "1.1.1",
"Remora.Discord.API": "78.0.0-github11168366508",
"Remora.Discord.Caching.Abstractions": "1.1.4-github11168366508"
}
},
"Remora.Extensions.Options.Immutable": {
"type": "Transitive",
"resolved": "1.0.8",
"contentHash": "CCw7IlZnE7hCGsO7sb9w05qdYY7bTufdYe6hiXKTOE3IDwdl2xtV7vitMif1KXVAjSZi9QySk8UPA5OfJTC3bA==",
"dependencies": {
"Microsoft.Extensions.Options": "7.0.1"
}
},
"Remora.Rest": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "uncX4dsj6sq52ZUAnUrUs/usl3YEO4KZ+939r1K6Ojlq2IAZuuJ/4WocicARAiUZp8xa4xeOk1xbAP0+54D3gg==",
"dependencies": {
"JsonDocumentPath": "1.0.3",
"Microsoft.Extensions.Http": "8.0.0",
"OneOf": "3.0.263",
"Remora.Rest.Core": "2.2.1",
"Remora.Results": "7.4.1",
"System.Text.Json": "8.0.3"
}
},
"Remora.Rest.Core": {
"type": "Transitive",
"resolved": "2.2.1",
"contentHash": "XWhTyHiClwJHiZf0+Ci0+R8ZdeJOyFWvPYh05JNYwAE9327T57d7VIqInbZ8/NfRdgYZ3TSHEjUwITVhetQZZQ=="
},
"Remora.Results": {
"type": "Transitive",
"resolved": "7.4.1",
"contentHash": "XDO1jZBNpp3d0gApH0uG8BcOkjL4QxMJAEkmx3SlP202GDHev0BthuC4yOcENT5yApZvVT4IV5pJAwLYtSYIFg=="
},
"Serilog.Extensions.Logging": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
"dependencies": {
"Microsoft.Extensions.Logging": "8.0.0",
"Serilog": "3.1.1"
}
},
"Serilog.Formatting.Compact": {
"type": "Transitive",
"resolved": "2.0.0",
"contentHash": "ob6z3ikzFM3D1xalhFuBIK1IOWf+XrQq+H4KeH4VqBcPpNcmUgZlRQ2h3Q7wvthpdZBBoY86qZOI2LCXNaLlNA==",
"dependencies": {
"Serilog": "3.1.0"
}
},
"Serilog.Settings.Configuration": {
"type": "Transitive",
"resolved": "8.0.4",
"contentHash": "pkxvq0umBKK8IKFJc1aV5S/HGRG/NIxJ6FV42KaTPLfDmBOAbBUB1m5gqqlGxzEa1MgDDWtQlWJdHTSxVWNx+Q==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "8.0.0",
"Microsoft.Extensions.DependencyModel": "8.0.2",
"Serilog": "3.1.1"
}
},
"Serilog.Sinks.Debug": {
"type": "Transitive",
"resolved": "2.0.0",
"contentHash": "Y6g3OBJ4JzTyyw16fDqtFcQ41qQAydnEvEqmXjhwhgjsnG/FaJ8GUqF5ldsC/bVkK8KYmqrPhDO+tm4dF6xx4A==",
"dependencies": {
"Serilog": "2.10.0"
}
},
"Serilog.Sinks.File": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "uwV5hdhWPwUH1szhO8PJpFiahqXmzPzJT/sOijH/kFgUx+cyoDTMM8MHD0adw9+Iem6itoibbUXHYslzXsLEAg==",
"dependencies": {
"Serilog": "2.10.0"
}
},
"System.ComponentModel.Annotations": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg=="
},
"System.Diagnostics.DiagnosticSource": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "5.0.1",
"contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A=="
},
"System.Threading.Channels": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA=="
},
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q=="
}
}
}
}

View file

@ -43,7 +43,7 @@ internal class Program
return; return;
} }
var db = new DatabasePool(config, null); var db = new DatabasePool(config, Log.Logger, null);
DatabasePool.ConfigureDapper(); DatabasePool.ConfigureDapper();
if (Environment.GetEnvironmentVariable("MIGRATE") == "true") if (Environment.GetEnvironmentVariable("MIGRATE") == "true")
{ {

View file

@ -10,6 +10,13 @@ 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.

View file

@ -1,5 +1,3 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForOtherTypes/@EntryValue">UseVar</s:String>
<s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForSimpleTypes/@EntryValue">UseVar</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pluralkit/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=pluralkit/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=remora/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=remora/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>