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