chore: format with csharpier

This commit is contained in:
sam 2024-10-09 17:35:11 +02:00
parent 2f516dcb73
commit 4f54077c68
59 changed files with 2000 additions and 942 deletions

View file

@ -30,7 +30,8 @@ public class ChannelCommands(
ChannelCache channelCache, ChannelCache channelCache,
IFeedbackService feedbackService, IFeedbackService feedbackService,
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
InMemoryDataService<Snowflake, ChannelCommandData> dataService) : CommandGroup InMemoryDataService<Snowflake, ChannelCommandData> dataService
) : CommandGroup
{ {
private readonly ILogger _logger = logger.ForContext<ChannelCommands>(); private readonly ILogger _logger = logger.ForContext<ChannelCommands>();
@ -40,22 +41,30 @@ public class ChannelCommands(
public async Task<IResult> ConfigureChannelsAsync() public async Task<IResult> ConfigureChannelsAsync()
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) throw new CataloggerError("Guild not in cache"); if (!guildCache.TryGet(guildId, out var guild))
throw new CataloggerError("Guild not in cache");
var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
var (embeds, components) = BuildRootMenu(guildChannels, guild, guildConfig); var (embeds, components) = BuildRootMenu(guildChannels, guild, guildConfig);
var msg = await feedbackService.SendContextualAsync(embeds: embeds, var msg = await feedbackService
options: new FeedbackMessageOptions(MessageComponents: components)).GetOrThrow(); .SendContextualAsync(
embeds: embeds,
options: new FeedbackMessageOptions(MessageComponents: components)
)
.GetOrThrow();
dataService.TryAddData(msg.ID, new ChannelCommandData(userId, CurrentPage: null)); dataService.TryAddData(msg.ID, new ChannelCommandData(userId, CurrentPage: null));
return Result.Success; return Result.Success;
} }
public static (List<IEmbed>, List<IMessageComponent>) BuildRootMenu(List<IChannel> guildChannels, IGuild guild, public static (List<IEmbed>, List<IMessageComponent>) BuildRootMenu(
DbGuild guildConfig) List<IChannel> guildChannels,
IGuild guild,
DbGuild guildConfig
)
{ {
List<IEmbed> embeds = List<IEmbed> embeds =
[ [
@ -65,135 +74,342 @@ public class ChannelCommands(
Colour: DiscordUtils.Purple, Colour: DiscordUtils.Purple,
Fields: new[] Fields: new[]
{ {
new EmbedField("Server changes", PrettyChannelString(guildConfig.Channels.GuildUpdate), true), new EmbedField(
new EmbedField("Emoji changes", PrettyChannelString(guildConfig.Channels.GuildEmojisUpdate), true), "Server changes",
new EmbedField("New roles", PrettyChannelString(guildConfig.Channels.GuildRoleCreate), true), PrettyChannelString(guildConfig.Channels.GuildUpdate),
new EmbedField("Edited roles", PrettyChannelString(guildConfig.Channels.GuildRoleUpdate), true), true
new EmbedField("Deleted roles", PrettyChannelString(guildConfig.Channels.GuildRoleDelete), true), ),
new EmbedField(
new EmbedField("New channels", PrettyChannelString(guildConfig.Channels.ChannelCreate), true), "Emoji changes",
new EmbedField("Edited channels", PrettyChannelString(guildConfig.Channels.ChannelUpdate), true), PrettyChannelString(guildConfig.Channels.GuildEmojisUpdate),
new EmbedField("Deleted channels", PrettyChannelString(guildConfig.Channels.ChannelDelete), true), true
new EmbedField("Members joining", PrettyChannelString(guildConfig.Channels.GuildMemberAdd), true), ),
new EmbedField("Members leaving", PrettyChannelString(guildConfig.Channels.GuildMemberRemove), new EmbedField(
true), "New roles",
PrettyChannelString(guildConfig.Channels.GuildRoleCreate),
new EmbedField("Member role changes", PrettyChannelString(guildConfig.Channels.GuildMemberUpdate), true
true), ),
new EmbedField("Key role changes", PrettyChannelString(guildConfig.Channels.GuildKeyRoleUpdate), new EmbedField(
true), "Edited roles",
new EmbedField("Member name changes", PrettyChannelString(guildConfig.Channels.GuildRoleUpdate),
true
),
new EmbedField(
"Deleted roles",
PrettyChannelString(guildConfig.Channels.GuildRoleDelete),
true
),
new EmbedField(
"New channels",
PrettyChannelString(guildConfig.Channels.ChannelCreate),
true
),
new EmbedField(
"Edited channels",
PrettyChannelString(guildConfig.Channels.ChannelUpdate),
true
),
new EmbedField(
"Deleted channels",
PrettyChannelString(guildConfig.Channels.ChannelDelete),
true
),
new EmbedField(
"Members joining",
PrettyChannelString(guildConfig.Channels.GuildMemberAdd),
true
),
new EmbedField(
"Members leaving",
PrettyChannelString(guildConfig.Channels.GuildMemberRemove),
true
),
new EmbedField(
"Member role changes",
PrettyChannelString(guildConfig.Channels.GuildMemberUpdate),
true
),
new EmbedField(
"Key role changes",
PrettyChannelString(guildConfig.Channels.GuildKeyRoleUpdate),
true
),
new EmbedField(
"Member name changes",
PrettyChannelString(guildConfig.Channels.GuildMemberNickUpdate), PrettyChannelString(guildConfig.Channels.GuildMemberNickUpdate),
true), true
new EmbedField("Member avatar changes", ),
PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate), true), new EmbedField(
new EmbedField("Kicks", PrettyChannelString(guildConfig.Channels.GuildMemberKick), true), "Member avatar changes",
PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate),
new EmbedField("Bans", PrettyChannelString(guildConfig.Channels.GuildBanAdd), true), true
new EmbedField("Unbans", PrettyChannelString(guildConfig.Channels.GuildBanRemove), true), ),
new EmbedField("New invites", PrettyChannelString(guildConfig.Channels.InviteCreate), true), new EmbedField(
new EmbedField("Deleted invites", PrettyChannelString(guildConfig.Channels.InviteDelete), true), "Kicks",
new EmbedField("Edited messages", PrettyChannelString(guildConfig.Channels.MessageUpdate), true), PrettyChannelString(guildConfig.Channels.GuildMemberKick),
true
new EmbedField("Deleted messages", PrettyChannelString(guildConfig.Channels.MessageDelete), true), ),
new EmbedField("Bulk deleted messages", PrettyChannelString(guildConfig.Channels.MessageDeleteBulk), new EmbedField(
true), "Bans",
}) PrettyChannelString(guildConfig.Channels.GuildBanAdd),
true
),
new EmbedField(
"Unbans",
PrettyChannelString(guildConfig.Channels.GuildBanRemove),
true
),
new EmbedField(
"New invites",
PrettyChannelString(guildConfig.Channels.InviteCreate),
true
),
new EmbedField(
"Deleted invites",
PrettyChannelString(guildConfig.Channels.InviteDelete),
true
),
new EmbedField(
"Edited messages",
PrettyChannelString(guildConfig.Channels.MessageUpdate),
true
),
new EmbedField(
"Deleted messages",
PrettyChannelString(guildConfig.Channels.MessageDelete),
true
),
new EmbedField(
"Bulk deleted messages",
PrettyChannelString(guildConfig.Channels.MessageDeleteBulk),
true
),
}
),
]; ];
List<IMessageComponent> components = List<IMessageComponent> components =
[ [
new ActionRowComponent([ new ActionRowComponent(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Server changes", [
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", new ButtonComponent(
nameof(LogChannelType.GuildUpdate))), ButtonComponentStyle.Primary,
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Emoji changes", Label: "Server changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", CustomID: CustomIDHelpers.CreateButtonIDWithState(
nameof(LogChannelType.GuildEmojisUpdate))), "config-channels",
new ButtonComponent(ButtonComponentStyle.Primary, Label: "New roles", nameof(LogChannelType.GuildUpdate)
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", )
nameof(LogChannelType.GuildRoleCreate))), ),
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Edited roles", new ButtonComponent(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", ButtonComponentStyle.Primary,
nameof(LogChannelType.GuildRoleUpdate))), Label: "Emoji changes",
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Deleted roles", CustomID: CustomIDHelpers.CreateButtonIDWithState(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "config-channels",
nameof(LogChannelType.GuildRoleDelete))), nameof(LogChannelType.GuildEmojisUpdate)
]), )
new ActionRowComponent([ ),
new ButtonComponent(ButtonComponentStyle.Primary, Label: "New channels", new ButtonComponent(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", ButtonComponentStyle.Primary,
nameof(LogChannelType.ChannelCreate))), Label: "New roles",
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Edited channels", CustomID: CustomIDHelpers.CreateButtonIDWithState(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "config-channels",
nameof(LogChannelType.ChannelUpdate))), nameof(LogChannelType.GuildRoleCreate)
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Deleted channels", )
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", ),
nameof(LogChannelType.ChannelDelete))), new ButtonComponent(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Members joining", ButtonComponentStyle.Primary,
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", Label: "Edited roles",
nameof(LogChannelType.GuildMemberAdd))), CustomID: CustomIDHelpers.CreateButtonIDWithState(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Members leaving", "config-channels",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", nameof(LogChannelType.GuildRoleUpdate)
nameof(LogChannelType.GuildMemberRemove))), )
]), ),
new ActionRowComponent([ new ButtonComponent(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Member role changes", ButtonComponentStyle.Primary,
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", Label: "Deleted roles",
nameof(LogChannelType.GuildMemberUpdate))), CustomID: CustomIDHelpers.CreateButtonIDWithState(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Key role changes", "config-channels",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", nameof(LogChannelType.GuildRoleDelete)
nameof(LogChannelType.GuildKeyRoleUpdate))), )
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Member name changes", ),
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", ]
nameof(LogChannelType.GuildMemberNickUpdate))), ),
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Members avatar changes", new ActionRowComponent(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", [
nameof(LogChannelType.GuildMemberAvatarUpdate))), new ButtonComponent(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Kicks", ButtonComponentStyle.Primary,
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", Label: "New channels",
nameof(LogChannelType.GuildMemberKick))), CustomID: CustomIDHelpers.CreateButtonIDWithState(
]), "config-channels",
new ActionRowComponent([ nameof(LogChannelType.ChannelCreate)
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Bans", )
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", ),
nameof(LogChannelType.GuildBanAdd))), new ButtonComponent(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Unbans", ButtonComponentStyle.Primary,
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", Label: "Edited channels",
nameof(LogChannelType.GuildBanRemove))), CustomID: CustomIDHelpers.CreateButtonIDWithState(
new ButtonComponent(ButtonComponentStyle.Primary, Label: "New invites", "config-channels",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", nameof(LogChannelType.ChannelUpdate)
nameof(LogChannelType.InviteCreate))), )
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Deleted invites", ),
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", new ButtonComponent(
nameof(LogChannelType.InviteDelete))), ButtonComponentStyle.Primary,
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Edited messages", Label: "Deleted channels",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", CustomID: CustomIDHelpers.CreateButtonIDWithState(
nameof(LogChannelType.MessageUpdate))), "config-channels",
]), nameof(LogChannelType.ChannelDelete)
new ActionRowComponent([ )
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Deleted messages", ),
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", new ButtonComponent(
nameof(LogChannelType.MessageDelete))), ButtonComponentStyle.Primary,
new ButtonComponent(ButtonComponentStyle.Primary, Label: "Bulk deleted messages", Label: "Members joining",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", CustomID: CustomIDHelpers.CreateButtonIDWithState(
nameof(LogChannelType.MessageDeleteBulk))), "config-channels",
new ButtonComponent(ButtonComponentStyle.Secondary, Label: "Close", nameof(LogChannelType.GuildMemberAdd)
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "close")), )
]), ),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Members leaving",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberRemove)
)
),
]
),
new ActionRowComponent(
[
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Member role changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberUpdate)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Key role changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildKeyRoleUpdate)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Member name changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberNickUpdate)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Members avatar changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberAvatarUpdate)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Kicks",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberKick)
)
),
]
),
new ActionRowComponent(
[
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Bans",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildBanAdd)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Unbans",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildBanRemove)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "New invites",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.InviteCreate)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Deleted invites",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.InviteDelete)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Edited messages",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.MessageUpdate)
)
),
]
),
new ActionRowComponent(
[
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Deleted messages",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.MessageDelete)
)
),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Bulk deleted messages",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.MessageDeleteBulk)
)
),
new ButtonComponent(
ButtonComponentStyle.Secondary,
Label: "Close",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"close"
)
),
]
),
]; ];
return (embeds, components); return (embeds, components);
string PrettyChannelString(ulong id) string PrettyChannelString(ulong id)
{ {
if (id == 0) return "Not set"; if (id == 0)
if (guildChannels.All(c => c.ID != id)) return $"unknown channel {id}"; return "Not set";
if (guildChannels.All(c => c.ID != id))
return $"unknown channel {id}";
return $"<#{id}>"; return $"<#{id}>";
} }
} }
public static string PrettyLogTypeName(LogChannelType type) => type switch public static string PrettyLogTypeName(LogChannelType type) =>
type switch
{ {
LogChannelType.GuildUpdate => "Server changes", LogChannelType.GuildUpdate => "Server changes",
LogChannelType.GuildEmojisUpdate => "Emoji changes", LogChannelType.GuildEmojisUpdate => "Emoji changes",
@ -217,6 +433,10 @@ public class ChannelCommands(
LogChannelType.MessageUpdate => "Edited messages", LogChannelType.MessageUpdate => "Edited messages",
LogChannelType.MessageDelete => "Deleted messages", LogChannelType.MessageDelete => "Deleted messages",
LogChannelType.MessageDeleteBulk => "Bulk deleted messages", LogChannelType.MessageDeleteBulk => "Bulk deleted messages",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, "Invalid LogChannelType value") _ => throw new ArgumentOutOfRangeException(
nameof(type),
type,
"Invalid LogChannelType value"
),
}; };
} }

View file

@ -27,7 +27,8 @@ public class ChannelCommandsComponents(
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
IFeedbackService feedbackService, IFeedbackService feedbackService,
IDiscordRestInteractionAPI interactionApi, IDiscordRestInteractionAPI interactionApi,
InMemoryDataService<Snowflake, ChannelCommandData> dataService) : InteractionGroup InMemoryDataService<Snowflake, ChannelCommandData> dataService
) : InteractionGroup
{ {
private readonly ILogger _logger = logger.ForContext<ChannelCommandsComponents>(); private readonly ILogger _logger = logger.ForContext<ChannelCommandsComponents>();
@ -35,11 +36,16 @@ public class ChannelCommandsComponents(
[SuppressInteractionResponse(true)] [SuppressInteractionResponse(true)]
public async Task<Result> OnButtonPressedAsync(string state) public async Task<Result> OnButtonPressedAsync(string state)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) throw new CataloggerError("No context"); if (contextInjection.Context is not IInteractionCommandContext ctx)
if (!ctx.TryGetUserID(out var userId)) throw new CataloggerError("No user ID in context"); throw new CataloggerError("No context");
if (!ctx.Interaction.Message.TryGet(out var msg)) throw new CataloggerError("No message ID in context"); if (!ctx.TryGetUserID(out var userId))
if (!ctx.TryGetGuildID(out var guildId)) throw new CataloggerError("No guild ID in context"); throw new CataloggerError("No user ID in context");
if (!guildCache.TryGet(guildId, out var guild)) throw new CataloggerError("Guild not in cache"); if (!ctx.Interaction.Message.TryGet(out var msg))
throw new CataloggerError("No message ID in context");
if (!ctx.TryGetGuildID(out var guildId))
throw new CataloggerError("No guild ID in context");
if (!guildCache.TryGet(guildId, out var guild))
throw new CataloggerError("Guild not in cache");
var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
@ -47,20 +53,27 @@ public class ChannelCommandsComponents(
await using var lease = result.GetOrThrow(); await using var lease = result.GetOrThrow();
if (lease.Data.UserId != userId) if (lease.Data.UserId != userId)
{ {
return (Result)await feedbackService.SendContextualAsync("This is not your configuration menu.", return (Result)
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)); await feedbackService.SendContextualAsync(
"This is not your configuration menu.",
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)
);
} }
switch (state) switch (state)
{ {
case "close": case "close":
return await interactionApi.UpdateMessageAsync(ctx.Interaction, return await interactionApi.UpdateMessageAsync(
new InteractionMessageCallbackData(Components: Array.Empty<IMessageComponent>())); ctx.Interaction,
new InteractionMessageCallbackData(Components: Array.Empty<IMessageComponent>())
);
case "reset": case "reset":
if (lease.Data.CurrentPage == null) if (lease.Data.CurrentPage == null)
throw new CataloggerError("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))
throw new CataloggerError($"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'"); throw new CataloggerError(
$"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'"
);
// TODO: figure out some way to make this less verbose? // TODO: figure out some way to make this less verbose?
switch (channelType) switch (channelType)
@ -140,8 +153,10 @@ public class ChannelCommandsComponents(
goto case "return"; goto case "return";
case "return": case "return":
var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig); var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig);
await interactionApi.UpdateMessageAsync(ctx.Interaction, await interactionApi.UpdateMessageAsync(
new InteractionMessageCallbackData(Embeds: e, Components: c)); ctx.Interaction,
new InteractionMessageCallbackData(Embeds: e, Components: c)
);
lease.Data = new ChannelCommandData(userId, CurrentPage: null); lease.Data = new ChannelCommandData(userId, CurrentPage: null);
return Result.Success; return Result.Success;
} }
@ -151,9 +166,12 @@ public class ChannelCommandsComponents(
var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType); var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType);
string? channelMention; string? channelMention;
if (channelId is 0) channelMention = null; if (channelId is 0)
else if (guildChannels.All(c => c.ID != channelId)) channelMention = $"unknown channel {channelId}"; channelMention = null;
else channelMention = $"<#{channelId}>"; else if (guildChannels.All(c => c.ID != channelId))
channelMention = $"unknown channel {channelId}";
else
channelMention = $"<#{channelId}>";
List<IEmbed> embeds = List<IEmbed> embeds =
[ [
@ -161,43 +179,69 @@ public class ChannelCommandsComponents(
Title: ChannelCommands.PrettyLogTypeName(logChannelType), Title: ChannelCommands.PrettyLogTypeName(logChannelType),
Description: channelMention == null Description: channelMention == null
? "This event is not currently logged.\nTo start logging it somewhere, select a channel below." ? "This event is not currently logged.\nTo start logging it somewhere, select a channel below."
: $"This event is currently set to log to {channelMention}." + : $"This event is currently set to log to {channelMention}."
"\nTo change where it is logged, select a channel below." + + "\nTo change where it is logged, select a channel below."
"\nTo disable logging this event entirely, select \"Stop logging\" below.", + "\nTo disable logging this event entirely, select \"Stop logging\" below.",
Colour: DiscordUtils.Purple) Colour: DiscordUtils.Purple
),
]; ];
List<IMessageComponent> components = List<IMessageComponent> components =
[ [
new ActionRowComponent(new[] new ActionRowComponent(
new[]
{ {
new ChannelSelectComponent(CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"), new ChannelSelectComponent(
ChannelTypes: new[] { ChannelType.GuildText }) CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"),
}), ChannelTypes: new[] { ChannelType.GuildText }
new ActionRowComponent(new[] ),
}
),
new ActionRowComponent(
new[]
{ {
new ButtonComponent(ButtonComponentStyle.Danger, Label: "Stop logging", new ButtonComponent(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "reset"), ButtonComponentStyle.Danger,
IsDisabled: channelMention == null), Label: "Stop logging",
new ButtonComponent(ButtonComponentStyle.Secondary, Label: "Return to menu", CustomID: CustomIDHelpers.CreateButtonIDWithState(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "return")) "config-channels",
}) "reset"
),
IsDisabled: channelMention == null
),
new ButtonComponent(
ButtonComponentStyle.Secondary,
Label: "Return to menu",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"return"
)
),
}
),
]; ];
lease.Data = new ChannelCommandData(userId, CurrentPage: state); lease.Data = new ChannelCommandData(userId, CurrentPage: state);
return await interactionApi.UpdateMessageAsync(ctx.Interaction, return await interactionApi.UpdateMessageAsync(
new InteractionMessageCallbackData(Embeds: embeds, Components: components)); ctx.Interaction,
new InteractionMessageCallbackData(Embeds: embeds, Components: components)
);
} }
[SelectMenu("config-channels")] [SelectMenu("config-channels")]
[SuppressInteractionResponse(true)] [SuppressInteractionResponse(true)]
public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels) public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) throw new CataloggerError("No context"); if (contextInjection.Context is not IInteractionCommandContext ctx)
if (!ctx.TryGetUserID(out var userId)) throw new CataloggerError("No user ID in context"); throw new CataloggerError("No context");
if (!ctx.Interaction.Message.TryGet(out var msg)) throw new CataloggerError("No message ID in context"); if (!ctx.TryGetUserID(out var userId))
if (!ctx.TryGetGuildID(out var guildId)) throw new CataloggerError("No guild ID in context"); throw new CataloggerError("No user ID in context");
if (!guildCache.TryGet(guildId, out var guild)) throw new CataloggerError("Guild not in cache"); if (!ctx.Interaction.Message.TryGet(out var msg))
throw new CataloggerError("No message ID in context");
if (!ctx.TryGetGuildID(out var guildId))
throw new CataloggerError("No guild ID in context");
if (!guildCache.TryGet(guildId, out var guild))
throw new CataloggerError("Guild not in cache");
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
var channelId = channels[0].ID.ToUlong(); var channelId = channels[0].ID.ToUlong();
@ -205,12 +249,17 @@ public class ChannelCommandsComponents(
await using var lease = result.GetOrThrow(); await using var lease = result.GetOrThrow();
if (lease.Data.UserId != userId) if (lease.Data.UserId != userId)
{ {
return (Result)await feedbackService.SendContextualAsync("This is not your configuration menu.", return (Result)
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)); await feedbackService.SendContextualAsync(
"This is not your configuration menu.",
options: new FeedbackMessageOptions(MessageFlags: MessageFlags.Ephemeral)
);
} }
if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType)) if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType))
throw new CataloggerError($"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'"); throw new CataloggerError(
$"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'"
);
switch (channelType) switch (channelType)
{ {
@ -291,31 +340,52 @@ public class ChannelCommandsComponents(
[ [
new Embed( new Embed(
Title: ChannelCommands.PrettyLogTypeName(channelType), Title: ChannelCommands.PrettyLogTypeName(channelType),
Description: $"This event is currently set to log to <#{channelId}>." + Description: $"This event is currently set to log to <#{channelId}>."
"\nTo change where it is logged, select a channel below." + + "\nTo change where it is logged, select a channel below."
"\nTo disable logging this event entirely, select \"Stop logging\" below.", + "\nTo disable logging this event entirely, select \"Stop logging\" below.",
Colour: DiscordUtils.Purple) Colour: DiscordUtils.Purple
),
]; ];
List<IMessageComponent> components = List<IMessageComponent> components =
[ [
new ActionRowComponent(new[] new ActionRowComponent(
new[]
{ {
new ChannelSelectComponent(CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"), new ChannelSelectComponent(
ChannelTypes: new[] { ChannelType.GuildText }) CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"),
}), ChannelTypes: new[] { ChannelType.GuildText }
new ActionRowComponent(new[] ),
}
),
new ActionRowComponent(
new[]
{ {
new ButtonComponent(ButtonComponentStyle.Danger, Label: "Stop logging", new ButtonComponent(
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "reset")), ButtonComponentStyle.Danger,
new ButtonComponent(ButtonComponentStyle.Secondary, Label: "Return to menu", Label: "Stop logging",
CustomID: CustomIDHelpers.CreateButtonIDWithState("config-channels", "return")) CustomID: CustomIDHelpers.CreateButtonIDWithState(
}) "config-channels",
"reset"
)
),
new ButtonComponent(
ButtonComponentStyle.Secondary,
Label: "Return to menu",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"return"
)
),
}
),
]; ];
lease.Data = lease.Data with { UserId = userId }; lease.Data = lease.Data with { UserId = userId };
return await interactionApi.UpdateMessageAsync(ctx.Interaction, return await interactionApi.UpdateMessageAsync(
new InteractionMessageCallbackData(Embeds: embeds, Components: components)); ctx.Interaction,
new InteractionMessageCallbackData(Embeds: embeds, Components: components)
);
} }
} }

View file

@ -22,41 +22,52 @@ public class KeyRoleCommands(
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
IFeedbackService feedbackService, IFeedbackService feedbackService,
GuildCache guildCache, GuildCache guildCache,
RoleCache roleCache) : CommandGroup RoleCache roleCache
) : CommandGroup
{ {
[Command("list")] [Command("list")]
[Description("List this server's key roles.")] [Description("List this server's key roles.")]
public async Task<IResult> ListKeyRolesAsync() public async Task<IResult> ListKeyRolesAsync()
{ {
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) throw new CataloggerError("Guild not in cache"); if (!guildCache.TryGet(guildId, out var guild))
throw new CataloggerError("Guild not in cache");
var guildRoles = roleCache.GuildRoles(guildId).ToList(); var guildRoles = roleCache.GuildRoles(guildId).ToList();
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
if (guildConfig.KeyRoles.Count == 0) if (guildConfig.KeyRoles.Count == 0)
return await feedbackService.SendContextualAsync( return await feedbackService.SendContextualAsync(
"There are no key roles to list. Add some with `/key-roles add`."); "There are no key roles to list. Add some with `/key-roles add`."
);
var description = string.Join("\n", guildConfig.KeyRoles.Select(id => var description = string.Join(
"\n",
guildConfig.KeyRoles.Select(id =>
{ {
var role = guildRoles.FirstOrDefault(r => r.ID.Value == id); var role = guildRoles.FirstOrDefault(r => r.ID.Value == id);
return role != null ? $"- {role.Name} <@&{role.ID}>" : $"- unknown role {id}"; return role != null ? $"- {role.Name} <@&{role.ID}>" : $"- unknown role {id}";
})); })
);
return await feedbackService.SendContextualEmbedAsync(new Embed( return await feedbackService.SendContextualEmbedAsync(
new Embed(
Title: $"Key roles for {guild.Name}", Title: $"Key roles for {guild.Name}",
Description: description, Description: description,
Colour: DiscordUtils.Purple)); Colour: DiscordUtils.Purple
)
);
} }
[Command("add")] [Command("add")]
[Description("Add a new key role.")] [Description("Add a new key role.")]
public async Task<IResult> AddKeyRoleAsync( public async Task<IResult> AddKeyRoleAsync(
[Description("The role to add.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId) [Description("The role to add.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId
)
{ {
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) throw new CataloggerError("Role is not cached"); if (role == null)
throw new CataloggerError("Role is not cached");
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
if (guildConfig.KeyRoles.Any(id => role.ID == id)) if (guildConfig.KeyRoles.Any(id => role.ID == id))
@ -66,27 +77,34 @@ public class KeyRoleCommands(
db.Update(guildConfig); db.Update(guildConfig);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return await feedbackService.SendContextualAsync($"Added {role.Name} to this server's key roles!"); return await feedbackService.SendContextualAsync(
$"Added {role.Name} to this server's key roles!"
);
} }
[Command("remove")] [Command("remove")]
[Description("Remove a key role.")] [Description("Remove a key role.")]
public async Task<IResult> RemoveKeyRoleAsync( public async Task<IResult> RemoveKeyRoleAsync(
[Description("The role to remove.")] [DiscordTypeHint(TypeHint.Role)] [Description("The role to remove.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId
Snowflake roleId) )
{ {
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) throw new CataloggerError("Role is not cached"); if (role == null)
throw new CataloggerError("Role is not cached");
var guildConfig = await db.GetGuildAsync(guildId); var guildConfig = await db.GetGuildAsync(guildId);
if (guildConfig.KeyRoles.All(id => role.ID != id)) if (guildConfig.KeyRoles.All(id => role.ID != id))
return await feedbackService.SendContextualAsync($"{role.Name} is already not a key role."); return await feedbackService.SendContextualAsync(
$"{role.Name} is already not a key role."
);
guildConfig.KeyRoles.Remove(role.ID.Value); guildConfig.KeyRoles.Remove(role.ID.Value);
db.Update(guildConfig); db.Update(guildConfig);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return await feedbackService.SendContextualAsync($"Removed {role.Name} from this server's key roles!"); return await feedbackService.SendContextualAsync(
$"Removed {role.Name} from this server's key roles!"
);
} }
} }

View file

@ -32,7 +32,8 @@ public class MetaCommands(
ContextInjectionService contextInjection, ContextInjectionService contextInjection,
GuildCache guildCache, GuildCache guildCache,
ChannelCache channelCache, ChannelCache channelCache,
IDiscordRestChannelAPI channelApi) : CommandGroup IDiscordRestChannelAPI channelApi
) : CommandGroup
{ {
private readonly ILogger _logger = logger.ForContext<MetaCommands>(); private readonly ILogger _logger = logger.ForContext<MetaCommands>();
private readonly HttpClient _client = new(); private readonly HttpClient _client = new();
@ -41,12 +42,14 @@ public class MetaCommands(
[Description("Ping pong! See the bot's latency")] [Description("Ping pong! See the bot's latency")]
public async Task<IResult> PingAsync() public async Task<IResult> PingAsync()
{ {
var shardId = contextInjection.Context?.TryGetGuildID(out var guildId) == true var shardId =
contextInjection.Context?.TryGetGuildID(out var guildId) == true
? client.ShardIdFor(guildId.Value) ? client.ShardIdFor(guildId.Value)
: 0; : 0;
var averageLatency = client.Shards.Values.Select(x => x.Latency.TotalMilliseconds).Sum() / var averageLatency =
client.Shards.Count; client.Shards.Values.Select(x => x.Latency.TotalMilliseconds).Sum()
/ client.Shards.Count;
var t1 = clock.GetCurrentInstant(); var t1 = clock.GetCurrentInstant();
var msg = await feedbackService.SendContextualAsync("...").GetOrThrow(); var msg = await feedbackService.SendContextualAsync("...").GetOrThrow();
@ -57,42 +60,54 @@ public class MetaCommands(
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithColour(DiscordUtils.Purple) .WithColour(DiscordUtils.Purple)
.WithFooter($"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}") .WithFooter(
$"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}"
)
.WithCurrentTimestamp(); .WithCurrentTimestamp();
embed.AddField("Ping", embed.AddField(
$"Gateway: {client.Shards[shardId].Latency.TotalMilliseconds:N0}ms (average: {averageLatency:N0}ms)\n" + "Ping",
$"API: {elapsed.TotalMilliseconds:N0}ms", $"Gateway: {client.Shards[shardId].Latency.TotalMilliseconds:N0}ms (average: {averageLatency:N0}ms)\n"
inline: true); + $"API: {elapsed.TotalMilliseconds:N0}ms",
inline: true
);
embed.AddField("Memory usage", memoryUsage.Bytes().Humanize(), inline: true); embed.AddField("Memory usage", memoryUsage.Bytes().Humanize(), inline: true);
var messageRate = await MessagesRate(); var messageRate = await MessagesRate();
embed.AddField("Messages received", embed.AddField(
"Messages received",
messageRate != null messageRate != null
? $"{messageRate / 5:F1}/m\n({CataloggerMetrics.MessagesReceived.Value:N0} since last restart)" ? $"{messageRate / 5:F1}/m\n({CataloggerMetrics.MessagesReceived.Value:N0} since last restart)"
: $"{CataloggerMetrics.MessagesReceived.Value:N0} since last restart", : $"{CataloggerMetrics.MessagesReceived.Value:N0} since last restart",
true); true
);
embed.AddField("Shard", $"{shardId + 1} of {client.Shards.Count}", true); embed.AddField("Shard", $"{shardId + 1} of {client.Shards.Count}", true);
embed.AddField("Uptime", embed.AddField(
$"{(CataloggerMetrics.Startup - clock.GetCurrentInstant()).Prettify(TimeUnit.Second)}\n" + "Uptime",
$"since <t:{CataloggerMetrics.Startup.ToUnixTimeSeconds()}:F>", $"{(CataloggerMetrics.Startup - clock.GetCurrentInstant()).Prettify(TimeUnit.Second)}\n"
true); + $"since <t:{CataloggerMetrics.Startup.ToUnixTimeSeconds()}:F>",
true
);
embed.AddField("Numbers", embed.AddField(
$"{CataloggerMetrics.MessagesStored.Value:N0} messages " + "Numbers",
$"from {guildCache.Size:N0} servers\nCached {channelCache.Size:N0} channels", $"{CataloggerMetrics.MessagesStored.Value:N0} messages "
false); + $"from {guildCache.Size:N0} servers\nCached {channelCache.Size:N0} channels",
false
);
IEmbed[] embeds = [embed.Build().GetOrThrow()]; IEmbed[] embeds = [embed.Build().GetOrThrow()];
return (Result)await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); return (Result)
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, configurable prometheus endpoint
private async Task<double?> MessagesRate() private async Task<double?> MessagesRate()
{ {
if (!config.Logging.EnableMetrics) return null; if (!config.Logging.EnableMetrics)
return null;
try try
{ {

View file

@ -16,37 +16,47 @@ public class ChannelCreateResponder(
RoleCache roleCache, RoleCache roleCache,
ChannelCache channelCache, ChannelCache channelCache,
UserCache userCache, UserCache userCache,
WebhookExecutorService webhookExecutor) : IResponder<IChannelCreate> WebhookExecutorService webhookExecutor
) : IResponder<IChannelCreate>
{ {
public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default)
{ {
if (!ch.GuildID.IsDefined()) return Result.Success; if (!ch.GuildID.IsDefined())
return Result.Success;
channelCache.Set(ch); channelCache.Set(ch);
var builder = new EmbedBuilder() var builder = new EmbedBuilder()
.WithTitle(ch.Type switch .WithTitle(
ch.Type switch
{ {
ChannelType.GuildVoice => "Voice channel created", ChannelType.GuildVoice => "Voice channel created",
ChannelType.GuildCategory => "Category channel created", ChannelType.GuildCategory => "Category channel created",
ChannelType.GuildAnnouncement or ChannelType.GuildText => "Text channel created", ChannelType.GuildAnnouncement or ChannelType.GuildText =>
_ => "Channel created" "Text channel created",
}) _ => "Channel created",
}
)
.WithColour(DiscordUtils.Green) .WithColour(DiscordUtils.Green)
.WithFooter($"ID: {ch.ID}"); .WithFooter($"ID: {ch.ID}");
if (ch.ParentID.IsDefined(out var parentId)) if (ch.ParentID.IsDefined(out var parentId))
{ {
builder.WithDescription(channelCache.TryGet(parentId.Value, out var parentChannel) builder.WithDescription(
channelCache.TryGet(parentId.Value, out var parentChannel)
? $"**Name:** {ch.Name}\n**Category:** {parentChannel.Name}" ? $"**Name:** {ch.Name}\n**Category:** {parentChannel.Name}"
: $"**Name:** {ch.Name}"); : $"**Name:** {ch.Name}"
);
} }
else builder.WithDescription($"**Name:** {ch.Name}"); else
builder.WithDescription($"**Name:** {ch.Name}");
foreach (var overwrite in ch.PermissionOverwrites.OrDefault() ?? []) foreach (var overwrite in ch.PermissionOverwrites.OrDefault() ?? [])
{ {
if (overwrite.Type == PermissionOverwriteType.Role) if (overwrite.Type == PermissionOverwriteType.Role)
{ {
var roleName = roleCache.TryGet(overwrite.ID, out var role) ? role.Name : $"role {overwrite.ID}"; var roleName = roleCache.TryGet(overwrite.ID, out var role)
? role.Name
: $"role {overwrite.ID}";
var embedFieldValue = ""; var embedFieldValue = "";
if (overwrite.Allow.GetPermissions().Count != 0) if (overwrite.Allow.GetPermissions().Count != 0)
embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}"; embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}";
@ -64,12 +74,19 @@ public class ChannelCreateResponder(
if (overwrite.Deny.GetPermissions().Count != 0) if (overwrite.Deny.GetPermissions().Count != 0)
embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}"; embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}";
builder.AddField($"Override for {user?.Tag() ?? $"user {overwrite.ID}"}", embedFieldValue.Trim()); builder.AddField(
$"Override for {user?.Tag() ?? $"user {overwrite.ID}"}",
embedFieldValue.Trim()
);
} }
} }
var guildConfig = await db.GetGuildAsync(ch.GuildID.Value, ct); var guildConfig = await db.GetGuildAsync(ch.GuildID.Value, ct);
webhookExecutor.QueueLog(guildConfig, LogChannelType.ChannelCreate, builder.Build().GetOrThrow()); webhookExecutor.QueueLog(
guildConfig,
LogChannelType.ChannelCreate,
builder.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }
} }

View file

@ -14,7 +14,8 @@ public class ChannelDeleteResponder(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
ChannelCache channelCache, ChannelCache channelCache,
WebhookExecutorService webhookExecutor) : IResponder<IChannelDelete> WebhookExecutorService webhookExecutor
) : IResponder<IChannelDelete>
{ {
private readonly ILogger _logger = logger.ForContext<ChannelDeleteResponder>(); private readonly ILogger _logger = logger.ForContext<ChannelDeleteResponder>();
@ -41,7 +42,10 @@ public class ChannelDeleteResponder(
.WithCurrentTimestamp() .WithCurrentTimestamp()
.WithDescription($"**Name:** {channel.Name.Value}"); .WithDescription($"**Name:** {channel.Name.Value}");
if (channel.ParentID.IsDefined(out var parentId) && channelCache.TryGet(parentId.Value, out var category)) if (
channel.ParentID.IsDefined(out var parentId)
&& channelCache.TryGet(parentId.Value, out var category)
)
embed.Description += $"\n**Category:** {category.Name}"; embed.Description += $"\n**Category:** {category.Name}";
else else
embed.Description += "\n**Category:** (none)"; embed.Description += "\n**Category:** (none)";
@ -49,7 +53,11 @@ public class ChannelDeleteResponder(
if (channel.Topic.IsDefined(out var topic)) if (channel.Topic.IsDefined(out var topic))
embed.AddField("Description", topic); embed.AddField("Description", topic);
webhookExecutor.QueueLog(guildConfig, LogChannelType.ChannelDelete, embed.Build().GetOrThrow()); webhookExecutor.QueueLog(
guildConfig,
LogChannelType.ChannelDelete,
embed.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }
} }

View file

@ -19,8 +19,8 @@ public class ChannelUpdateResponder(
ChannelCache channelCache, ChannelCache channelCache,
RoleCache roleCache, RoleCache roleCache,
UserCache userCache, UserCache userCache,
WebhookExecutorService webhookExecutor) WebhookExecutorService webhookExecutor
: IResponder<IChannelUpdate> ) : IResponder<IChannelUpdate>
{ {
private readonly ILogger _logger = logger.ForContext<ChannelUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<ChannelUpdateResponder>();
@ -37,20 +37,26 @@ public class ChannelUpdateResponder(
var guildConfig = await db.GetGuildAsync(evt.GuildID.Value, ct); var guildConfig = await db.GetGuildAsync(evt.GuildID.Value, ct);
var builder = new EmbedBuilder() var builder = new EmbedBuilder()
.WithTitle(evt.Type switch .WithTitle(
evt.Type switch
{ {
ChannelType.GuildVoice => "Voice channel edited", ChannelType.GuildVoice => "Voice channel edited",
ChannelType.GuildCategory => "Category channel edited", ChannelType.GuildCategory => "Category channel edited",
ChannelType.GuildAnnouncement or ChannelType.GuildText => "Text channel edited", ChannelType.GuildAnnouncement or ChannelType.GuildText =>
_ => "Channel edited" "Text channel edited",
}) _ => "Channel edited",
}
)
.WithColour(DiscordUtils.Blue) .WithColour(DiscordUtils.Blue)
.WithFooter($"ID: {evt.ID} | Name: {evt.Name}") .WithFooter($"ID: {evt.ID} | Name: {evt.Name}")
.WithCurrentTimestamp(); .WithCurrentTimestamp();
if (oldChannel.ParentID != evt.ParentID) if (oldChannel.ParentID != evt.ParentID)
{ {
var categoryUpdate = CategoryUpdate(oldChannel.ParentID.OrDefault(), evt.ParentID.OrDefault()); var categoryUpdate = CategoryUpdate(
oldChannel.ParentID.OrDefault(),
evt.ParentID.OrDefault()
);
if (!string.IsNullOrWhiteSpace(categoryUpdate)) if (!string.IsNullOrWhiteSpace(categoryUpdate))
builder.AddField("Category", categoryUpdate); builder.AddField("Category", categoryUpdate);
} }
@ -64,7 +70,8 @@ public class ChannelUpdateResponder(
var newTopic = evt.Topic.OrDefault() ?? "(none)"; var newTopic = evt.Topic.OrDefault() ?? "(none)";
var topicField = $"**Before:** {oldTopic}\n\n**After:** {newTopic}"; var topicField = $"**Before:** {oldTopic}\n\n**After:** {newTopic}";
if (topicField.Length > 1000) topicField = topicField[..1000] + "…"; if (topicField.Length > 1000)
topicField = topicField[..1000] + "…";
builder.AddField("Description", topicField); builder.AddField("Description", topicField);
} }
@ -72,11 +79,19 @@ public class ChannelUpdateResponder(
var oldOverrides = oldChannel.PermissionOverwrites.OrDefault() ?? []; var oldOverrides = oldChannel.PermissionOverwrites.OrDefault() ?? [];
var newOverrides = evt.PermissionOverwrites.OrDefault() ?? []; var newOverrides = evt.PermissionOverwrites.OrDefault() ?? [];
var addedOverrides = newOverrides.Where(o => oldOverrides.All(o2 => o.ID != o2.ID)).ToList(); var addedOverrides = newOverrides
var removedOverrides = oldOverrides.Where(o => newOverrides.All(o2 => o.ID != o2.ID)).ToList(); .Where(o => oldOverrides.All(o2 => o.ID != o2.ID))
.ToList();
var removedOverrides = oldOverrides
.Where(o => newOverrides.All(o2 => o.ID != o2.ID))
.ToList();
// Overrides filtered to ones that exist in both lists, but have different allow or deny values // Overrides filtered to ones that exist in both lists, but have different allow or deny values
var editedOverrides = newOverrides.Where(o => oldOverrides.Any(o2 => var editedOverrides = newOverrides.Where(o =>
o.ID == o2.ID && (o.Allow.Value != o2.Allow.Value || o.Deny.Value != o2.Deny.Value))); oldOverrides.Any(o2 =>
o.ID == o2.ID
&& (o.Allow.Value != o2.Allow.Value || o.Deny.Value != o2.Deny.Value)
)
);
if (addedOverrides.Count != 0) if (addedOverrides.Count != 0)
{ {
@ -90,7 +105,9 @@ public class ChannelUpdateResponder(
} }
else else
{ {
addedOverrideNames.Add(roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}"); addedOverrideNames.Add(
roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}"
);
break; break;
} }
@ -110,7 +127,9 @@ public class ChannelUpdateResponder(
} }
else else
{ {
removedOverrideNames.Add(roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}"); removedOverrideNames.Add(
roleCache.TryGet(o.ID, out var role) ? role.Name : $"role {o.ID}"
);
break; break;
} }
} }
@ -120,9 +139,12 @@ public class ChannelUpdateResponder(
foreach (var overwrite in editedOverrides) foreach (var overwrite in editedOverrides)
{ {
var perms = string.Join("\n", var perms = string.Join(
PermissionUpdate(oldOverrides.First(o => o.ID == overwrite.ID), overwrite)); "\n",
if (string.IsNullOrWhiteSpace(perms)) continue; PermissionUpdate(oldOverrides.First(o => o.ID == overwrite.ID), overwrite)
);
if (string.IsNullOrWhiteSpace(perms))
continue;
builder.AddField(await OverwriteName(overwrite), perms.Trim()); builder.AddField(await OverwriteName(overwrite), perms.Trim());
} }
@ -134,19 +156,24 @@ public class ChannelUpdateResponder(
embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}"; embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}";
if (overwrite.Deny.GetPermissions().Count != 0) if (overwrite.Deny.GetPermissions().Count != 0)
embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}"; embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}";
if (string.IsNullOrWhiteSpace(embedFieldValue)) continue; if (string.IsNullOrWhiteSpace(embedFieldValue))
continue;
builder.AddField(await OverwriteName(overwrite), embedFieldValue.Trim()); builder.AddField(await OverwriteName(overwrite), embedFieldValue.Trim());
} }
// Sometimes we get channel update events for channels that didn't actually have anything loggable change. // Sometimes we get channel update events for channels that didn't actually have anything loggable change.
// If that happens, there will be no embed fields, so just check for that // If that happens, there will be no embed fields, so just check for that
if (builder.Fields.Count == 0) return Result.Success; if (builder.Fields.Count == 0)
webhookExecutor.QueueLog(guildConfig, LogChannelType.ChannelUpdate, builder.Build().GetOrThrow()); return Result.Success;
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.ChannelUpdate,
builder.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }
finally finally
{ {
channelCache.Set(evt); channelCache.Set(evt);
} }
@ -172,43 +199,66 @@ public class ChannelUpdateResponder(
: $"Override for role {overwrite.ID}"; : $"Override for role {overwrite.ID}";
case PermissionOverwriteType.Member: case PermissionOverwriteType.Member:
var user = await userCache.GetUserAsync(overwrite.ID); var user = await userCache.GetUserAsync(overwrite.ID);
return user != null ? $"Override for {user.Tag()}" : $"Override for user {overwrite.ID}"; return user != null
? $"Override for {user.Tag()}"
: $"Override for user {overwrite.ID}";
default: default:
throw new ArgumentOutOfRangeException(nameof(overwrite), overwrite.Type, throw new ArgumentOutOfRangeException(
"Invalid PermissionOverwriteType"); nameof(overwrite),
overwrite.Type,
"Invalid PermissionOverwriteType"
);
} }
} }
private static IEnumerable<string> PermissionUpdate(IPermissionOverwrite oldOverwrite, private static IEnumerable<string> PermissionUpdate(
IPermissionOverwrite newOverwrite) IPermissionOverwrite oldOverwrite,
IPermissionOverwrite newOverwrite
)
{ {
foreach (var perm in Enum.GetValues<DiscordPermission>()) foreach (var perm in Enum.GetValues<DiscordPermission>())
{ {
if (newOverwrite.Allow.HasPermission(perm) && !oldOverwrite.Allow.HasPermission(perm) && if (
!oldOverwrite.Deny.HasPermission(perm)) newOverwrite.Allow.HasPermission(perm)
&& !oldOverwrite.Allow.HasPermission(perm)
&& !oldOverwrite.Deny.HasPermission(perm)
)
{ {
yield return $"\u2b1c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}"; yield return $"\u2b1c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}";
} }
else if (newOverwrite.Deny.HasPermission(perm) && !oldOverwrite.Allow.HasPermission(perm) && else if (
!oldOverwrite.Deny.HasPermission(perm)) newOverwrite.Deny.HasPermission(perm)
&& !oldOverwrite.Allow.HasPermission(perm)
&& !oldOverwrite.Deny.HasPermission(perm)
)
{ {
yield return $"\u2b1c \u279c \u274c {perm.Humanize(LetterCasing.Title)}"; yield return $"\u2b1c \u279c \u274c {perm.Humanize(LetterCasing.Title)}";
} }
else if (newOverwrite.Allow.HasPermission(perm) && oldOverwrite.Deny.HasPermission(perm)) else if (
newOverwrite.Allow.HasPermission(perm) && oldOverwrite.Deny.HasPermission(perm)
)
{ {
yield return $"\u274c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}"; yield return $"\u274c \u279c \u2705 {perm.Humanize(LetterCasing.Title)}";
} }
else if (newOverwrite.Deny.HasPermission(perm) && oldOverwrite.Allow.HasPermission(perm)) else if (
newOverwrite.Deny.HasPermission(perm) && oldOverwrite.Allow.HasPermission(perm)
)
{ {
yield return $"\u2705 \u279c \u274c {perm.Humanize(LetterCasing.Title)}"; yield return $"\u2705 \u279c \u274c {perm.Humanize(LetterCasing.Title)}";
} }
else if (!newOverwrite.Allow.HasPermission(perm) && !newOverwrite.Deny.HasPermission(perm) && else if (
oldOverwrite.Allow.HasPermission(perm)) !newOverwrite.Allow.HasPermission(perm)
&& !newOverwrite.Deny.HasPermission(perm)
&& oldOverwrite.Allow.HasPermission(perm)
)
{ {
yield return $"\u2705 \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}"; yield return $"\u2705 \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}";
} }
else if (!newOverwrite.Allow.HasPermission(perm) && !newOverwrite.Deny.HasPermission(perm) && else if (
oldOverwrite.Allow.HasPermission(perm)) !newOverwrite.Allow.HasPermission(perm)
&& !newOverwrite.Deny.HasPermission(perm)
&& oldOverwrite.Allow.HasPermission(perm)
)
{ {
yield return $"\u274c \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}"; yield return $"\u274c \u279c \u2b1c {perm.Humanize(LetterCasing.Title)}";
} }

View file

@ -6,7 +6,8 @@ using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds; namespace Catalogger.Backend.Bot.Responders.Guilds;
public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger) : IResponder<IGuildAuditLogEntryCreate> public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger)
: IResponder<IGuildAuditLogEntryCreate>
{ {
private readonly ILogger _logger = logger.ForContext<AuditLogResponder>(); private readonly ILogger _logger = logger.ForContext<AuditLogResponder>();

View file

@ -20,8 +20,8 @@ public class GuildCreateResponder(
ChannelCache channelCache, ChannelCache channelCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
IMemberCache memberCache, IMemberCache memberCache,
GuildFetchService guildFetchService) GuildFetchService guildFetchService
: IResponder<IGuildCreate>, IResponder<IGuildDelete> ) : IResponder<IGuildCreate>, IResponder<IGuildDelete>
{ {
private readonly ILogger _logger = logger.ForContext<GuildCreateResponder>(); private readonly ILogger _logger = logger.ForContext<GuildCreateResponder>();
@ -31,37 +31,46 @@ public class GuildCreateResponder(
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))
{ {
_logger.Verbose("Received guild create for available guild {GuildName} / {GuildId})", guild.Name, guild.ID); _logger.Verbose(
"Received guild create for available guild {GuildName} / {GuildId})",
guild.Name,
guild.ID
);
guildId = guild.ID.ToUlong(); guildId = guild.ID.ToUlong();
guildName = guild.Name; guildName = guild.Name;
guildCache.Set(guild); guildCache.Set(guild);
foreach (var c in guild.Channels) channelCache.Set(c, guild.ID); foreach (var c in guild.Channels)
foreach (var r in guild.Roles) roleCache.Set(r, guild.ID); channelCache.Set(c, guild.ID);
foreach (var r in guild.Roles)
roleCache.Set(r, guild.ID);
if (!await memberCache.IsGuildCachedAsync(guild.ID)) if (!await memberCache.IsGuildCachedAsync(guild.ID))
guildFetchService.EnqueueGuild(guild.ID); guildFetchService.EnqueueGuild(guild.ID);
} }
else else
{ {
_logger.Verbose("Received guild create for unavailable guild {GuildId}", unavailableGuild.ID); _logger.Verbose(
"Received guild create for unavailable guild {GuildId}",
unavailableGuild.ID
);
guildId = unavailableGuild.ID.ToUlong(); guildId = unavailableGuild.ID.ToUlong();
} }
var tx = await db.Database.BeginTransactionAsync(ct); var tx = await db.Database.BeginTransactionAsync(ct);
if (await db.Guilds.FindAsync([guildId], ct) != null) return Result.Success; if (await db.Guilds.FindAsync([guildId], ct) != null)
return Result.Success;
db.Add(new Guild db.Add(new Guild { Id = guildId });
{
Id = guildId
});
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); await tx.CommitAsync(ct);
_logger.Information("Joined new guild {GuildName} / {GuildId}", guildName, guildId); _logger.Information("Joined new guild {GuildName} / {GuildId}", guildName, guildId);
if (config.Discord.GuildLogId != null && evt.Guild.IsT0) if (config.Discord.GuildLogId != null && evt.Guild.IsT0)
webhookExecutor.QueueLog(config.Discord.GuildLogId.Value, new EmbedBuilder() webhookExecutor.QueueLog(
config.Discord.GuildLogId.Value,
new EmbedBuilder()
.WithTitle("Joined new guild") .WithTitle("Joined new guild")
.WithDescription($"Joined new guild **{guild.Name}**") .WithDescription($"Joined new guild **{guild.Name}**")
.WithFooter($"ID: {guild.ID}") .WithFooter($"ID: {guild.ID}")
@ -70,7 +79,8 @@ public class GuildCreateResponder(
.WithThumbnailUrl(guild.IconUrl()) .WithThumbnailUrl(guild.IconUrl())
#pragma warning restore CS8604 // Possible null reference argument. #pragma warning restore CS8604 // Possible null reference argument.
.Build() .Build()
.GetOrThrow()); .GetOrThrow()
);
return Result.Success; return Result.Success;
} }
@ -92,7 +102,9 @@ public class GuildCreateResponder(
_logger.Information("Left guild {GuildName} / {GuildId}", guild.Name, guild.ID); _logger.Information("Left guild {GuildName} / {GuildId}", guild.Name, guild.ID);
if (config.Discord.GuildLogId != null) if (config.Discord.GuildLogId != null)
webhookExecutor.QueueLog(config.Discord.GuildLogId.Value, new EmbedBuilder() webhookExecutor.QueueLog(
config.Discord.GuildLogId.Value,
new EmbedBuilder()
.WithTitle("Left guild") .WithTitle("Left guild")
.WithDescription($"Left guild **{guild.Name}**") .WithDescription($"Left guild **{guild.Name}**")
.WithFooter($"ID: {guild.ID}") .WithFooter($"ID: {guild.ID}")
@ -101,7 +113,8 @@ public class GuildCreateResponder(
.WithThumbnailUrl(guild.IconUrl()) .WithThumbnailUrl(guild.IconUrl())
#pragma warning restore CS8604 // Possible null reference argument. #pragma warning restore CS8604 // Possible null reference argument.
.Build() .Build()
.GetOrThrow()); .GetOrThrow()
);
return Task.FromResult(Result.Success); return Task.FromResult(Result.Success);
} }

View file

@ -23,7 +23,8 @@ public class GuildMemberAddResponder(
UserCache userCache, UserCache userCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
IDiscordRestGuildAPI guildApi, IDiscordRestGuildAPI guildApi,
PluralkitApiService pluralkitApi) : IResponder<IGuildMemberAdd> PluralkitApiService pluralkitApi
) : IResponder<IGuildMemberAdd>
{ {
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>(); private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
private static readonly TimeSpan NewAccountThreshold = 7.Days(); private static readonly TimeSpan NewAccountThreshold = 7.Days();
@ -45,7 +46,8 @@ public class GuildMemberAddResponder(
var guildConfig = await db.GetGuildAsync(member.GuildID, ct); var guildConfig = await db.GetGuildAsync(member.GuildID, ct);
var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct); var guildRes = await guildApi.GetGuildAsync(member.GuildID, withCounts: true, ct);
if (guildRes.IsSuccess) if (guildRes.IsSuccess)
builder.Description += $"\n{guildRes.Entity.ApproximateMemberCount.Value.Ordinalize()} to join"; builder.Description +=
$"\n{guildRes.Entity.ApproximateMemberCount.Value.Ordinalize()} to join";
builder.Description += builder.Description +=
$"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>"; $"\ncreated {user.ID.Timestamp.Prettify()} ago\n<t:{user.ID.Timestamp.ToUnixTimeSeconds()}:F>";
@ -53,15 +55,19 @@ public class GuildMemberAddResponder(
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct); var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(user.ID.Value, ct);
if (pkSystem != null) if (pkSystem != null)
{ {
var createdAt = pkSystem.Created != null var createdAt =
pkSystem.Created != null
? $"{pkSystem.Created.Value.Prettify()} ago (<t:{pkSystem.Created.Value.ToUnixTimeSeconds()}:F>)" ? $"{pkSystem.Created.Value.Prettify()} ago (<t:{pkSystem.Created.Value.ToUnixTimeSeconds()}:F>)"
: "*(unknown)*"; : "*(unknown)*";
builder.AddField("PluralKit system", $""" builder.AddField(
"PluralKit system",
$"""
**ID:** {pkSystem.Id} (`{pkSystem.Uuid}`) **ID:** {pkSystem.Id} (`{pkSystem.Uuid}`)
**Name:** {pkSystem.Name ?? "*(none)*"} **Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"} **Tag:** {pkSystem.Tag ?? "*(none)*"}
**Created:** {createdAt} **Created:** {createdAt}
"""); """
);
} }
// TODO: find used invite // TODO: find used invite
@ -70,50 +76,72 @@ public class GuildMemberAddResponder(
if (user.ID.Timestamp > DateTimeOffset.Now - NewAccountThreshold) if (user.ID.Timestamp > DateTimeOffset.Now - NewAccountThreshold)
{ {
embeds.Add(new EmbedBuilder() embeds.Add(
new EmbedBuilder()
.WithTitle("New account") .WithTitle("New account")
.WithColour(DiscordUtils.Orange) .WithColour(DiscordUtils.Orange)
.WithDescription($"\u26a0\ufe0f Created {user.ID.Timestamp.Prettify()} ago") .WithDescription($"\u26a0\ufe0f Created {user.ID.Timestamp.Prettify()} ago")
.Build() .Build()
.GetOrThrow()); .GetOrThrow()
);
} }
var watchlist = await db.GetWatchlistEntryAsync(member.GuildID, user.ID, ct); var watchlist = await db.GetWatchlistEntryAsync(member.GuildID, user.ID, ct);
if (watchlist != null) if (watchlist != null)
{ {
var moderator = await userCache.GetUserAsync(DiscordSnowflake.New(watchlist.ModeratorId)); var moderator = await userCache.GetUserAsync(
var mod = moderator != null ? $"{moderator.Tag()} (<@{moderator.ID}>)" : $"<@{watchlist.ModeratorId}>"; DiscordSnowflake.New(watchlist.ModeratorId)
);
var mod =
moderator != null
? $"{moderator.Tag()} (<@{moderator.ID}>)"
: $"<@{watchlist.ModeratorId}>";
embeds.Add(new EmbedBuilder() embeds.Add(
new EmbedBuilder()
.WithTitle("⚠️ User on watchlist") .WithTitle("⚠️ User on watchlist")
.WithColour(DiscordUtils.Red) .WithColour(DiscordUtils.Red)
.WithDescription($"**{user.Tag()}** is on this server's watch list.\n\n{watchlist.Reason}") .WithDescription(
$"**{user.Tag()}** is on this server's watch list.\n\n{watchlist.Reason}"
)
.WithFooter($"ID: {user.ID} | Added") .WithFooter($"ID: {user.ID} | Added")
.WithTimestamp(watchlist.AddedAt.ToDateTimeOffset()) .WithTimestamp(watchlist.AddedAt.ToDateTimeOffset())
.AddField("Moderator", mod).GetOrThrow() .AddField("Moderator", mod)
.GetOrThrow()
.Build() .Build()
.GetOrThrow()); .GetOrThrow()
);
} }
if (pkSystem != null) if (pkSystem != null)
{ {
if (guildConfig.BannedSystems.Contains(pkSystem.Id) || if (
guildConfig.BannedSystems.Contains(pkSystem.Uuid.ToString())) guildConfig.BannedSystems.Contains(pkSystem.Id)
|| guildConfig.BannedSystems.Contains(pkSystem.Uuid.ToString())
)
{ {
embeds.Add(new EmbedBuilder().WithTitle("Banned system") embeds.Add(
new EmbedBuilder()
.WithTitle("Banned system")
.WithDescription( .WithDescription(
"\u26a0\ufe0f The system associated with this account has been banned from the server.") "\u26a0\ufe0f The system associated with this account has been banned from the server."
)
.WithColour(DiscordUtils.Red) .WithColour(DiscordUtils.Red)
.WithFooter($"ID: {pkSystem.Id}") .WithFooter($"ID: {pkSystem.Id}")
.Build() .Build()
.GetOrThrow()); .GetOrThrow()
);
} }
} }
if (embeds.Count > 1) if (embeds.Count > 1)
await webhookExecutor.SendLogAsync(guildConfig.Channels.GuildMemberAdd, await webhookExecutor.SendLogAsync(
embeds.Cast<IEmbed>().ToList(), []); guildConfig.Channels.GuildMemberAdd,
else webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]); embeds.Cast<IEmbed>().ToList(),
[]
);
else
webhookExecutor.QueueLog(guildConfig.Channels.GuildMemberAdd, embeds[0]);
return Result.Success; return Result.Success;
} }

View file

@ -17,7 +17,8 @@ public class GuildMemberRemoveResponder(
IMemberCache memberCache, IMemberCache memberCache,
RoleCache roleCache, RoleCache roleCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
AuditLogEnrichedResponderService auditLogEnrichedResponderService) : IResponder<IGuildMemberRemove> AuditLogEnrichedResponderService auditLogEnrichedResponderService
) : IResponder<IGuildMemberRemove>
{ {
private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>(); private readonly ILogger _logger = logger.ForContext<GuildMemberAddResponder>();
@ -43,8 +44,14 @@ public class GuildMemberRemoveResponder(
{ {
_logger.Information( _logger.Information(
"Guild member {UserId} in {GuildId} left but wasn't in the cache, sending limited embed", "Guild member {UserId} in {GuildId} left but wasn't in the cache, sending limited embed",
evt.User.ID, evt.GuildID); evt.User.ID,
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberRemove, embed.Build().GetOrThrow()); evt.GuildID
);
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildMemberRemove,
embed.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }
@ -65,12 +72,17 @@ public class GuildMemberRemoveResponder(
} }
roleMentions += $"<@&{role.ID}>"; roleMentions += $"<@&{role.ID}>";
if (idx != roles.Count - 1) roleMentions += ", "; if (idx != roles.Count - 1)
roleMentions += ", ";
} }
embed.AddField("Roles", roleMentions, inline: false); embed.AddField("Roles", roleMentions, inline: false);
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberRemove, embed.Build().GetOrThrow()); webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildMemberRemove,
embed.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }
finally finally

View file

@ -5,20 +5,28 @@ using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Guilds; namespace Catalogger.Backend.Bot.Responders.Guilds;
public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache) : IResponder<IGuildMembersChunk> public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache)
: IResponder<IGuildMembersChunk>
{ {
private readonly ILogger _logger = logger.ForContext<GuildMembersChunkResponder>(); private readonly ILogger _logger = logger.ForContext<GuildMembersChunkResponder>();
public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default)
{ {
_logger.Debug("Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}", evt.ChunkIndex + 1, _logger.Debug(
evt.ChunkCount, evt.GuildID); "Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}",
evt.ChunkIndex + 1,
evt.ChunkCount,
evt.GuildID
);
await memberCache.SetManyAsync(evt.GuildID, evt.Members); await memberCache.SetManyAsync(evt.GuildID, evt.Members);
if (evt.ChunkIndex == evt.ChunkCount - 1) if (evt.ChunkIndex == evt.ChunkCount - 1)
{ {
_logger.Debug("Final chunk for guild {GuildId} received, marking as cached", evt.GuildID); _logger.Debug(
"Final chunk for guild {GuildId} received, marking as cached",
evt.GuildID
);
await memberCache.MarkAsCachedAsync(evt.GuildID); await memberCache.MarkAsCachedAsync(evt.GuildID);
} }

View file

@ -18,8 +18,8 @@ public class MessageCreateResponder(
DatabaseContext db, DatabaseContext db,
MessageRepository messageRepository, MessageRepository messageRepository,
UserCache userCache, UserCache userCache,
PkMessageHandler pkMessageHandler) PkMessageHandler pkMessageHandler
: IResponder<IMessageCreate> ) : IResponder<IMessageCreate>
{ {
private readonly ILogger _logger = logger.ForContext<MessageCreateResponder>(); private readonly ILogger _logger = logger.ForContext<MessageCreateResponder>();
@ -30,8 +30,10 @@ public class MessageCreateResponder(
if (!msg.GuildID.IsDefined()) if (!msg.GuildID.IsDefined())
{ {
_logger.Debug("Received message create event for message {MessageId} despite it not being in a guild", _logger.Debug(
msg.ID); "Received message create event for message {MessageId} despite it not being in a guild",
msg.ID
);
return Result.Success; return Result.Success;
} }
@ -66,7 +68,8 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
private readonly ILogger _logger = logger.ForContext<PkMessageHandler>(); private readonly ILogger _logger = logger.ForContext<PkMessageHandler>();
[GeneratedRegex( [GeneratedRegex(
@"^System ID: (\w{5,6}) \| Member ID: (\w{5,6}) \| Sender: .+ \((\d+)\) \| Message ID: (\d+) \| Original Message ID: (\d+)$")] @"^System ID: (\w{5,6}) \| Member ID: (\w{5,6}) \| Sender: .+ \((\d+)\) \| Message ID: (\d+) \| Original Message ID: (\d+)$"
)]
private static partial Regex FooterRegex(); private static partial Regex FooterRegex();
[GeneratedRegex(@"^https:\/\/discord.com\/channels\/\d+\/(\d+)\/\d+$")] [GeneratedRegex(@"^https:\/\/discord.com\/channels\/\d+\/(\d+)\/\d+$")]
@ -89,11 +92,15 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
// The first (only, I think always?) embed's footer must match the expected format // The first (only, I think always?) embed's footer must match the expected format
var firstEmbed = msg.Embeds.FirstOrDefault(); var firstEmbed = msg.Embeds.FirstOrDefault();
if (firstEmbed == null || !firstEmbed.Footer.TryGet(out var footer) || if (
!FooterRegex().IsMatch(footer.Text)) firstEmbed == null
|| !firstEmbed.Footer.TryGet(out var footer)
|| !FooterRegex().IsMatch(footer.Text)
)
{ {
_logger.Debug( _logger.Debug(
"PK message is not a log message because there is no first embed or its footer doesn't match the regex"); "PK message is not a log message because there is no first embed or its footer doesn't match the regex"
);
return; return;
} }
@ -101,19 +108,28 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
if (!ulong.TryParse(match.Groups[3].Value, out var authorId)) if (!ulong.TryParse(match.Groups[3].Value, out var authorId))
{ {
_logger.Debug("Author ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID); _logger.Debug(
"Author ID in PluralKit log {LogMessageId} was not a valid snowflake",
msg.ID
);
return; return;
} }
if (!ulong.TryParse(match.Groups[4].Value, out var msgId)) if (!ulong.TryParse(match.Groups[4].Value, out var msgId))
{ {
_logger.Debug("Message ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID); _logger.Debug(
"Message ID in PluralKit log {LogMessageId} was not a valid snowflake",
msg.ID
);
return; return;
} }
if (!ulong.TryParse(match.Groups[5].Value, out var originalId)) if (!ulong.TryParse(match.Groups[5].Value, out var originalId))
{ {
_logger.Debug("Original ID in PluralKit log {LogMessageId} was not a valid snowflake", msg.ID); _logger.Debug(
"Original ID in PluralKit log {LogMessageId} was not a valid snowflake",
msg.ID
);
return; return;
} }
@ -121,8 +137,13 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>(); await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var messageRepository = scope.ServiceProvider.GetRequiredService<MessageRepository>(); var messageRepository = scope.ServiceProvider.GetRequiredService<MessageRepository>();
await messageRepository.SetProxiedMessageDataAsync(msgId, originalId, authorId, await messageRepository.SetProxiedMessageDataAsync(
systemId: match.Groups[1].Value, memberId: match.Groups[2].Value); msgId,
originalId,
authorId,
systemId: match.Groups[1].Value,
memberId: match.Groups[2].Value
);
db.IgnoredMessages.Add(new IgnoredMessage(originalId)); db.IgnoredMessages.Add(new IgnoredMessage(originalId));
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@ -144,17 +165,26 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
return; return;
} }
if (hasProxyInfo) return; if (hasProxyInfo)
return;
var pkMessage = await pluralkitApi.GetPluralKitMessageAsync(msgId); var pkMessage = await pluralkitApi.GetPluralKitMessageAsync(msgId);
if (pkMessage == null) if (pkMessage == null)
{ {
_logger.Debug("Message with ID {MessageId} was proxied by PluralKit, but API returned 404", msgId); _logger.Debug(
"Message with ID {MessageId} was proxied by PluralKit, but API returned 404",
msgId
);
return; return;
} }
await messageRepository.SetProxiedMessageDataAsync(msgId, pkMessage.Original, pkMessage.Sender, await messageRepository.SetProxiedMessageDataAsync(
pkMessage.System?.Id, pkMessage.Member?.Id); msgId,
pkMessage.Original,
pkMessage.Sender,
pkMessage.System?.Id,
pkMessage.Member?.Id
);
db.IgnoredMessages.Add(new IgnoredMessage(pkMessage.Original)); db.IgnoredMessages.Add(new IgnoredMessage(pkMessage.Original));
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -24,41 +24,55 @@ public class MessageDeleteResponder(
ChannelCache channelCache, ChannelCache channelCache,
UserCache userCache, UserCache userCache,
IClock clock, IClock clock,
PluralkitApiService pluralkitApi) : IResponder<IMessageDelete> PluralkitApiService pluralkitApi
) : IResponder<IMessageDelete>
{ {
private readonly ILogger _logger = logger.ForContext<MessageDeleteResponder>(); private readonly ILogger _logger = logger.ForContext<MessageDeleteResponder>();
private static bool MaybePkProxyTrigger(Snowflake id) => id.Timestamp > DateTimeOffset.Now - 1.Minutes(); private static bool MaybePkProxyTrigger(Snowflake id) =>
id.Timestamp > DateTimeOffset.Now - 1.Minutes();
public async Task<Result> RespondAsync(IMessageDelete ev, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageDelete ev, CancellationToken ct = default)
{ {
if (!ev.GuildID.IsDefined()) return Result.Success; if (!ev.GuildID.IsDefined())
return Result.Success;
if (MaybePkProxyTrigger(ev.ID)) if (MaybePkProxyTrigger(ev.ID))
{ {
_logger.Debug( _logger.Debug(
"Deleted message {MessageId} is less than 1 minute old, delaying 5 seconds to give PK time to catch up", "Deleted message {MessageId} is less than 1 minute old, delaying 5 seconds to give PK time to catch up",
ev.ID); ev.ID
);
await Task.Delay(5.Seconds(), ct); await Task.Delay(5.Seconds(), ct);
} }
if (await messageRepository.IsMessageIgnoredAsync(ev.ID.Value, ct)) return Result.Success; if (await messageRepository.IsMessageIgnoredAsync(ev.ID.Value, ct))
return Result.Success;
var guild = await db.GetGuildAsync(ev.GuildID, ct); var guild = await db.GetGuildAsync(ev.GuildID, ct);
if (guild.IsMessageIgnored(ev.ChannelID, ev.ID)) return Result.Success; if (guild.IsMessageIgnored(ev.ChannelID, ev.ID))
return Result.Success;
var logChannel = webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, ev.ChannelID); var logChannel = webhookExecutor.GetLogChannel(
guild,
LogChannelType.MessageDelete,
ev.ChannelID
);
var msg = await messageRepository.GetMessageAsync(ev.ID.Value, ct); var msg = await messageRepository.GetMessageAsync(ev.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)
{ {
if (logChannel == null) return Result.Success; if (logChannel == null)
webhookExecutor.QueueLog(logChannel.Value, new Embed( return Result.Success;
webhookExecutor.QueueLog(
logChannel.Value,
new Embed(
Title: "Message deleted", Title: "Message deleted",
Description: $"A message not found in the database was deleted in <#{ev.ChannelID}> ({ev.ChannelID}).", Description: $"A message not found in the database was deleted in <#{ev.ChannelID}> ({ev.ChannelID}).",
Footer: new EmbedFooter(Text: $"ID: {ev.ID}"), Footer: new EmbedFooter(Text: $"ID: {ev.ID}"),
Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() Timestamp: clock.GetCurrentInstant().ToDateTimeOffset()
)); )
);
return Result.Success; return Result.Success;
} }
@ -71,13 +85,22 @@ public class MessageDeleteResponder(
var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(ev.ID.Value, ct); var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(ev.ID.Value, ct);
if (pkMsg != null && pkMsg.Id != ev.ID.Value && pkMsg.Original != ev.ID.Value) if (pkMsg != null && pkMsg.Id != ev.ID.Value && pkMsg.Original != ev.ID.Value)
{ {
_logger.Debug("Deleted message {MessageId} is a `pk;edit` message, ignoring", ev.ID); _logger.Debug(
"Deleted message {MessageId} is a `pk;edit` message, ignoring",
ev.ID
);
return Result.Success; return Result.Success;
} }
} }
logChannel = webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, ev.ChannelID, msg.UserId); logChannel = webhookExecutor.GetLogChannel(
if (logChannel == null) return Result.Success; guild,
LogChannelType.MessageDelete,
ev.ChannelID,
msg.UserId
);
if (logChannel == null)
return Result.Success;
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()
@ -89,23 +112,34 @@ public class MessageDeleteResponder(
if (user != null) if (user != null)
builder.WithAuthor(user.Tag(), url: null, iconUrl: user.AvatarUrl()); builder.WithAuthor(user.Tag(), url: null, iconUrl: user.AvatarUrl());
if (msg.Member != null) builder.WithTitle($"Message by {msg.Username} deleted"); if (msg.Member != null)
builder.WithTitle($"Message by {msg.Username} deleted");
string channelMention; string channelMention;
if (!channelCache.TryGet(ev.ChannelID, out var channel)) if (!channelCache.TryGet(ev.ChannelID, out var channel))
channelMention = $"<#{msg.ChannelId}>"; channelMention = $"<#{msg.ChannelId}>";
else if (channel.Type is ChannelType.AnnouncementThread or ChannelType.PrivateThread else if (
or ChannelType.PublicThread) channel.Type
is ChannelType.AnnouncementThread
or ChannelType.PrivateThread
or ChannelType.PublicThread
)
channelMention = channelMention =
$"<#{channel.ParentID.Value}>\nID: {channel.ParentID.Value}\n\nThread: {channel.Name} (<#{channel.ID}>)"; $"<#{channel.ParentID.Value}>\nID: {channel.ParentID.Value}\n\nThread: {channel.Name} (<#{channel.ID}>)";
else channelMention = $"<#{channel.ID}>\nID: {channel.ID}"; else
channelMention = $"<#{channel.ID}>\nID: {channel.ID}";
var userMention = user != null var userMention =
user != null
? $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}" ? $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}"
: $"<@{msg.UserId}>\nID: {msg.UserId}"; : $"<@{msg.UserId}>\nID: {msg.UserId}";
builder.AddField("Channel", channelMention, true); builder.AddField("Channel", channelMention, true);
builder.AddField(msg.System != null ? "Linked Discord account" : "Sender", userMention, true); builder.AddField(
msg.System != null ? "Linked Discord account" : "Sender",
userMention,
true
);
if (msg is { System: not null, Member: not null }) if (msg is { System: not null, Member: not null })
{ {
builder.AddField("\u200b", "**PluralKit information**", false); builder.AddField("\u200b", "**PluralKit information**", false);
@ -115,10 +149,14 @@ public class MessageDeleteResponder(
if (msg.Metadata != null) if (msg.Metadata != null)
{ {
var attachmentInfo = string.Join("\n", var attachmentInfo = string.Join(
"\n",
msg.Metadata.Attachments.Select(a => msg.Metadata.Attachments.Select(a =>
$"{a.Filename} ({a.ContentType}, {a.Size.Bytes().Humanize()})")); $"{a.Filename} ({a.ContentType}, {a.Size.Bytes().Humanize()})"
if (!string.IsNullOrWhiteSpace(attachmentInfo)) builder.AddField("Attachments", attachmentInfo, false); )
);
if (!string.IsNullOrWhiteSpace(attachmentInfo))
builder.AddField("Attachments", attachmentInfo, false);
} }
webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow()); webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow());

View file

@ -21,7 +21,8 @@ public class MessageUpdateResponder(
UserCache userCache, UserCache userCache,
MessageRepository messageRepository, MessageRepository messageRepository,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
PluralkitApiService pluralkitApi) : IResponder<IMessageUpdate> PluralkitApiService pluralkitApi
) : IResponder<IMessageUpdate>
{ {
private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>();
@ -33,8 +34,10 @@ public class MessageUpdateResponder(
if (!msg.GuildID.IsDefined()) if (!msg.GuildID.IsDefined())
{ {
_logger.Debug("Received message create event for message {MessageId} despite it not being in a guild", _logger.Debug(
msg.ID); "Received message create event for message {MessageId} despite it not being in a guild",
msg.ID
);
return Result.Success; return Result.Success;
} }
@ -48,25 +51,39 @@ public class MessageUpdateResponder(
try try
{ {
var logChannel = webhookExecutor.GetLogChannel(guildConfig, LogChannelType.MessageUpdate, msg.ChannelID, var logChannel = webhookExecutor.GetLogChannel(
msg.Author.ID.Value); guildConfig,
if (logChannel == null) return Result.Success; LogChannelType.MessageUpdate,
msg.ChannelID,
msg.Author.ID.Value
);
if (logChannel == null)
return Result.Success;
var oldMessage = await messageRepository.GetMessageAsync(msg.ID.Value, ct); var oldMessage = await messageRepository.GetMessageAsync(msg.ID.Value, ct);
if (oldMessage == null) if (oldMessage == null)
{ {
logger.Debug("Message {Id} was edited and should be logged but is not in the database", msg.ID); logger.Debug(
"Message {Id} was edited and should be logged but is not in the database",
msg.ID
);
return Result.Success; return Result.Success;
} }
if (oldMessage.Content == msg.Content || if (
(oldMessage.Content == "None" && string.IsNullOrEmpty(msg.Content))) return Result.Success; oldMessage.Content == msg.Content
|| (oldMessage.Content == "None" && string.IsNullOrEmpty(msg.Content))
)
return Result.Success;
var user = msg.Author; var user = msg.Author;
if (msg.Author.ID != oldMessage.UserId) if (msg.Author.ID != oldMessage.UserId)
{ {
var systemAccount = await userCache.GetUserAsync(DiscordSnowflake.New(oldMessage.UserId)); var systemAccount = await userCache.GetUserAsync(
if (systemAccount != null) user = systemAccount; DiscordSnowflake.New(oldMessage.UserId)
);
if (systemAccount != null)
user = systemAccount;
} }
var embedBuilder = new EmbedBuilder() var embedBuilder = new EmbedBuilder()
@ -78,19 +95,25 @@ public class MessageUpdateResponder(
.WithTimestamp(msg.ID.Timestamp); .WithTimestamp(msg.ID.Timestamp);
var fields = ChunksUpTo(msg.Content, 1000) var fields = ChunksUpTo(msg.Content, 1000)
.Select<string, IEmbedField>((s, i) => .Select<string, IEmbedField>(
new EmbedField($"New content{(i != 0 ? " (cont.)" : "")}", s, false)) (s, i) => new EmbedField($"New content{(i != 0 ? " (cont.)" : "")}", s, false)
)
.ToList(); .ToList();
embedBuilder.SetFields(fields); embedBuilder.SetFields(fields);
string channelMention; string channelMention;
if (!channelCache.TryGet(msg.ChannelID, out var channel)) if (!channelCache.TryGet(msg.ChannelID, out var channel))
channelMention = $"<#{msg.ChannelID}>"; channelMention = $"<#{msg.ChannelID}>";
else if (channel.Type is ChannelType.AnnouncementThread or ChannelType.PrivateThread else if (
or ChannelType.PublicThread) channel.Type
is ChannelType.AnnouncementThread
or ChannelType.PrivateThread
or ChannelType.PublicThread
)
channelMention = channelMention =
$"<#{channel.ParentID.Value}>\nID: {channel.ParentID.Value}\n\nThread: {channel.Name} (<#{channel.ID}>)"; $"<#{channel.ParentID.Value}>\nID: {channel.ParentID.Value}\n\nThread: {channel.Name} (<#{channel.ID}>)";
else channelMention = $"<#{channel.ID}>\nID: {channel.ID}"; else
channelMention = $"<#{channel.ID}>\nID: {channel.ID}";
embedBuilder.AddField("Channel", channelMention, true); embedBuilder.AddField("Channel", channelMention, true);
embedBuilder.AddField("Sender", $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}", true); embedBuilder.AddField("Sender", $"<@{user.ID}>\n{user.Tag()}\nID: {user.ID}", true);
@ -103,7 +126,10 @@ public class MessageUpdateResponder(
embedBuilder.AddField("Member ID", oldMessage.Member, true); embedBuilder.AddField("Member ID", oldMessage.Member, true);
} }
embedBuilder.AddField("Link", $"https://discord.com/channels/{msg.GuildID}/{msg.ChannelID}/{msg.ID}"); embedBuilder.AddField(
"Link",
$"https://discord.com/channels/{msg.GuildID}/{msg.ChannelID}/{msg.ID}"
);
webhookExecutor.QueueLog(logChannel.Value, embedBuilder.Build().GetOrThrow()); webhookExecutor.QueueLog(logChannel.Value, embedBuilder.Build().GetOrThrow());
return Result.Success; return Result.Success;
@ -113,35 +139,87 @@ public class MessageUpdateResponder(
// Messages should be *saved* if any of the message events are enabled for this channel, but should only // Messages should be *saved* if any of the message events are enabled for this channel, but should only
// be *logged* if the MessageUpdate event is enabled, so we check if we should save here. // be *logged* if the MessageUpdate event is enabled, so we check if we should save here.
// You also can't return early in `finally` blocks, so this has to be nested :( // You also can't return early in `finally` blocks, so this has to be nested :(
if (webhookExecutor.GetLogChannel(guildConfig, LogChannelType.MessageUpdate, msg.ChannelID, if (
msg.Author.ID.Value) != null || webhookExecutor.GetLogChannel(guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.MessageDelete, msg.ChannelID, guildConfig,
msg.Author.ID.Value) != null || webhookExecutor.GetLogChannel(guildConfig, LogChannelType.MessageUpdate,
LogChannelType.MessageDeleteBulk, msg.ChannelID, msg.ChannelID,
msg.Author.ID.Value) != null) msg.Author.ID.Value
) != null
|| webhookExecutor.GetLogChannel(
guildConfig,
LogChannelType.MessageDelete,
msg.ChannelID,
msg.Author.ID.Value
) != null
|| webhookExecutor.GetLogChannel(
guildConfig,
LogChannelType.MessageDeleteBulk,
msg.ChannelID,
msg.Author.ID.Value
) != null
)
{ {
if (!await messageRepository.UpdateMessageAsync(msg, ct) && msg.ApplicationID.Is(DiscordUtils.PkUserId)) if (
!await messageRepository.UpdateMessageAsync(msg, ct)
&& msg.ApplicationID.Is(DiscordUtils.PkUserId)
)
{ {
_logger.Debug( _logger.Debug(
"Message {MessageId} wasn't stored yet and was proxied by PluralKit, fetching proxy information from its API", "Message {MessageId} wasn't stored yet and was proxied by PluralKit, fetching proxy information from its API",
msg.ID); msg.ID
);
var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(msg.ID.Value, ct); var pkMsg = await pluralkitApi.GetPluralKitMessageAsync(msg.ID.Value, ct);
if (pkMsg != null) if (pkMsg != null)
await messageRepository.SetProxiedMessageDataAsync(msg.ID.Value, pkMsg.Original, pkMsg.Sender, await messageRepository.SetProxiedMessageDataAsync(
pkMsg.System?.Id, pkMsg.Member?.Id); msg.ID.Value,
pkMsg.Original,
pkMsg.Sender,
pkMsg.System?.Id,
pkMsg.Member?.Id
);
} }
} }
} }
} }
private static MessageCreate ConvertToMessageCreate(IMessageUpdate evt) => new(evt.GuildID, evt.Member, private static MessageCreate ConvertToMessageCreate(IMessageUpdate evt) =>
evt.Mentions.GetOrThrow(), evt.ID.GetOrThrow(), evt.ChannelID.GetOrThrow(), evt.Author.GetOrThrow(), new(
evt.Content.GetOrThrow(), evt.Timestamp.GetOrThrow(), evt.EditedTimestamp.GetOrThrow(), IsTTS: false, evt.GuildID,
evt.MentionsEveryone.GetOrThrow(), evt.MentionedRoles.GetOrThrow(), evt.MentionedChannels, evt.Member,
evt.Attachments.GetOrThrow(), evt.Embeds.GetOrThrow(), evt.Reactions, evt.Nonce, evt.IsPinned.GetOrThrow(), evt.Mentions.GetOrThrow(),
evt.WebhookID, evt.Type.GetOrThrow(), evt.Activity, evt.Application, evt.ApplicationID, evt.MessageReference, evt.ID.GetOrThrow(),
evt.Flags, evt.ReferencedMessage, evt.Interaction, evt.Thread, evt.Components, evt.StickerItems, evt.Position, evt.ChannelID.GetOrThrow(),
evt.Resolved, evt.InteractionMetadata, evt.Poll); 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)
{ {

View file

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

View file

@ -14,7 +14,8 @@ public class RoleCreateResponder(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
RoleCache roleCache, RoleCache roleCache,
WebhookExecutorService webhookExecutor) : IResponder<IGuildRoleCreate> WebhookExecutorService webhookExecutor
) : IResponder<IGuildRoleCreate>
{ {
private readonly ILogger _logger = logger.ForContext<RoleCreateResponder>(); private readonly ILogger _logger = logger.ForContext<RoleCreateResponder>();
@ -28,15 +29,21 @@ public class RoleCreateResponder(
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithTitle("Role created") .WithTitle("Role created")
.WithColour(DiscordUtils.Green) .WithColour(DiscordUtils.Green)
.WithDescription($"**Name:** {evt.Role.Name}\n**Colour:** {evt.Role.Colour.ToPrettyString()}" + .WithDescription(
$"\n**Mentionable:** {evt.Role.IsMentionable}\n**Shown separately:** {evt.Role.IsHoisted}"); $"**Name:** {evt.Role.Name}\n**Colour:** {evt.Role.Colour.ToPrettyString()}"
+ $"\n**Mentionable:** {evt.Role.IsMentionable}\n**Shown separately:** {evt.Role.IsHoisted}"
);
if (!evt.Role.Permissions.Value.IsZero) if (!evt.Role.Permissions.Value.IsZero)
{ {
embed.AddField("Permissions", evt.Role.Permissions.ToPrettyString(), inline: false); embed.AddField("Permissions", evt.Role.Permissions.ToPrettyString(), inline: false);
} }
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildRoleCreate, embed.Build().GetOrThrow()); webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildRoleCreate,
embed.Build().GetOrThrow()
);
return Result.Success; return Result.Success;
} }

View file

@ -12,10 +12,12 @@ using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Roles; namespace Catalogger.Backend.Bot.Responders.Roles;
public class RoleUpdateResponder(ILogger logger, public class RoleUpdateResponder(
ILogger logger,
DatabaseContext db, DatabaseContext db,
RoleCache roleCache, RoleCache roleCache,
WebhookExecutorService webhookExecutor) : IResponder<IGuildRoleUpdate> WebhookExecutorService webhookExecutor
) : IResponder<IGuildRoleUpdate>
{ {
private readonly ILogger _logger = logger.ForContext<RoleUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<RoleUpdateResponder>();
@ -27,7 +29,10 @@ public class RoleUpdateResponder(ILogger logger,
if (!roleCache.TryGet(evt.Role.ID, out var oldRole)) if (!roleCache.TryGet(evt.Role.ID, out var oldRole))
{ {
_logger.Information("Received role update event for {RoleId} but it wasn't cached, ignoring", evt.Role.ID); _logger.Information(
"Received role update event for {RoleId} but it wasn't cached, ignoring",
evt.Role.ID
);
return Result.Success; return Result.Success;
} }
@ -42,21 +47,31 @@ public class RoleUpdateResponder(ILogger logger,
embed.AddField("Name", $"**Before:** {oldRole.Name}\n**After:** {newRole.Name}"); embed.AddField("Name", $"**Before:** {oldRole.Name}\n**After:** {newRole.Name}");
} }
if (newRole.IsHoisted != oldRole.IsHoisted || newRole.IsMentionable != oldRole.IsMentionable) if (
newRole.IsHoisted != oldRole.IsHoisted
|| newRole.IsMentionable != oldRole.IsMentionable
)
{ {
embed.AddField( embed.AddField(
"\u200b", $"**Mentionable:** {newRole.IsMentionable}\n**Shown separately:** {newRole.IsHoisted}"); "\u200b",
$"**Mentionable:** {newRole.IsMentionable}\n**Shown separately:** {newRole.IsHoisted}"
);
} }
if (newRole.Colour != oldRole.Colour) if (newRole.Colour != oldRole.Colour)
{ {
embed.AddField("Colour", embed.AddField(
$"**Before:** {oldRole.Colour.ToPrettyString()}\n**After:** {newRole.Colour.ToPrettyString()}"); "Colour",
$"**Before:** {oldRole.Colour.ToPrettyString()}\n**After:** {newRole.Colour.ToPrettyString()}"
);
} }
if (newRole.Permissions.Value != oldRole.Permissions.Value) if (newRole.Permissions.Value != oldRole.Permissions.Value)
{ {
var diff = string.Join("\n", PermissionUpdate(oldRole.Permissions, newRole.Permissions)); var diff = string.Join(
"\n",
PermissionUpdate(oldRole.Permissions, newRole.Permissions)
);
embed.AddField("Permissions", $"```diff\n{diff}\n```"); embed.AddField("Permissions", $"```diff\n{diff}\n```");
} }
@ -69,7 +84,11 @@ public class RoleUpdateResponder(ILogger logger,
} }
var guildConfig = await db.GetGuildAsync(evt.GuildID, ct); var guildConfig = await db.GetGuildAsync(evt.GuildID, ct);
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildRoleUpdate, embed.Build().GetOrThrow()); webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildRoleUpdate,
embed.Build().GetOrThrow()
);
} }
finally finally
{ {
@ -79,7 +98,10 @@ public class RoleUpdateResponder(ILogger logger,
return Result.Success; return Result.Success;
} }
private static IEnumerable<string> PermissionUpdate(IDiscordPermissionSet oldValue, IDiscordPermissionSet newValue) private static IEnumerable<string> PermissionUpdate(
IDiscordPermissionSet oldValue,
IDiscordPermissionSet newValue
)
{ {
foreach (var perm in Enum.GetValues<DiscordPermission>()) foreach (var perm in Enum.GetValues<DiscordPermission>())
{ {

View file

@ -2,7 +2,8 @@ using Remora.Discord.Gateway.Results;
namespace Catalogger.Backend.Bot; namespace Catalogger.Backend.Bot;
public class ShardedDiscordService(ShardedGatewayClient client, IHostApplicationLifetime lifetime) : BackgroundService public class ShardedDiscordService(ShardedGatewayClient client, IHostApplicationLifetime lifetime)
: BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {

View file

@ -16,18 +16,22 @@ public class ShardedGatewayClient(
IDiscordRestGatewayAPI gatewayApi, IDiscordRestGatewayAPI gatewayApi,
IServiceProvider services, IServiceProvider services,
IOptions<DiscordGatewayClientOptions> gatewayClientOptions, IOptions<DiscordGatewayClientOptions> gatewayClientOptions,
Config config) Config config
: IDisposable ) : IDisposable
{ {
private int _shardCount = config.Discord.ShardCount ?? 0; private int _shardCount = config.Discord.ShardCount ?? 0;
private readonly ILogger _logger = logger.ForContext<ShardedGatewayClient>(); private readonly ILogger _logger = logger.ForContext<ShardedGatewayClient>();
private readonly ConcurrentDictionary<int, DiscordGatewayClient> _gatewayClients = new(); private readonly ConcurrentDictionary<int, DiscordGatewayClient> _gatewayClients = new();
private static readonly FieldInfo Field = private static readonly FieldInfo Field = typeof(DiscordGatewayClient).GetField(
typeof(DiscordGatewayClient).GetField("_connectionStatus", BindingFlags.Instance | BindingFlags.NonPublic)!; "_connectionStatus",
BindingFlags.Instance | BindingFlags.NonPublic
)!;
private static readonly Func<DiscordGatewayClient, GatewayConnectionStatus> GetConnectionStatus = private static readonly Func<
client => (GatewayConnectionStatus)Field.GetValue(client)!; DiscordGatewayClient,
GatewayConnectionStatus
> GetConnectionStatus = client => (GatewayConnectionStatus)Field.GetValue(client)!;
public IReadOnlyDictionary<int, DiscordGatewayClient> Shards => _gatewayClients; public IReadOnlyDictionary<int, DiscordGatewayClient> Shards => _gatewayClients;
@ -45,19 +49,26 @@ public class ShardedGatewayClient(
if (_shardCount < discordShardCount && _shardCount != 0) if (_shardCount < discordShardCount && _shardCount != 0)
_logger.Warning( _logger.Warning(
"Discord recommends {DiscordShardCount} for this bot, but only {ConfigShardCount} shards are requested. This may cause issues later", "Discord recommends {DiscordShardCount} for this bot, but only {ConfigShardCount} shards are requested. This may cause issues later",
discordShardCount, _shardCount); discordShardCount,
_shardCount
);
if (_shardCount == 0) _shardCount = discordShardCount; if (_shardCount == 0)
_shardCount = discordShardCount;
} }
var clients = Enumerable.Range(0, _shardCount).Select(s => var clients = Enumerable
.Range(0, _shardCount)
.Select(s =>
{ {
var client = var client = ActivatorUtilities.CreateInstance<DiscordGatewayClient>(
ActivatorUtilities.CreateInstance<DiscordGatewayClient>(services, services,
CloneOptions(gatewayClientOptions.Value, s)); CloneOptions(gatewayClientOptions.Value, s)
);
_gatewayClients[s] = client; _gatewayClients[s] = client;
return client; return client;
}).ToArray(); })
.ToArray();
var tasks = new List<Task<Result>>(); var tasks = new List<Task<Result>>();
@ -69,7 +80,10 @@ public class ShardedGatewayClient(
var res = client.RunAsync(ct); var res = client.RunAsync(ct);
tasks.Add(res); tasks.Add(res);
while (GetConnectionStatus(client) is not GatewayConnectionStatus.Connected && !res.IsCompleted) while (
GetConnectionStatus(client) is not GatewayConnectionStatus.Connected
&& !res.IsCompleted
)
{ {
await Task.Delay(100, ct); await Task.Delay(100, ct);
} }
@ -92,7 +106,9 @@ public class ShardedGatewayClient(
public DiscordGatewayClient ClientFor(ulong guildId) => public DiscordGatewayClient ClientFor(ulong guildId) =>
_gatewayClients.TryGetValue(ShardIdFor(guildId), out var client) _gatewayClients.TryGetValue(ShardIdFor(guildId), out var client)
? client ? client
: throw new CataloggerError("Shard was null, has ShardedGatewayClient.RunAsync been called?"); : throw new CataloggerError(
"Shard was null, has ShardedGatewayClient.RunAsync been called?"
);
public void Dispose() public void Dispose()
{ {
@ -100,7 +116,10 @@ public class ShardedGatewayClient(
client.Dispose(); client.Dispose();
} }
private IOptions<DiscordGatewayClientOptions> CloneOptions(DiscordGatewayClientOptions options, int shardId) private IOptions<DiscordGatewayClientOptions> CloneOptions(
DiscordGatewayClientOptions options,
int shardId
)
{ {
var ret = new DiscordGatewayClientOptions var ret = new DiscordGatewayClientOptions
{ {
@ -112,7 +131,7 @@ public class ShardedGatewayClient(
LargeThreshold = options.LargeThreshold, LargeThreshold = options.LargeThreshold,
CommandBurstRate = options.CommandBurstRate, CommandBurstRate = options.CommandBurstRate,
HeartbeatSafetyMargin = options.HeartbeatSafetyMargin, HeartbeatSafetyMargin = options.HeartbeatSafetyMargin,
MinimumSafetyMargin = options.MinimumSafetyMargin MinimumSafetyMargin = options.MinimumSafetyMargin,
}; };
return Options.Create(ret); return Options.Create(ret);

View file

@ -10,13 +10,21 @@ public interface IWebhookCache
Task<Webhook?> GetWebhookAsync(ulong channelId); Task<Webhook?> GetWebhookAsync(ulong channelId);
Task SetWebhookAsync(ulong channelId, Webhook webhook); Task SetWebhookAsync(ulong channelId, Webhook webhook);
public async Task<Webhook> GetOrFetchWebhookAsync(ulong channelId, Func<Snowflake, Task<IWebhook>> fetch) public async Task<Webhook> GetOrFetchWebhookAsync(
ulong channelId,
Func<Snowflake, Task<IWebhook>> fetch
)
{ {
var webhook = await GetWebhookAsync(channelId); var webhook = await GetWebhookAsync(channelId);
if (webhook != null) return webhook.Value; if (webhook != null)
return webhook.Value;
var discordWebhook = await fetch(DiscordSnowflake.New(channelId)); var discordWebhook = await fetch(DiscordSnowflake.New(channelId));
webhook = new Webhook { Id = discordWebhook.ID.ToUlong(), Token = discordWebhook.Token.Value }; webhook = new Webhook
{
Id = discordWebhook.ID.ToUlong(),
Token = discordWebhook.Token.Value,
};
await SetWebhookAsync(channelId, webhook.Value); await SetWebhookAsync(channelId, webhook.Value);
return webhook.Value; return webhook.Value;
} }

View file

@ -6,10 +6,21 @@ namespace Catalogger.Backend.Cache.InMemoryCache;
public class AuditLogCache public class AuditLogCache
{ {
private readonly ConcurrentDictionary<(Snowflake GuildId, Snowflake TargetId), ActionData> _kicks = new(); private readonly ConcurrentDictionary<
private readonly ConcurrentDictionary<(Snowflake GuildId, Snowflake TargetId), ActionData> _bans = new(); (Snowflake GuildId, Snowflake TargetId),
ActionData
> _kicks = new();
private readonly ConcurrentDictionary<
(Snowflake GuildId, Snowflake TargetId),
ActionData
> _bans = new();
public void SetKick(Snowflake guildId, string targetId, Snowflake moderatorId, Optional<string> reason) public void SetKick(
Snowflake guildId,
string targetId,
Snowflake moderatorId,
Optional<string> reason
)
{ {
if (!DiscordSnowflake.TryParse(targetId, out var targetUser)) if (!DiscordSnowflake.TryParse(targetId, out var targetUser))
throw new CataloggerError("Target ID was not a valid snowflake"); throw new CataloggerError("Target ID was not a valid snowflake");
@ -20,7 +31,12 @@ public class AuditLogCache
public bool TryGetKick(Snowflake guildId, Snowflake targetId, out ActionData data) => public bool TryGetKick(Snowflake guildId, Snowflake targetId, out ActionData data) =>
_kicks.TryGetValue((guildId, targetId), out data); _kicks.TryGetValue((guildId, targetId), out data);
public void SetBan(Snowflake guildId, string targetId, Snowflake moderatorId, Optional<string> reason) public void SetBan(
Snowflake guildId,
string targetId,
Snowflake moderatorId,
Optional<string> reason
)
{ {
if (!DiscordSnowflake.TryParse(targetId, out var targetUser)) if (!DiscordSnowflake.TryParse(targetId, out var targetUser))
throw new CataloggerError("Target ID was not a valid snowflake"); throw new CataloggerError("Target ID was not a valid snowflake");

View file

@ -17,18 +17,21 @@ public class ChannelCache
_channels[channel.ID] = channel; _channels[channel.ID] = channel;
if (guildId == null) if (guildId == null)
{ {
if (!channel.GuildID.TryGet(out var snowflake)) return; if (!channel.GuildID.TryGet(out var snowflake))
return;
guildId = snowflake; guildId = snowflake;
} }
// Add to set of guild channels // Add to set of guild channels
_guildChannels.AddOrUpdate(guildId.Value, _guildChannels.AddOrUpdate(
guildId.Value,
_ => [channel.ID], _ => [channel.ID],
(_, l) => (_, l) =>
{ {
l.Add(channel.ID); l.Add(channel.ID);
return l; return l;
}); }
);
} }
public bool TryGet(Snowflake id, [NotNullWhen(true)] out IChannel? channel) => public bool TryGet(Snowflake id, [NotNullWhen(true)] out IChannel? channel) =>
@ -37,13 +40,18 @@ public class ChannelCache
public void Remove(Snowflake? guildId, Snowflake id, out IChannel? channel) public void Remove(Snowflake? guildId, Snowflake id, out IChannel? channel)
{ {
_channels.Remove(id, out channel); _channels.Remove(id, out channel);
if (guildId == null) return; if (guildId == null)
return;
// Remove from set of guild channels // Remove from set of guild channels
_guildChannels.AddOrUpdate(guildId.Value, _ => [], (_, s) => _guildChannels.AddOrUpdate(
guildId.Value,
_ => [],
(_, s) =>
{ {
s.Remove(id); s.Remove(id);
return s; return s;
}); }
);
} }
/// <summary> /// <summary>
@ -54,6 +62,8 @@ public class ChannelCache
public IEnumerable<IChannel> GuildChannels(Snowflake guildId) => public IEnumerable<IChannel> GuildChannels(Snowflake guildId) =>
!_guildChannels.TryGetValue(guildId, out var channelIds) !_guildChannels.TryGetValue(guildId, out var channelIds)
? [] ? []
: channelIds.Select(id => _channels.GetValueOrDefault(id)) : channelIds
.Where(c => c != null).Select(c => c!); .Select(id => _channels.GetValueOrDefault(id))
.Where(c => c != null)
.Select(c => c!);
} }

View file

@ -12,6 +12,10 @@ public class GuildCache
public int Size => _guilds.Count; public int Size => _guilds.Count;
public void Set(IGuild guild) => _guilds[guild.ID] = guild; public void Set(IGuild guild) => _guilds[guild.ID] = guild;
public bool Remove(Snowflake id, [NotNullWhen(true)] out IGuild? guild) => _guilds.Remove(id, out guild);
public bool TryGet(Snowflake id, [NotNullWhen(true)] out IGuild? guild) => _guilds.TryGetValue(id, out guild); public bool Remove(Snowflake id, [NotNullWhen(true)] out IGuild? guild) =>
_guilds.Remove(id, out guild);
public bool TryGet(Snowflake id, [NotNullWhen(true)] out IGuild? guild) =>
_guilds.TryGetValue(id, out guild);
} }

View file

@ -8,7 +8,8 @@ public class InMemoryInviteCache : IInviteCache
{ {
private readonly ConcurrentDictionary<Snowflake, IEnumerable<IInvite>> _invites = new(); private readonly ConcurrentDictionary<Snowflake, IEnumerable<IInvite>> _invites = new();
public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) => _invites.TryGetValue(guildId, out var invites) public Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) =>
_invites.TryGetValue(guildId, out var invites)
? Task.FromResult(invites) ? Task.FromResult(invites)
: Task.FromResult<IEnumerable<IInvite>>([]); : Task.FromResult<IEnumerable<IInvite>>([]);

View file

@ -19,7 +19,9 @@ public class InMemoryMemberCache : IMemberCache
public Task SetAsync(Snowflake guildId, IGuildMember member) public Task SetAsync(Snowflake guildId, IGuildMember member)
{ {
if (!member.User.IsDefined()) if (!member.User.IsDefined())
throw new CataloggerError("Member with undefined User passed to InMemoryMemberCache.SetAsync"); throw new CataloggerError(
"Member with undefined User passed to InMemoryMemberCache.SetAsync"
);
_members[(guildId, member.User.Value.ID)] = member; _members[(guildId, member.User.Value.ID)] = member;
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -36,7 +38,8 @@ public class InMemoryMemberCache : IMemberCache
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task<bool> IsGuildCachedAsync(Snowflake guildId) => Task.FromResult(_guilds.ContainsKey(guildId)); public Task<bool> IsGuildCachedAsync(Snowflake guildId) =>
Task.FromResult(_guilds.ContainsKey(guildId));
public Task MarkAsCachedAsync(Snowflake guildId) public Task MarkAsCachedAsync(Snowflake guildId)
{ {

View file

@ -16,13 +16,15 @@ public class RoleCache
{ {
_roles[role.ID] = role; _roles[role.ID] = role;
// Add to set of guild channels // Add to set of guild channels
_guildRoles.AddOrUpdate(guildId, _guildRoles.AddOrUpdate(
guildId,
_ => [role.ID], _ => [role.ID],
(_, l) => (_, l) =>
{ {
l.Add(role.ID); l.Add(role.ID);
return l; return l;
}); }
);
} }
public bool TryGet(Snowflake id, [NotNullWhen(true)] out IRole? role) => public bool TryGet(Snowflake id, [NotNullWhen(true)] out IRole? role) =>
@ -32,16 +34,21 @@ public class RoleCache
{ {
_roles.Remove(id, out role); _roles.Remove(id, out role);
// Remove from set of guild channels // Remove from set of guild channels
_guildRoles.AddOrUpdate(guildId, _ => [], (_, s) => _guildRoles.AddOrUpdate(
guildId,
_ => [],
(_, s) =>
{ {
s.Remove(id); s.Remove(id);
return s; return s;
}); }
);
} }
public void RemoveGuild(Snowflake guildId) public void RemoveGuild(Snowflake guildId)
{ {
if (!_guildRoles.TryGetValue(guildId, out var roleIds)) return; if (!_guildRoles.TryGetValue(guildId, out var roleIds))
return;
foreach (var id in roleIds) foreach (var id in roleIds)
{ {
_roles.Remove(id, out _); _roles.Remove(id, out _);
@ -58,6 +65,8 @@ public class RoleCache
public IEnumerable<IRole> GuildRoles(Snowflake guildId) => public IEnumerable<IRole> GuildRoles(Snowflake guildId) =>
!_guildRoles.TryGetValue(guildId, out var roleIds) !_guildRoles.TryGetValue(guildId, out var roleIds)
? [] ? []
: roleIds.Select(id => _roles.GetValueOrDefault(id)) : roleIds
.Where(r => r != null).Select(r => r!); .Select(id => _roles.GetValueOrDefault(id))
.Where(r => r != null)
.Select(r => r!);
} }

View file

@ -13,13 +13,16 @@ public class UserCache(IDiscordRestUserAPI userApi)
public int Size => _cacheSize; public int Size => _cacheSize;
public async Task<IUser?> GetUserAsync(Snowflake userId) => await _cache.GetOrAddAsync(userId.ToString(), public async Task<IUser?> GetUserAsync(Snowflake userId) =>
await _cache.GetOrAddAsync(
userId.ToString(),
async () => async () =>
{ {
var user = await userApi.GetUserAsync(userId).GetOrThrow(); var user = await userApi.GetUserAsync(userId).GetOrThrow();
Interlocked.Increment(ref _cacheSize); Interlocked.Increment(ref _cacheSize);
return user; return user;
}); }
);
public void UpdateUser(IUser user) => _cache.Add(user.ID.ToString(), user); public void UpdateUser(IUser user) => _cache.Add(user.ID.ToString(), user);
} }

View file

@ -10,7 +10,8 @@ public class RedisInviteCache(RedisService redisService) : IInviteCache
{ {
public async Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId) public async Task<IEnumerable<IInvite>> TryGetAsync(Snowflake guildId)
{ {
var redisInvites = await redisService.GetAsync<List<RedisInvite>>(InvitesKey(guildId)) ?? []; var redisInvites =
await redisService.GetAsync<List<RedisInvite>>(InvitesKey(guildId)) ?? [];
return redisInvites.Select(r => r.ToRemoraInvite()); return redisInvites.Select(r => r.ToRemoraInvite());
} }
@ -25,15 +26,26 @@ internal record RedisInvite(
RedisPartialGuild? Guild, RedisPartialGuild? Guild,
RedisPartialChannel? Channel, RedisPartialChannel? Channel,
RedisUser? Inviter, RedisUser? Inviter,
DateTimeOffset? ExpiresAt) DateTimeOffset? ExpiresAt
)
{ {
public static RedisInvite FromIInvite(IInvite invite) => new(invite.Code, public static RedisInvite FromIInvite(IInvite invite) =>
new(
invite.Code,
invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(), invite.Guild.Map(RedisPartialGuild.FromIPartialGuild).OrDefault(),
invite.Channel != null ? RedisPartialChannel.FromIPartialChannel(invite.Channel) : null, invite.Channel != null ? RedisPartialChannel.FromIPartialChannel(invite.Channel) : null,
invite.Inviter.Map(RedisUser.FromIUser).OrDefault(), invite.ExpiresAt.OrDefault()); invite.Inviter.Map(RedisUser.FromIUser).OrDefault(),
invite.ExpiresAt.OrDefault()
);
public Invite ToRemoraInvite() => new(Code, Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(), public Invite ToRemoraInvite() =>
Channel?.ToRemoraPartialChannel(), Inviter?.ToRemoraUser() ?? new Optional<IUser>(), ExpiresAt: ExpiresAt); new(
Code,
Guild?.ToRemoraPartialGuild() ?? new Optional<IPartialGuild>(),
Channel?.ToRemoraPartialChannel(),
Inviter?.ToRemoraUser() ?? new Optional<IUser>(),
ExpiresAt: ExpiresAt
);
} }
internal record RedisPartialGuild(ulong Id, string? Name) internal record RedisPartialGuild(ulong Id, string? Name)
@ -41,7 +53,8 @@ internal record RedisPartialGuild(ulong Id, string? Name)
public static RedisPartialGuild FromIPartialGuild(IPartialGuild guild) => public static RedisPartialGuild FromIPartialGuild(IPartialGuild guild) =>
new(guild.ID.Value.Value, guild.Name.OrDefault(null)); new(guild.ID.Value.Value, guild.Name.OrDefault(null));
public PartialGuild ToRemoraPartialGuild() => new(DiscordSnowflake.New(Id), Name ?? new Optional<string>()); public PartialGuild ToRemoraPartialGuild() =>
new(DiscordSnowflake.New(Id), Name ?? new Optional<string>());
} }
internal record RedisPartialChannel(ulong Id, string? Name) internal record RedisPartialChannel(ulong Id, string? Name)

View file

@ -10,29 +10,45 @@ public class RedisMemberCache(RedisService redisService) : IMemberCache
{ {
public async Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId) public async Task<IGuildMember?> TryGetAsync(Snowflake guildId, Snowflake userId)
{ {
var redisMember = await redisService.GetHashAsync<RedisMember>(GuildMembersKey(guildId), userId.ToString()); var redisMember = await redisService.GetHashAsync<RedisMember>(
GuildMembersKey(guildId),
userId.ToString()
);
return redisMember?.ToRemoraMember(); return redisMember?.ToRemoraMember();
} }
public async Task SetAsync(Snowflake guildId, IGuildMember member) public async Task SetAsync(Snowflake guildId, IGuildMember member)
{ {
if (!member.User.IsDefined()) if (!member.User.IsDefined())
throw new CataloggerError("Member with undefined User passed to RedisMemberCache.SetAsync"); throw new CataloggerError(
await redisService.SetHashAsync(GuildMembersKey(guildId), member.User.Value.ID.ToString(), "Member with undefined User passed to RedisMemberCache.SetAsync"
RedisMember.FromIGuildMember(member)); );
await redisService.SetHashAsync(
GuildMembersKey(guildId),
member.User.Value.ID.ToString(),
RedisMember.FromIGuildMember(member)
);
} }
public async Task SetManyAsync(Snowflake guildId, IReadOnlyList<IGuildMember> members) public async Task SetManyAsync(Snowflake guildId, IReadOnlyList<IGuildMember> members)
{ {
if (members.Any(m => !m.User.IsDefined())) if (members.Any(m => !m.User.IsDefined()))
throw new CataloggerError("Member with undefined User passed to RedisMemberCache.SetAsync"); throw new CataloggerError(
"Member with undefined User passed to RedisMemberCache.SetAsync"
);
var redisMembers = members.Select(RedisMember.FromIGuildMember).ToList(); var redisMembers = members.Select(RedisMember.FromIGuildMember).ToList();
await redisService.SetHashAsync(GuildMembersKey(guildId), redisMembers, m => m.User.Id.ToString()); await redisService.SetHashAsync(
GuildMembersKey(guildId),
redisMembers,
m => m.User.Id.ToString()
);
} }
public async Task RemoveAsync(Snowflake guildId, Snowflake userId) => public async Task RemoveAsync(Snowflake guildId, Snowflake userId) =>
await redisService.GetDatabase().HashDeleteAsync(GuildMembersKey(guildId), userId.ToString()); await redisService
.GetDatabase()
.HashDeleteAsync(GuildMembersKey(guildId), userId.ToString());
public async Task<bool> IsGuildCachedAsync(Snowflake guildId) => public async Task<bool> IsGuildCachedAsync(Snowflake guildId) =>
await redisService.GetDatabase().SetContainsAsync(GuildCacheKey, guildId.ToString()); await redisService.GetDatabase().SetContainsAsync(GuildCacheKey, guildId.ToString());
@ -44,6 +60,7 @@ public class RedisMemberCache(RedisService redisService) : IMemberCache
await redisService.GetDatabase().SetRemoveAsync(GuildCacheKey, guildId.ToString()); await redisService.GetDatabase().SetRemoveAsync(GuildCacheKey, guildId.ToString());
private const string GuildCacheKey = "cached-guilds"; private const string GuildCacheKey = "cached-guilds";
private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}"; private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}";
} }
@ -56,16 +73,37 @@ internal record RedisMember(
DateTimeOffset? PremiumSince, DateTimeOffset? PremiumSince,
GuildMemberFlags Flags, GuildMemberFlags Flags,
bool? IsPending, bool? IsPending,
DateTimeOffset? CommunicationDisabledUntil) DateTimeOffset? CommunicationDisabledUntil
)
{ {
public static RedisMember FromIGuildMember(IGuildMember member) => new( public static RedisMember FromIGuildMember(IGuildMember member) =>
RedisUser.FromIUser(member.User.Value), member.Nickname.OrDefault(null), member.Avatar.OrDefault(null)?.Value, new(
member.Roles.ToArray(), member.JoinedAt, member.PremiumSince.OrDefault(null), member.Flags, RedisUser.FromIUser(member.User.Value),
member.IsPending.OrDefault(null), member.CommunicationDisabledUntil.OrDefault(null)); member.Nickname.OrDefault(null),
member.Avatar.OrDefault(null)?.Value,
member.Roles.ToArray(),
member.JoinedAt,
member.PremiumSince.OrDefault(null),
member.Flags,
member.IsPending.OrDefault(null),
member.CommunicationDisabledUntil.OrDefault(null)
);
public GuildMember ToRemoraMember() => new(User.ToRemoraUser(), Nickname, public GuildMember ToRemoraMember() =>
Avatar != null ? new ImageHash(Avatar) : null, Roles, JoinedAt, PremiumSince, false, false, Flags, new(
IsPending, default, CommunicationDisabledUntil); User.ToRemoraUser(),
Nickname,
Avatar != null ? new ImageHash(Avatar) : null,
Roles,
JoinedAt,
PremiumSince,
false,
false,
Flags,
IsPending,
default,
CommunicationDisabledUntil
);
} }
internal record RedisUser( internal record RedisUser(
@ -76,13 +114,30 @@ internal record RedisUser(
string? Avatar, string? Avatar,
bool IsBot, bool IsBot,
bool IsSystem, bool IsSystem,
string? Banner) string? Banner
)
{ {
public static RedisUser FromIUser(IUser user) => new(user.ID.Value, user.Username, user.Discriminator, public static RedisUser FromIUser(IUser user) =>
user.GlobalName.OrDefault(null), user.Avatar?.Value, user.IsBot.OrDefault(false), new(
user.IsSystem.OrDefault(false), user.Banner.OrDefault(null)?.Value); user.ID.Value,
user.Username,
user.Discriminator,
user.GlobalName.OrDefault(null),
user.Avatar?.Value,
user.IsBot.OrDefault(false),
user.IsSystem.OrDefault(false),
user.Banner.OrDefault(null)?.Value
);
public User ToRemoraUser() => new(DiscordSnowflake.New(Id), Username, Discriminator, GlobalName, public User ToRemoraUser() =>
Avatar != null ? new ImageHash(Avatar) : null, IsBot, IsSystem, new(
Banner: Banner != null ? new ImageHash(Banner) : null); DiscordSnowflake.New(Id),
Username,
Discriminator,
GlobalName,
Avatar != null ? new ImageHash(Avatar) : null,
IsBot,
IsSystem,
Banner: Banner != null ? new ImageHash(Banner) : null
);
} }

View file

@ -1,5 +1,3 @@
namespace Catalogger.Backend; namespace Catalogger.Backend;
public class CataloggerError(string message) : Exception(message) public class CataloggerError(string message) : Exception(message) { }
{
}

View file

@ -7,25 +7,37 @@ public static class CataloggerMetrics
{ {
public static Instant Startup { get; set; } public static Instant Startup { get; set; }
public static readonly Gauge MessagesReceived = public static readonly Gauge MessagesReceived = Metrics.CreateGauge(
Metrics.CreateGauge("catalogger_received_messages", "Number of messages Catalogger has received"); "catalogger_received_messages",
"Number of messages Catalogger has received"
);
public static long MessageRateMinute { get; set; } public static long MessageRateMinute { get; set; }
public static readonly Gauge GuildsCached = public static readonly Gauge GuildsCached = Metrics.CreateGauge(
Metrics.CreateGauge("catalogger_cache_guilds", "Number of guilds in the cache"); "catalogger_cache_guilds",
"Number of guilds in the cache"
);
public static readonly Gauge ChannelsCached = public static readonly Gauge ChannelsCached = Metrics.CreateGauge(
Metrics.CreateGauge("catalogger_cache_channels", "Number of channels in the cache"); "catalogger_cache_channels",
"Number of channels in the cache"
);
public static readonly Gauge UsersCached = public static readonly Gauge UsersCached = Metrics.CreateGauge(
Metrics.CreateGauge("catalogger_cache_users", "Number of users in the cache"); "catalogger_cache_users",
"Number of users in the cache"
);
public static readonly Gauge MessagesStored = public static readonly Gauge MessagesStored = Metrics.CreateGauge(
Metrics.CreateGauge("catalogger_stored_messages", "Number of users in the cache"); "catalogger_stored_messages",
"Number of users in the cache"
);
public static readonly Summary MetricsCollectionTime = public static readonly Summary MetricsCollectionTime = Metrics.CreateSummary(
Metrics.CreateSummary("catalogger_time_metrics", "Time it took to collect metrics"); "catalogger_time_metrics",
"Time it took to collect metrics"
);
public static Gauge ProcessPhysicalMemory => public static Gauge ProcessPhysicalMemory =>
Metrics.CreateGauge("catalogger_process_physical_memory", "Process physical memory"); Metrics.CreateGauge("catalogger_process_physical_memory", "Process physical memory");
@ -36,7 +48,9 @@ public static class CataloggerMetrics
public static Gauge ProcessPrivateMemory => public static Gauge ProcessPrivateMemory =>
Metrics.CreateGauge("catalogger_process_private_memory", "Process private memory"); Metrics.CreateGauge("catalogger_process_private_memory", "Process private memory");
public static Gauge ProcessThreads => Metrics.CreateGauge("catalogger_process_threads", "Process thread count"); public static Gauge ProcessThreads =>
Metrics.CreateGauge("catalogger_process_threads", "Process thread count");
public static Gauge ProcessHandles => Metrics.CreateGauge("catalogger_process_handles", "Process handle count"); public static Gauge ProcessHandles =>
Metrics.CreateGauge("catalogger_process_handles", "Process handle count");
} }

View file

@ -30,18 +30,17 @@ public class DatabaseContext : DbContext
}.ConnectionString; }.ConnectionString;
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder dataSourceBuilder.EnableDynamicJson().UseNodaTime();
.EnableDynamicJson()
.UseNodaTime();
_dataSource = dataSourceBuilder.Build(); _dataSource = dataSourceBuilder.Build();
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
} }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
=> optionsBuilder optionsBuilder
.ConfigureWarnings(c => .ConfigureWarnings(c =>
c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning) c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)
.Ignore(CoreEventId.SaveChangesFailed)) .Ignore(CoreEventId.SaveChangesFailed)
)
.UseNpgsql(_dataSource, o => o.UseNodaTime()) .UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention() .UseSnakeCaseNamingConvention()
.UseLoggerFactory(_loggerFactory) .UseLoggerFactory(_loggerFactory)
@ -53,14 +52,17 @@ public class DatabaseContext : DbContext
configurationBuilder.Properties<List<ulong>>().HaveConversion<UlongArrayValueConverter>(); configurationBuilder.Properties<List<ulong>>().HaveConversion<UlongArrayValueConverter>();
} }
private static readonly ValueComparer<List<ulong>> UlongListValueComparer = new( private static readonly ValueComparer<List<ulong>> UlongListValueComparer =
new(
(c1, c2) => c1 != null && c2 != null && c1.SequenceEqual(c2), (c1, c2) => c1 != null && c2 != null && c1.SequenceEqual(c2),
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())) c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode()))
); );
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Guild>().Property(g => g.KeyRoles) modelBuilder
.Entity<Guild>()
.Property(g => g.KeyRoles)
.Metadata.SetValueComparer(UlongListValueComparer); .Metadata.SetValueComparer(UlongListValueComparer);
modelBuilder.Entity<Invite>().HasKey(i => i.Code); modelBuilder.Entity<Invite>().HasKey(i => i.Code);
@ -76,7 +78,8 @@ public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<Data
public DatabaseContext CreateDbContext(string[] args) public DatabaseContext CreateDbContext(string[] args)
{ {
// Read the configuration file // Read the configuration file
var config = new ConfigurationBuilder() var config =
new ConfigurationBuilder()
.AddConfiguration() .AddConfiguration()
.Build() .Build()
// Get the configuration as our config class // Get the configuration as our config class
@ -86,12 +89,14 @@ public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<Data
} }
} }
public class UlongValueConverter() : ValueConverter<ulong, long>( public class UlongValueConverter()
: ValueConverter<ulong, long>(
convertToProviderExpression: x => (long)x, convertToProviderExpression: x => (long)x,
convertFromProviderExpression: x => (ulong)x convertFromProviderExpression: x => (ulong)x
); );
public class UlongArrayValueConverter() : ValueConverter<List<ulong>, List<long>>( public class UlongArrayValueConverter()
: ValueConverter<List<ulong>, List<long>>(
convertToProviderExpression: x => x.Select(i => (long)i).ToList(), convertToProviderExpression: x => x.Select(i => (long)i).ToList(),
convertFromProviderExpression: x => x.Select(i => (ulong)i).ToList() convertFromProviderExpression: x => x.Select(i => (ulong)i).ToList()
); );

View file

@ -20,23 +20,22 @@ namespace Catalogger.Backend.Database.Migrations
id = table.Column<long>(type: "bigint", nullable: false), id = table.Column<long>(type: "bigint", nullable: false),
channels = table.Column<Guild.ChannelConfig>(type: "jsonb", nullable: false), channels = table.Column<Guild.ChannelConfig>(type: "jsonb", nullable: false),
banned_systems = table.Column<List<string>>(type: "text[]", nullable: false), banned_systems = table.Column<List<string>>(type: "text[]", nullable: false),
key_roles = table.Column<List<long>>(type: "bigint[]", nullable: false) key_roles = table.Column<List<long>>(type: "bigint[]", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_guilds", x => x.id); table.PrimaryKey("pk_guilds", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "ignored_messages", name: "ignored_messages",
columns: table => new columns: table => new { id = table.Column<long>(type: "bigint", nullable: false) },
{
id = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_ignored_messages", x => x.id); table.PrimaryKey("pk_ignored_messages", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "invites", name: "invites",
@ -44,12 +43,13 @@ namespace Catalogger.Backend.Database.Migrations
{ {
code = table.Column<string>(type: "text", nullable: false), code = table.Column<string>(type: "text", nullable: false),
guild_id = table.Column<long>(type: "bigint", nullable: false), guild_id = table.Column<long>(type: "bigint", nullable: false),
name = table.Column<string>(type: "text", nullable: false) name = table.Column<string>(type: "text", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_invites", x => x.code); table.PrimaryKey("pk_invites", x => x.code);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "messages", name: "messages",
@ -65,12 +65,13 @@ namespace Catalogger.Backend.Database.Migrations
username = table.Column<byte[]>(type: "bytea", nullable: false), username = table.Column<byte[]>(type: "bytea", nullable: false),
content = table.Column<byte[]>(type: "bytea", nullable: false), content = table.Column<byte[]>(type: "bytea", nullable: false),
metadata = table.Column<byte[]>(type: "bytea", nullable: true), metadata = table.Column<byte[]>(type: "bytea", nullable: true),
attachment_size = table.Column<int>(type: "integer", nullable: false) attachment_size = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_messages", x => x.id); table.PrimaryKey("pk_messages", x => x.id);
}); }
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "watchlists", name: "watchlists",
@ -78,38 +79,39 @@ namespace Catalogger.Backend.Database.Migrations
{ {
guild_id = table.Column<long>(type: "bigint", nullable: false), guild_id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false), user_id = table.Column<long>(type: "bigint", nullable: false),
added_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), added_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()"
),
moderator_id = table.Column<long>(type: "bigint", nullable: false), moderator_id = table.Column<long>(type: "bigint", nullable: false),
reason = table.Column<string>(type: "text", nullable: false) reason = table.Column<string>(type: "text", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_watchlists", x => new { x.guild_id, x.user_id }); table.PrimaryKey("pk_watchlists", x => new { x.guild_id, x.user_id });
}); }
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_invites_guild_id", name: "ix_invites_guild_id",
table: "invites", table: "invites",
column: "guild_id"); column: "guild_id"
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "guilds");
name: "guilds");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "ignored_messages");
name: "ignored_messages");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "invites");
name: "invites");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "messages");
name: "messages");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "watchlists");
name: "watchlists");
} }
} }
} }

View file

@ -9,18 +9,26 @@ public class Guild
[DatabaseGenerated(DatabaseGeneratedOption.None)] [DatabaseGenerated(DatabaseGeneratedOption.None)]
public required ulong Id { get; init; } public required ulong Id { get; init; }
[Column(TypeName = "jsonb")] public ChannelConfig Channels { get; init; } = new(); [Column(TypeName = "jsonb")]
public ChannelConfig Channels { get; init; } = new();
public List<string> BannedSystems { get; init; } = []; public List<string> BannedSystems { get; init; } = [];
public List<ulong> KeyRoles { get; init; } = []; public List<ulong> KeyRoles { get; init; } = [];
public bool IsMessageIgnored(Snowflake channelId, Snowflake userId) public bool IsMessageIgnored(Snowflake channelId, Snowflake userId)
{ {
if (Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 } || if (
Channels.IgnoredChannels.Contains(channelId.ToUlong()) || Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 }
Channels.IgnoredUsers.Contains(userId.ToUlong())) return true; || Channels.IgnoredChannels.Contains(channelId.ToUlong())
|| Channels.IgnoredUsers.Contains(userId.ToUlong())
)
return true;
if (Channels.IgnoredUsersPerChannel.TryGetValue(channelId.ToUlong(), if (
out var thisChannelIgnoredUsers)) Channels.IgnoredUsersPerChannel.TryGetValue(
channelId.ToUlong(),
out var thisChannelIgnoredUsers
)
)
return thisChannelIgnoredUsers.Contains(userId.ToUlong()); return thisChannelIgnoredUsers.Contains(userId.ToUlong());
return false; return false;

View file

@ -15,13 +15,16 @@ public class Message
public string? Member { get; set; } public string? Member { get; set; }
public string? System { get; set; } public string? System { get; set; }
[Column("username")] public byte[] EncryptedUsername { get; set; } = []; [Column("username")]
[Column("content")] public byte[] EncryptedContent { get; set; } = []; public byte[] EncryptedUsername { get; set; } = [];
[Column("metadata")] public byte[]? EncryptedMetadata { get; set; }
[Column("content")]
public byte[] EncryptedContent { get; set; } = [];
[Column("metadata")]
public byte[]? EncryptedMetadata { get; set; }
public int AttachmentSize { get; set; } = 0; public int AttachmentSize { get; set; } = 0;
} }
public record IgnoredMessage( public record IgnoredMessage([property: DatabaseGenerated(DatabaseGeneratedOption.None)] ulong Id);
[property: DatabaseGenerated(DatabaseGeneratedOption.None)]
ulong Id);

View file

@ -6,7 +6,11 @@ using DbMessage = Catalogger.Backend.Database.Models.Message;
namespace Catalogger.Backend.Database.Queries; namespace Catalogger.Backend.Database.Queries;
public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionService encryptionService) public class MessageRepository(
ILogger logger,
DatabaseContext db,
IEncryptionService encryptionService
)
{ {
private readonly ILogger _logger = logger.ForContext<MessageRepository>(); private readonly ILogger _logger = logger.ForContext<MessageRepository>();
@ -14,8 +18,10 @@ public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionSe
{ {
_logger.Debug("Saving message {MessageId}", msg.ID); _logger.Debug("Saving message {MessageId}", msg.ID);
var metadata = new Metadata(IsWebhook: msg.WebhookID.HasValue, var metadata = new Metadata(
msg.Attachments.Select(a => new Attachment(a.Filename, a.Size, a.ContentType.Value))); IsWebhook: msg.WebhookID.HasValue,
msg.Attachments.Select(a => new Attachment(a.Filename, a.Size, a.ContentType.Value))
);
var dbMessage = new DbMessage var dbMessage = new DbMessage
{ {
@ -24,12 +30,22 @@ public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionSe
ChannelId = msg.ChannelID.ToUlong(), ChannelId = msg.ChannelID.ToUlong(),
GuildId = msg.GuildID.ToUlong(), GuildId = msg.GuildID.ToUlong(),
EncryptedContent = EncryptedContent = await Task.Run(
await Task.Run( () =>
() => encryptionService.Encrypt(string.IsNullOrWhiteSpace(msg.Content) ? "None" : msg.Content), ct), encryptionService.Encrypt(
EncryptedUsername = await Task.Run(() => encryptionService.Encrypt(msg.Author.Tag()), ct), string.IsNullOrWhiteSpace(msg.Content) ? "None" : msg.Content
EncryptedMetadata = await Task.Run(() => encryptionService.Encrypt(JsonSerializer.Serialize(metadata)), ct), ),
AttachmentSize = msg.Attachments.Select(a => a.Size).Sum() ct
),
EncryptedUsername = await Task.Run(
() => encryptionService.Encrypt(msg.Author.Tag()),
ct
),
EncryptedMetadata = await Task.Run(
() => encryptionService.Encrypt(JsonSerializer.Serialize(metadata)),
ct
),
AttachmentSize = msg.Attachments.Select(a => a.Size).Sum(),
}; };
db.Add(dbMessage); db.Add(dbMessage);
@ -56,17 +72,32 @@ public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionSe
} }
else else
{ {
var metadata = new Metadata(IsWebhook: msg.WebhookID.HasValue, var metadata = new Metadata(
msg.Attachments.Select(a => new Attachment(a.Filename, a.Size, a.ContentType.Value))); IsWebhook: msg.WebhookID.HasValue,
msg.Attachments.Select(a => new Attachment(a.Filename, a.Size, a.ContentType.Value))
);
var dbMsg = await db.Messages.FindAsync(msg.ID.Value); var dbMsg = await db.Messages.FindAsync(msg.ID.Value);
if (dbMsg == null) throw new CataloggerError("Message was null despite HasProxyInfoAsync returning true"); if (dbMsg == null)
throw new CataloggerError(
"Message was null despite HasProxyInfoAsync returning true"
);
dbMsg.EncryptedContent = await Task.Run( dbMsg.EncryptedContent = await Task.Run(
() => encryptionService.Encrypt(string.IsNullOrWhiteSpace(msg.Content) ? "None" : msg.Content), ct); () =>
dbMsg.EncryptedUsername = await Task.Run(() => encryptionService.Encrypt(msg.Author.Tag()), ct); encryptionService.Encrypt(
dbMsg.EncryptedMetadata = string.IsNullOrWhiteSpace(msg.Content) ? "None" : msg.Content
await Task.Run(() => encryptionService.Encrypt(JsonSerializer.Serialize(metadata)), ct); ),
ct
);
dbMsg.EncryptedUsername = await Task.Run(
() => encryptionService.Encrypt(msg.Author.Tag()),
ct
);
dbMsg.EncryptedMetadata = await Task.Run(
() => encryptionService.Encrypt(JsonSerializer.Serialize(metadata)),
ct
);
db.Update(dbMsg); db.Update(dbMsg);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@ -80,17 +111,26 @@ public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionSe
_logger.Debug("Retrieving message {MessageId}", id); _logger.Debug("Retrieving message {MessageId}", id);
var dbMsg = await db.Messages.FindAsync(id); var dbMsg = await db.Messages.FindAsync(id);
if (dbMsg == null) return null; if (dbMsg == null)
return null;
return new Message(dbMsg.Id, dbMsg.OriginalId, dbMsg.UserId, dbMsg.ChannelId, dbMsg.GuildId, dbMsg.Member, return new Message(
dbMsg.Id,
dbMsg.OriginalId,
dbMsg.UserId,
dbMsg.ChannelId,
dbMsg.GuildId,
dbMsg.Member,
dbMsg.System, dbMsg.System,
Username: await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedUsername), ct), Username: await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedUsername), ct),
Content: await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedContent), ct), Content: await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedContent), ct),
Metadata: dbMsg.EncryptedMetadata != null Metadata: dbMsg.EncryptedMetadata != null
? JsonSerializer.Deserialize<Metadata>( ? JsonSerializer.Deserialize<Metadata>(
await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedMetadata), ct)) await Task.Run(() => encryptionService.Decrypt(dbMsg.EncryptedMetadata), ct)
)
: null, : null,
dbMsg.AttachmentSize); dbMsg.AttachmentSize
);
} }
/// <summary> /// <summary>
@ -101,12 +141,19 @@ public class MessageRepository(ILogger logger, DatabaseContext db, IEncryptionSe
{ {
_logger.Debug("Checking if message {MessageId} has proxy information", id); _logger.Debug("Checking if message {MessageId} has proxy information", id);
var msg = await db.Messages.Select(m => new { m.Id, m.OriginalId }).FirstOrDefaultAsync(m => m.Id == id); var msg = await db
.Messages.Select(m => new { m.Id, m.OriginalId })
.FirstOrDefaultAsync(m => m.Id == id);
return (msg != null, msg?.OriginalId != null); return (msg != null, msg?.OriginalId != null);
} }
public async Task SetProxiedMessageDataAsync(ulong id, ulong originalId, ulong authorId, string? systemId, public async Task SetProxiedMessageDataAsync(
string? memberId) ulong id,
ulong originalId,
ulong authorId,
string? systemId,
string? memberId
)
{ {
_logger.Debug("Setting proxy information for message {MessageId}", id); _logger.Debug("Setting proxy information for message {MessageId}", id);

View file

@ -6,21 +6,34 @@ namespace Catalogger.Backend.Database.Queries;
public static class QueryExtensions public static class QueryExtensions
{ {
public static async ValueTask<Guild> GetGuildAsync(this DatabaseContext db, Snowflake id, public static async ValueTask<Guild> GetGuildAsync(
CancellationToken ct = default) => await db.GetGuildAsync(id.ToUlong(), ct); this DatabaseContext db,
Snowflake id,
CancellationToken ct = default
) => await db.GetGuildAsync(id.ToUlong(), ct);
public static async ValueTask<Guild> GetGuildAsync(this DatabaseContext db, Optional<Snowflake> id, public static async ValueTask<Guild> GetGuildAsync(
CancellationToken ct = default) => await db.GetGuildAsync(id.ToUlong(), ct); this DatabaseContext db,
Optional<Snowflake> id,
CancellationToken ct = default
) => await db.GetGuildAsync(id.ToUlong(), ct);
public static async ValueTask<Guild> GetGuildAsync(this DatabaseContext db, ulong id, public static async ValueTask<Guild> GetGuildAsync(
CancellationToken ct = default) this DatabaseContext db,
ulong id,
CancellationToken ct = default
)
{ {
var guild = await db.Guilds.FindAsync([id], ct); var guild = await db.Guilds.FindAsync([id], ct);
if (guild == null) throw new CataloggerError("Guild not found, was not initialized during guild create"); if (guild == null)
throw new CataloggerError("Guild not found, was not initialized during guild create");
return guild; return guild;
} }
public static async Task<Watchlist?> GetWatchlistEntryAsync(this DatabaseContext db, Snowflake guildId, public static async Task<Watchlist?> GetWatchlistEntryAsync(
Snowflake userId, CancellationToken ct = default) => this DatabaseContext db,
await db.Watchlists.FindAsync([guildId.Value, userId.Value], ct); Snowflake guildId,
Snowflake userId,
CancellationToken ct = default
) => await db.Watchlists.FindAsync([guildId.Value, userId.Value], ct);
} }

View file

@ -5,12 +5,12 @@ namespace Catalogger.Backend.Database.Redis;
public class RedisService(Config config) public class RedisService(Config config)
{ {
private readonly ConnectionMultiplexer _multiplexer = ConnectionMultiplexer.Connect(config.Database.Redis!); private readonly ConnectionMultiplexer _multiplexer = ConnectionMultiplexer.Connect(
config.Database.Redis!
);
private readonly JsonSerializerOptions _options = new() private readonly JsonSerializerOptions _options =
{ new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db); public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db);
@ -32,10 +32,18 @@ public class RedisService(Config config)
await GetDatabase().HashSetAsync(hashKey, fieldKey, json); await GetDatabase().HashSetAsync(hashKey, fieldKey, json);
} }
public async Task SetHashAsync<T>(string hashKey, IEnumerable<T> values, Func<T, string> keySelector) public async Task SetHashAsync<T>(
string hashKey,
IEnumerable<T> values,
Func<T, string> keySelector
)
{ {
var hashEntries = values var hashEntries = values
.Select(v => new { Key = keySelector(v), Value = JsonSerializer.Serialize(v, _options) }) .Select(v => new
{
Key = keySelector(v),
Value = JsonSerializer.Serialize(v, _options),
})
.Select(v => new HashEntry(v.Key, v.Value)); .Select(v => new HashEntry(v.Key, v.Value));
await GetDatabase().HashSetAsync(hashKey, hashEntries.ToArray()); await GetDatabase().HashSetAsync(hashKey, hashEntries.ToArray());
} }

View file

@ -17,7 +17,9 @@ public static class DiscordExtensions
public static string Tag(this IPartialUser user) public static string Tag(this IPartialUser user)
{ {
var discriminator = user.Discriminator.OrDefault(); var discriminator = user.Discriminator.OrDefault();
return discriminator == 0 ? user.Username.Value : $"{user.Username.Value}#{discriminator:0000}"; return discriminator == 0
? user.Username.Value
: $"{user.Username.Value}#{discriminator:0000}";
} }
public static string AvatarUrl(this IUser user, int size = 256) public static string AvatarUrl(this IUser user, int size = 256)
@ -28,13 +30,15 @@ public static class DiscordExtensions
return $"https://cdn.discordapp.com/avatars/{user.ID}/{user.Avatar.Value}{ext}?size={size}"; return $"https://cdn.discordapp.com/avatars/{user.ID}/{user.Avatar.Value}{ext}?size={size}";
} }
var avatarIndex = user.Discriminator == 0 ? (int)((user.ID.Value >> 22) % 6) : user.Discriminator % 5; var avatarIndex =
user.Discriminator == 0 ? (int)((user.ID.Value >> 22) % 6) : user.Discriminator % 5;
return $"https://cdn.discordapp.com/embed/avatars/{avatarIndex}.png?size={size}"; return $"https://cdn.discordapp.com/embed/avatars/{avatarIndex}.png?size={size}";
} }
public static string? IconUrl(this IGuild guild, int size = 256) public static string? IconUrl(this IGuild guild, int size = 256)
{ {
if (guild.Icon == null) return null; if (guild.Icon == null)
return null;
var ext = guild.Icon.HasGif ? ".gif" : ".webp"; var ext = guild.Icon.HasGif ? ".gif" : ".webp";
@ -45,7 +49,8 @@ public static class DiscordExtensions
public static ulong ToUlong(this Optional<Snowflake> snowflake) public static ulong ToUlong(this Optional<Snowflake> snowflake)
{ {
if (!snowflake.IsDefined()) throw new Exception("ToUlong called on an undefined Snowflake"); if (!snowflake.IsDefined())
throw new Exception("ToUlong called on an undefined Snowflake");
return snowflake.Value.Value; return snowflake.Value.Value;
} }
@ -58,37 +63,61 @@ public static class DiscordExtensions
return $"#{r}{g}{b}"; return $"#{r}{g}{b}";
} }
public static bool Is(this Optional<Snowflake> s1, Snowflake s2) => s1.IsDefined(out var value) && value == s2; public static bool Is(this Optional<Snowflake> s1, Snowflake s2) =>
public static bool Is(this Optional<Snowflake> s1, ulong s2) => s1.IsDefined(out var value) && value == s2; s1.IsDefined(out var value) && value == s2;
public static bool Is(this Optional<Snowflake> s1, ulong s2) =>
s1.IsDefined(out var value) && value == s2;
public static T GetOrThrow<T>(this Result<T> result) public static T GetOrThrow<T>(this Result<T> result)
{ {
if (result.Error != null) throw new DiscordRestException(result.Error.Message); if (result.Error != null)
throw new DiscordRestException(result.Error.Message);
return result.Entity; return result.Entity;
} }
public static T GetOrThrow<T>(this Optional<T> optional) => public static T GetOrThrow<T>(this Optional<T> optional) =>
optional.OrThrow(() => new CataloggerError("Optional<T> was unset")); optional.OrThrow(() => new CataloggerError("Optional<T> was unset"));
public static async Task<T> GetOrThrow<T>(this Task<Result<T>> result) => (await result).GetOrThrow(); public static async Task<T> GetOrThrow<T>(this Task<Result<T>> result) =>
(await result).GetOrThrow();
public static async Task<Result> UpdateMessageAsync(this IDiscordRestInteractionAPI interactionApi, public static async Task<Result> UpdateMessageAsync(
IInteraction interaction, InteractionMessageCallbackData data) => this IDiscordRestInteractionAPI interactionApi,
await interactionApi.CreateInteractionResponseAsync(interaction.ID, IInteraction interaction,
InteractionMessageCallbackData data
) =>
await interactionApi.CreateInteractionResponseAsync(
interaction.ID,
interaction.Token, interaction.Token,
new InteractionResponse(InteractionCallbackType.UpdateMessage, new InteractionResponse(
new Optional<OneOf<IInteractionMessageCallbackData, IInteractionAutocompleteCallbackData, InteractionCallbackType.UpdateMessage,
IInteractionModalCallbackData>>(data))); new Optional<
OneOf<
IInteractionMessageCallbackData,
IInteractionAutocompleteCallbackData,
IInteractionModalCallbackData
>
>(data)
)
);
public static string ToPrettyString(this IDiscordPermissionSet permissionSet) => public static string ToPrettyString(this IDiscordPermissionSet permissionSet) =>
string.Join(", ", permissionSet.GetPermissions().Select(p => p.Humanize(LetterCasing.Title))); string.Join(
", ",
permissionSet.GetPermissions().Select(p => p.Humanize(LetterCasing.Title))
);
public static (Snowflake, Snowflake) GetUserAndGuild(this ContextInjectionService contextInjectionService) public static (Snowflake, Snowflake) GetUserAndGuild(
this ContextInjectionService contextInjectionService
)
{ {
if (contextInjectionService.Context is not IInteractionCommandContext ctx) if (contextInjectionService.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); throw new CataloggerError("No context");
if (!ctx.TryGetUserID(out var userId)) throw new CataloggerError("No user ID in context"); if (!ctx.TryGetUserID(out var userId))
if (!ctx.TryGetGuildID(out var guildId)) throw new CataloggerError("No guild ID in context"); throw new CataloggerError("No user ID in context");
if (!ctx.TryGetGuildID(out var guildId))
throw new CataloggerError("No guild ID in context");
return (userId, guildId); return (userId, guildId);
} }
@ -99,8 +128,10 @@ public static class DiscordExtensions
/// <param name="filterByIds">An optional list of role IDs to return, from a member object or similar. /// <param name="filterByIds">An optional list of role IDs to return, from a member object or similar.
/// If null, the entire list is returned.</param> /// If null, the entire list is returned.</param>
/// <returns></returns> /// <returns></returns>
public static IEnumerable<IRole> Sorted(this IEnumerable<IRole> roles, public static IEnumerable<IRole> Sorted(
IEnumerable<Snowflake>? filterByIds = null) this IEnumerable<IRole> roles,
IEnumerable<Snowflake>? filterByIds = null
)
{ {
var sorted = roles.OrderByDescending(r => r.Position); var sorted = roles.OrderByDescending(r => r.Position);
return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted; return filterByIds != null ? sorted.Where(r => filterByIds.Contains(r.ID)) : sorted;

View file

@ -27,7 +27,10 @@ public static class StartupExtensions
/// <summary> /// <summary>
/// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls. /// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
/// </summary> /// </summary>
public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder, Config config) public static WebApplicationBuilder AddSerilog(
this WebApplicationBuilder builder,
Config config
)
{ {
var logCfg = new LoggerConfiguration() var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext() .Enrich.FromLogContext()
@ -35,8 +38,10 @@ public static class StartupExtensions
// 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", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", .MinimumLevel.Override(
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal) "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)
@ -69,7 +74,8 @@ public static class StartupExtensions
.AddEnvironmentVariables(); .AddEnvironmentVariables();
} }
public static IServiceCollection AddCustomServices(this IServiceCollection services) => services public static IServiceCollection AddCustomServices(this IServiceCollection services) =>
services
.AddSingleton<IClock>(SystemClock.Instance) .AddSingleton<IClock>(SystemClock.Instance)
.AddSingleton<GuildCache>() .AddSingleton<GuildCache>()
.AddSingleton<RoleCache>() .AddSingleton<RoleCache>()
@ -85,16 +91,26 @@ public static class StartupExtensions
.AddSingleton<PkMessageHandler>() .AddSingleton<PkMessageHandler>()
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance) .AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddSingleton<GuildFetchService>() .AddSingleton<GuildFetchService>()
.AddHostedService(serviceProvider => serviceProvider.GetRequiredService<GuildFetchService>()); .AddHostedService(serviceProvider =>
serviceProvider.GetRequiredService<GuildFetchService>()
);
public static IHostBuilder AddShardedDiscordService(this IHostBuilder builder, public static IHostBuilder AddShardedDiscordService(
Func<IServiceProvider, string> tokenFactory) => this IHostBuilder builder,
builder.ConfigureServices((_, services) => services Func<IServiceProvider, string> tokenFactory
) =>
builder.ConfigureServices(
(_, services) =>
services
.AddDiscordGateway(tokenFactory) .AddDiscordGateway(tokenFactory)
.AddSingleton<ShardedGatewayClient>() .AddSingleton<ShardedGatewayClient>()
.AddHostedService<ShardedDiscordService>()); .AddHostedService<ShardedDiscordService>()
);
public static IServiceCollection MaybeAddRedisCaches(this IServiceCollection services, Config config) public static IServiceCollection MaybeAddRedisCaches(
this IServiceCollection services,
Config config
)
{ {
if (config.Database.Redis == null) if (config.Database.Redis == null)
{ {
@ -104,7 +120,8 @@ public static class StartupExtensions
.AddSingleton<IInviteCache, InMemoryInviteCache>(); .AddSingleton<IInviteCache, InMemoryInviteCache>();
} }
return services.AddSingleton<RedisService>() return services
.AddSingleton<RedisService>()
.AddSingleton<IWebhookCache, RedisWebhookCache>() .AddSingleton<IWebhookCache, RedisWebhookCache>()
.AddSingleton<IMemberCache, RedisMemberCache>() .AddSingleton<IMemberCache, RedisMemberCache>()
.AddSingleton<IInviteCache, RedisInviteCache>(); .AddSingleton<IInviteCache, RedisInviteCache>();
@ -116,7 +133,9 @@ public static class StartupExtensions
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>(); var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>();
logger.Information("Starting Catalogger.NET"); logger.Information("Starting Catalogger.NET");
CataloggerMetrics.Startup = scope.ServiceProvider.GetRequiredService<IClock>().GetCurrentInstant(); CataloggerMetrics.Startup = scope
.ServiceProvider.GetRequiredService<IClock>()
.GetCurrentInstant();
await using (var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>()) await using (var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>())
{ {
@ -126,7 +145,8 @@ public static class StartupExtensions
logger.Information("Applying {Count} database migrations", migrationCount); logger.Information("Applying {Count} database migrations", migrationCount);
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
} }
else logger.Information("There are no pending migrations"); else
logger.Information("There are no pending migrations");
} }
var config = scope.ServiceProvider.GetRequiredService<Config>(); var config = scope.ServiceProvider.GetRequiredService<Config>();
@ -135,20 +155,28 @@ public static class StartupExtensions
if (config.Discord.ApplicationId == 0) if (config.Discord.ApplicationId == 0)
{ {
logger.Warning( logger.Warning(
"Application ID not set in config. Fetching and setting it now, but for future restarts, please add it to config.ini as Discord.ApplicationId."); "Application ID not set in config. Fetching and setting it now, but for future restarts, please add it to config.ini as Discord.ApplicationId."
);
var restApi = scope.ServiceProvider.GetRequiredService<IDiscordRestApplicationAPI>(); var restApi = scope.ServiceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
var application = await restApi.GetCurrentApplicationAsync().GetOrThrow(); var application = await restApi.GetCurrentApplicationAsync().GetOrThrow();
config.Discord.ApplicationId = application.ID.ToUlong(); config.Discord.ApplicationId = application.ID.ToUlong();
logger.Information("Current application ID is {ApplicationId}", config.Discord.ApplicationId); logger.Information(
"Current application ID is {ApplicationId}",
config.Discord.ApplicationId
);
} }
if (config.Discord.SyncCommands) if (config.Discord.SyncCommands)
{ {
if (config.Discord.CommandsGuildId != null) if (config.Discord.CommandsGuildId != null)
{ {
logger.Information("Syncing application commands with guild {GuildId}", config.Discord.CommandsGuildId); logger.Information(
"Syncing application commands with guild {GuildId}",
config.Discord.CommandsGuildId
);
await slashService.UpdateSlashCommandsAsync( await slashService.UpdateSlashCommandsAsync(
guildID: DiscordSnowflake.New(config.Discord.CommandsGuildId.Value)); guildID: DiscordSnowflake.New(config.Discord.CommandsGuildId.Value)
);
} }
else else
{ {
@ -156,6 +184,9 @@ public static class StartupExtensions
await slashService.UpdateSlashCommandsAsync(); await slashService.UpdateSlashCommandsAsync();
} }
} }
else logger.Information("Not syncing slash commands, Discord.SyncCommands is false or unset"); else
logger.Information(
"Not syncing slash commands, Discord.SyncCommands is false or unset"
);
} }
} }

View file

@ -12,8 +12,10 @@ public static class TimeExtensions
public static string Prettify(this Duration duration, TimeUnit minUnit = TimeUnit.Minute) => public static string Prettify(this Duration duration, TimeUnit minUnit = TimeUnit.Minute) =>
duration.ToTimeSpan().Prettify(minUnit); duration.ToTimeSpan().Prettify(minUnit);
public static string Prettify(this DateTimeOffset datetime, TimeUnit minUnit = TimeUnit.Minute) => public static string Prettify(
(datetime - DateTimeOffset.Now).Prettify(minUnit); this DateTimeOffset datetime,
TimeUnit minUnit = TimeUnit.Minute
) => (datetime - DateTimeOffset.Now).Prettify(minUnit);
public static string Prettify(this Instant instant, TimeUnit minUnit = TimeUnit.Minute) => public static string Prettify(this Instant instant, TimeUnit minUnit = TimeUnit.Minute) =>
(instant - SystemClock.Instance.GetCurrentInstant()).Prettify(minUnit); (instant - SystemClock.Instance.GetCurrentInstant()).Prettify(minUnit);

View file

@ -18,27 +18,30 @@ var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration(); var config = builder.AddConfiguration();
builder.AddSerilog(config); builder.AddSerilog(config);
builder.Services builder
.AddControllers() .Services.AddControllers()
.AddNewtonsoftJson(o => o.SerializerSettings.ContractResolver = .AddNewtonsoftJson(o =>
new DefaultContractResolver o.SerializerSettings.ContractResolver = new DefaultContractResolver
{ {
NamingStrategy = new SnakeCaseNamingStrategy() NamingStrategy = new SnakeCaseNamingStrategy(),
}); }
);
builder.Host builder
.AddShardedDiscordService(_ => config.Discord.Token) .Host.AddShardedDiscordService(_ => config.Discord.Token)
.ConfigureServices(s => .ConfigureServices(s =>
s.AddRespondersFromAssembly(typeof(Program).Assembly) s.AddRespondersFromAssembly(typeof(Program).Assembly)
.Configure<DiscordGatewayClientOptions>(g => .Configure<DiscordGatewayClientOptions>(g =>
g.Intents = GatewayIntents.Guilds | g.Intents =
GatewayIntents.GuildBans | GatewayIntents.Guilds
GatewayIntents.GuildInvites | | GatewayIntents.GuildBans
GatewayIntents.GuildMembers | | GatewayIntents.GuildInvites
GatewayIntents.GuildMessages | | GatewayIntents.GuildMembers
GatewayIntents.GuildWebhooks | | GatewayIntents.GuildMessages
GatewayIntents.MessageContents | | GatewayIntents.GuildWebhooks
GatewayIntents.GuildEmojisAndStickers) | GatewayIntents.MessageContents
| GatewayIntents.GuildEmojisAndStickers
)
.AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false) .AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false)
.AddCommandTree() .AddCommandTree()
// Start command tree // Start command tree
@ -59,8 +62,8 @@ builder.Services.AddMetricServer(o => o.Port = (ushort)config.Logging.MetricsPor
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
builder.Services.AddHostedService<BackgroundMetricsCollectionService>(); builder.Services.AddHostedService<BackgroundMetricsCollectionService>();
builder.Services builder
.AddDbContext<DatabaseContext>() .Services.AddDbContext<DatabaseContext>()
.MaybeAddRedisCaches(config) .MaybeAddRedisCaches(config)
.AddCustomServices() .AddCustomServices()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
@ -83,7 +86,8 @@ app.Urls.Add(config.Web.Address);
// Make sure metrics are updated whenever Prometheus scrapes them // Make sure metrics are updated whenever Prometheus scrapes them
Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct =>
await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct)); await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct)
);
app.Run(); app.Run();
Log.CloseAndFlush(); Log.CloseAndFlush();

View file

@ -19,18 +19,27 @@ public class AuditLogEnrichedResponderService(AuditLogCache auditLogCache, ILogg
if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var banData)) if (auditLogCache.TryGetBan(evt.GuildID, evt.User.ID, out var banData))
return await HandleBanAsync(evt, banData); return await HandleBanAsync(evt, banData);
_logger.Debug("Guild member remove event for guild {GuildId}/user {UserId} didn't match an audit log entry", _logger.Debug(
evt.GuildID, evt.User.ID); "Guild member remove event for guild {GuildId}/user {UserId} didn't match an audit log entry",
evt.GuildID,
evt.User.ID
);
return Result.Success; return Result.Success;
} }
private async Task<Result> HandleKickAsync(IGuildMemberRemove evt, AuditLogCache.ActionData kickData) private async Task<Result> HandleKickAsync(
IGuildMemberRemove evt,
AuditLogCache.ActionData kickData
)
{ {
return Result.Success; return Result.Success;
} }
private async Task<Result> HandleBanAsync(IGuildMemberRemove evt, AuditLogCache.ActionData banData) private async Task<Result> HandleBanAsync(
IGuildMemberRemove evt,
AuditLogCache.ActionData banData
)
{ {
return Result.Success; return Result.Success;
} }

View file

@ -13,7 +13,8 @@ public class GuildFetchService(
ILogger logger, ILogger logger,
ShardedGatewayClient client, ShardedGatewayClient client,
IDiscordRestGuildAPI guildApi, IDiscordRestGuildAPI guildApi,
IInviteCache inviteCache) : BackgroundService IInviteCache inviteCache
) : BackgroundService
{ {
private readonly ILogger _logger = logger.ForContext<GuildFetchService>(); private readonly ILogger _logger = logger.ForContext<GuildFetchService>();
private readonly ConcurrentQueue<Snowflake> _guilds = new(); private readonly ConcurrentQueue<Snowflake> _guilds = new();
@ -23,7 +24,8 @@ public class GuildFetchService(
using var timer = new PeriodicTimer(500.Milliseconds()); using var timer = new PeriodicTimer(500.Milliseconds());
while (await timer.WaitForNextTickAsync(stoppingToken)) while (await timer.WaitForNextTickAsync(stoppingToken))
{ {
if (!_guilds.TryPeek(out var guildId)) continue; if (!_guilds.TryPeek(out var guildId))
continue;
_logger.Debug("Fetching members and invites for guild {GuildId}", guildId); _logger.Debug("Fetching members and invites for guild {GuildId}", guildId);
client.ClientFor(guildId).SubmitCommand(new RequestGuildMembers(guildId, "", 0)); client.ClientFor(guildId).SubmitCommand(new RequestGuildMembers(guildId, "", 0));
@ -43,6 +45,7 @@ public class GuildFetchService(
public void EnqueueGuild(Snowflake guildId) public void EnqueueGuild(Snowflake guildId)
{ {
if (!_guilds.Contains(guildId)) _guilds.Enqueue(guildId); if (!_guilds.Contains(guildId))
_guilds.Enqueue(guildId);
} }
} }

View file

@ -12,7 +12,8 @@ public class MetricsCollectionService(
GuildCache guildCache, GuildCache guildCache,
ChannelCache channelCache, ChannelCache channelCache,
UserCache userCache, UserCache userCache,
IServiceProvider services) IServiceProvider services
)
{ {
private readonly ILogger _logger = logger.ForContext<MetricsCollectionService>(); private readonly ILogger _logger = logger.ForContext<MetricsCollectionService>();
@ -42,7 +43,10 @@ public class MetricsCollectionService(
} }
} }
public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService innerService) : BackgroundService public class BackgroundMetricsCollectionService(
ILogger logger,
MetricsCollectionService innerService
) : BackgroundService
{ {
private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>(); private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>();

View file

@ -17,17 +17,25 @@ public class PluralkitApiService(ILogger logger)
private readonly ILogger _logger = logger.ForContext<PluralkitApiService>(); private readonly ILogger _logger = logger.ForContext<PluralkitApiService>();
private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder() private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter(new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions() .AddRateLimiter(
new FixedWindowRateLimiter(
new FixedWindowRateLimiterOptions()
{ {
Window = 1.Seconds(), Window = 1.Seconds(),
PermitLimit = 2, PermitLimit = 2,
QueueLimit = 64, QueueLimit = 64,
})) }
)
)
.AddTimeout(20.Seconds()) .AddTimeout(20.Seconds())
.Build(); .Build();
private async Task<T?> DoRequestAsync<T>(string path, bool allowNotFound = false, private async Task<T?> DoRequestAsync<T>(
CancellationToken ct = default) where T : class string path,
bool allowNotFound = false,
CancellationToken ct = default
)
where T : class
{ {
var req = new HttpRequestMessage(HttpMethod.Get, $"{ApiBaseUrl}{path}"); var req = new HttpRequestMessage(HttpMethod.Get, $"{ApiBaseUrl}{path}");
req.Headers.Add("User-Agent", UserAgent); req.Headers.Add("User-Agent", UserAgent);
@ -43,27 +51,37 @@ public class PluralkitApiService(ILogger logger)
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
_logger.Error("Received non-200 status code {StatusCode} from PluralKit API path {Path}", resp.StatusCode, _logger.Error(
req); "Received non-200 status code {StatusCode} from PluralKit API path {Path}",
resp.StatusCode,
req
);
throw new CataloggerError("Non-200 status code from PluralKit API"); throw new CataloggerError("Non-200 status code from PluralKit API");
} }
var jsonOptions = new JsonSerializerOptions var jsonOptions = new JsonSerializerOptions
{ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }
.ConfigureForNodaTime(new NodaJsonSettings
{ {
InstantConverter = new NodaPatternConverter<Instant>(InstantPattern.ExtendedIso) PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
}); }.ConfigureForNodaTime(
new NodaJsonSettings
{
InstantConverter = new NodaPatternConverter<Instant>(InstantPattern.ExtendedIso),
}
);
return await resp.Content.ReadFromJsonAsync<T>(jsonOptions, ct) ?? return await resp.Content.ReadFromJsonAsync<T>(jsonOptions, ct)
throw new CataloggerError("JSON response from PluralKit API was null"); ?? throw new CataloggerError("JSON response from PluralKit API was null");
} }
public async Task<PkMessage?> GetPluralKitMessageAsync(ulong id, CancellationToken ct = default) => public async Task<PkMessage?> GetPluralKitMessageAsync(
await DoRequestAsync<PkMessage>($"/messages/{id}", allowNotFound: true, ct); ulong id,
CancellationToken ct = default
) => await DoRequestAsync<PkMessage>($"/messages/{id}", allowNotFound: true, ct);
public async Task<PkSystem?> GetPluralKitSystemAsync(ulong id, CancellationToken ct = default) => public async Task<PkSystem?> GetPluralKitSystemAsync(
await DoRequestAsync<PkSystem>($"/systems/{id}", allowNotFound: true, ct); ulong id,
CancellationToken ct = default
) => await DoRequestAsync<PkSystem>($"/systems/{id}", allowNotFound: true, ct);
public record PkMessage( public record PkMessage(
ulong Id, ulong Id,
@ -72,7 +90,8 @@ public class PluralkitApiService(ILogger logger)
ulong Channel, ulong Channel,
ulong Guild, ulong Guild,
PkSystem? System, PkSystem? System,
PkMember? Member); PkMember? Member
);
public record PkSystem(string Id, Guid Uuid, string? Name, string? Tag, Instant? Created); public record PkSystem(string Id, Guid Uuid, string? Name, string? Tag, Instant? Created);

View file

@ -16,7 +16,8 @@ public class WebhookExecutorService(
ILogger logger, ILogger logger,
IWebhookCache webhookCache, IWebhookCache webhookCache,
ChannelCache channelCache, ChannelCache channelCache,
IDiscordRestWebhookAPI webhookApi) IDiscordRestWebhookAPI webhookApi
)
{ {
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);
@ -35,7 +36,8 @@ public class WebhookExecutorService(
public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed) public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed)
{ {
var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null); var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null);
if (logChannel == null) return; if (logChannel == null)
return;
QueueLog(logChannel.Value, embed); QueueLog(logChannel.Value, embed);
} }
@ -45,7 +47,8 @@ public class WebhookExecutorService(
/// </summary> /// </summary>
public void QueueLog(ulong channelId, IEmbed embed) public void QueueLog(ulong channelId, IEmbed embed)
{ {
if (channelId == 0) return; if (channelId == 0)
return;
var queue = _cache.GetOrAdd(channelId, []); var queue = _cache.GetOrAdd(channelId, []);
queue.Enqueue(embed); queue.Enqueue(embed);
@ -60,21 +63,39 @@ public class WebhookExecutorService(
/// <param name="channelId">The channel ID to send the content to.</param> /// <param name="channelId">The channel ID to send the content to.</param>
/// <param name="embeds">The embeds to send. Must be under 6000 characters in length total, this is not checked by this method.</param> /// <param name="embeds">The embeds to send. Must be under 6000 characters in length total, this is not checked by this method.</param>
/// <param name="files">The files to send.</param> /// <param name="files">The files to send.</param>
public async Task SendLogAsync(ulong channelId, List<IEmbed> embeds, IEnumerable<FileData> files) public async Task SendLogAsync(
ulong channelId,
List<IEmbed> embeds,
IEnumerable<FileData> files
)
{ {
if (channelId == 0) return; if (channelId == 0)
return;
var attachments = files var attachments = files
.Select<FileData, OneOf.OneOf<FileData, IPartialAttachment>>(f => f) .Select<FileData, OneOf.OneOf<FileData, IPartialAttachment>>(f => f)
.ToList(); .ToList();
_logger.Debug("Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}", embeds.Count, _logger.Debug(
attachments.Count, channelId); "Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}",
embeds.Count,
attachments.Count,
channelId
);
var webhook = await webhookCache.GetOrFetchWebhookAsync(channelId, id => FetchWebhookAsync(id)); var webhook = await webhookCache.GetOrFetchWebhookAsync(
await webhookApi.ExecuteWebhookAsync(DiscordSnowflake.New(webhook.Id), webhook.Token, shouldWait: false, channelId,
embeds: embeds, attachments: attachments, username: _selfUser!.Username, id => FetchWebhookAsync(id)
avatarUrl: _selfUser.AvatarUrl()); );
await webhookApi.ExecuteWebhookAsync(
DiscordSnowflake.New(webhook.Id),
webhook.Token,
shouldWait: false,
embeds: embeds,
attachments: attachments,
username: _selfUser!.Username,
avatarUrl: _selfUser.AvatarUrl()
);
} }
/// <summary> /// <summary>
@ -82,18 +103,25 @@ public class WebhookExecutorService(
/// </summary> /// </summary>
private void SetTimer(ulong channelId, ConcurrentQueue<IEmbed> queue) private void SetTimer(ulong channelId, ConcurrentQueue<IEmbed> queue)
{ {
if (_timers.TryGetValue(channelId, out var existingTimer)) existingTimer.Dispose(); if (_timers.TryGetValue(channelId, out var existingTimer))
_timers[channelId] = new Timer(_ => existingTimer.Dispose();
_timers[channelId] = new Timer(
_ =>
{ {
_logger.Debug("Sending 5 queued embeds"); _logger.Debug("Sending 5 queued embeds");
var __ = SendLogAsync(channelId, TakeFromQueue(channelId).ToList(), []); var __ = SendLogAsync(channelId, TakeFromQueue(channelId).ToList(), []);
if (!queue.IsEmpty) if (!queue.IsEmpty)
{ {
if (_timers.TryGetValue(channelId, out var timer)) timer.Dispose(); if (_timers.TryGetValue(channelId, out var timer))
timer.Dispose();
SetTimer(channelId, queue); SetTimer(channelId, queue);
} }
}, null, 3000, Timeout.Infinite); },
null,
3000,
Timeout.Infinite
);
} }
/// <summary> /// <summary>
@ -109,7 +137,8 @@ public class WebhookExecutorService(
var embeds = new List<IEmbed>(); var embeds = new List<IEmbed>();
for (var i = 0; i < 5; i++) for (var i = 0; i < 5; i++)
{ {
if (!queue.TryDequeue(out var embed)) break; if (!queue.TryDequeue(out var embed))
break;
embeds.Add(embed); embeds.Add(embed);
} }
@ -118,25 +147,48 @@ public class WebhookExecutorService(
} }
// TODO: make it so this method can only have one request per channel in flight simultaneously // TODO: make it so this method can only have one request per channel in flight simultaneously
private async Task<IWebhook> FetchWebhookAsync(Snowflake channelId, CancellationToken ct = default) private async Task<IWebhook> FetchWebhookAsync(
Snowflake channelId,
CancellationToken ct = default
)
{ {
var channelWebhooks = var channelWebhooks = await webhookApi.GetChannelWebhooksAsync(channelId, ct).GetOrThrow();
await webhookApi.GetChannelWebhooksAsync(channelId, ct).GetOrThrow(); var webhook = channelWebhooks.FirstOrDefault(w =>
var webhook = channelWebhooks.FirstOrDefault(w => w.ApplicationID == _applicationId && w.Token.IsDefined()); w.ApplicationID == _applicationId && w.Token.IsDefined()
if (webhook != null) return webhook; );
if (webhook != null)
return webhook;
return await webhookApi.CreateWebhookAsync(channelId, "Catalogger", default, reason: "Creating logging webhook", return await webhookApi
ct: ct).GetOrThrow(); .CreateWebhookAsync(
channelId,
"Catalogger",
default,
reason: "Creating logging webhook",
ct: ct
)
.GetOrThrow();
} }
public ulong? GetLogChannel(Guild guild, LogChannelType logChannelType, Snowflake? channelId = null, public ulong? GetLogChannel(
ulong? userId = null) Guild guild,
LogChannelType logChannelType,
Snowflake? channelId = null,
ulong? userId = null
)
{ {
if (channelId == null) return GetDefaultLogChannel(guild, logChannelType); if (channelId == null)
if (!channelCache.TryGet(channelId.Value, out var channel)) return null; return GetDefaultLogChannel(guild, logChannelType);
if (!channelCache.TryGet(channelId.Value, out var channel))
return null;
Snowflake? categoryId; Snowflake? categoryId;
if (channel.Type is ChannelType.AnnouncementThread or ChannelType.PrivateThread or ChannelType.PublicThread) if (
channel.Type
is ChannelType.AnnouncementThread
or ChannelType.PrivateThread
or ChannelType.PublicThread
)
{ {
// 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;
@ -151,42 +203,63 @@ public class WebhookExecutorService(
} }
// Check if the channel, or its category, or the user is ignored // Check if the channel, or its category, or the user is ignored
if (guild.Channels.IgnoredChannels.Contains(channelId.Value.Value) || if (
categoryId != null && guild.Channels.IgnoredChannels.Contains(categoryId.Value.Value)) return null; guild.Channels.IgnoredChannels.Contains(channelId.Value.Value)
|| categoryId != null && guild.Channels.IgnoredChannels.Contains(categoryId.Value.Value)
)
return null;
if (userId != null) if (userId != null)
{ {
if (guild.Channels.IgnoredUsers.Contains(userId.Value)) return null; if (guild.Channels.IgnoredUsers.Contains(userId.Value))
return null;
// Check the channel-local and category-local ignored users // Check the channel-local and category-local ignored users
var channelIgnoredUsers = var channelIgnoredUsers =
guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value) ?? []; guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value)
var categoryIgnoredUsers = (categoryId != null ?? [];
? guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(categoryId.Value.Value) var categoryIgnoredUsers =
: []) ?? []; (
if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value)) return null; categoryId != null
? guild.Channels.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. // These three events can be redirected to other channels. Redirects can be on a channel or category level.
// Obviously, the events are only redirected if they're supposed to be logged in the first place. // Obviously, the events are only redirected if they're supposed to be logged in the first place.
if (logChannelType is LogChannelType.MessageUpdate or LogChannelType.MessageDelete if (
or LogChannelType.MessageDeleteBulk) logChannelType
is LogChannelType.MessageUpdate
or LogChannelType.MessageDelete
or LogChannelType.MessageDeleteBulk
)
{ {
if (GetDefaultLogChannel(guild, logChannelType) == 0) return null; if (GetDefaultLogChannel(guild, logChannelType) == 0)
return null;
var categoryRedirect = categoryId != null var categoryRedirect =
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value) ? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0; : 0;
if (guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect)) if (
guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect)
)
return channelRedirect; return channelRedirect;
if (categoryRedirect != 0) return categoryRedirect; if (categoryRedirect != 0)
return categoryRedirect;
return GetDefaultLogChannel(guild, logChannelType); return GetDefaultLogChannel(guild, logChannelType);
} }
return GetDefaultLogChannel(guild, logChannelType); return GetDefaultLogChannel(guild, logChannelType);
} }
public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) => channelType switch public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) =>
channelType switch
{ {
LogChannelType.GuildUpdate => guild.Channels.GuildUpdate, LogChannelType.GuildUpdate => guild.Channels.GuildUpdate,
LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate, LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate,
@ -210,7 +283,7 @@ public class WebhookExecutorService(
LogChannelType.MessageUpdate => guild.Channels.MessageUpdate, LogChannelType.MessageUpdate => guild.Channels.MessageUpdate,
LogChannelType.MessageDelete => guild.Channels.MessageDelete, LogChannelType.MessageDelete => guild.Channels.MessageDelete,
LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk, LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk,
_ => throw new ArgumentOutOfRangeException(nameof(channelType)) _ => throw new ArgumentOutOfRangeException(nameof(channelType)),
}; };
} }
@ -237,5 +310,5 @@ public enum LogChannelType
InviteDelete, InviteDelete,
MessageUpdate, MessageUpdate,
MessageDelete, MessageDelete,
MessageDeleteBulk MessageDeleteBulk,
} }