Catalogger.NET/Catalogger.Backend/Services/WebhookExecutorService.cs
sam d221441c10 feat: tweak embed dequeueing logic
We no longer blindly dequeue 5 embeds, we check their length too.
The webhook executor will now send up to 10 embeds OR
embeds totaling less than 6000 characters, whichever is less.
Embeds longer than 6000 characters are discarded to prevent errors.
We also check for an empty request body in SendLogAsync and bail to prevent 400s.
2024-10-12 23:47:18 +02:00

360 lines
13 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Extensions;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Rest.Core;
using Guild = Catalogger.Backend.Database.Models.Guild;
namespace Catalogger.Backend.Services;
[SuppressMessage(
"ReSharper",
"InconsistentlySynchronizedField",
Justification = "ILogger doesn't need to be synchronized"
)]
public class WebhookExecutorService(
Config config,
ILogger logger,
IWebhookCache webhookCache,
ChannelCache channelCache,
IDiscordRestWebhookAPI webhookApi
)
{
private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>();
private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId);
private readonly ConcurrentDictionary<ulong, ConcurrentQueue<IEmbed>> _cache = new();
private readonly ConcurrentDictionary<ulong, object> _locks = new();
private readonly ConcurrentDictionary<ulong, Timer> _timers = new();
private IUser? _selfUser;
public void SetSelfUser(IUser user) => _selfUser = user;
/// <summary>
/// Queues a log embed for the given log channel type.
/// If the log channel is already known, use the ulong overload of this method instead.
/// If the log channel depends on the source channel and source user, also use the ulong overload.
/// </summary>
public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed)
{
var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null);
if (logChannel == null)
return;
QueueLog(logChannel.Value, embed);
}
/// <summary>
/// Queues a log embed for the given channel ID.
/// </summary>
public void QueueLog(ulong channelId, IEmbed embed)
{
if (channelId == 0)
return;
var queue = _cache.GetOrAdd(channelId, []);
queue.Enqueue(embed);
_cache[channelId] = queue;
SetTimer(channelId, queue);
}
/// <summary>
/// Sends multiple embeds and/or files to a channel, bypassing the embed queue.
/// </summary>
/// <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="files">The files to send.</param>
public async Task SendLogAsync(
ulong channelId,
List<IEmbed> embeds,
IEnumerable<FileData> files
)
{
if (channelId == 0)
return;
var attachments = files
.Select<FileData, OneOf.OneOf<FileData, IPartialAttachment>>(f => f)
.ToList();
if (embeds.Count == 0 && attachments.Count == 0)
{
_logger.Error(
"SendLogAsync was called with zero embeds and zero attachments, bailing to prevent a bad request error"
);
return;
}
_logger.Debug(
"Sending {EmbedCount} embeds/{FileCount} files to channel {ChannelId}",
embeds.Count,
attachments.Count,
channelId
);
var webhook = await webhookCache.GetOrFetchWebhookAsync(
channelId,
id => FetchWebhookAsync(id)
);
await webhookApi.ExecuteWebhookAsync(
DiscordSnowflake.New(webhook.Id),
webhook.Token,
shouldWait: false,
embeds: embeds,
attachments: attachments,
username: _selfUser!.Username,
avatarUrl: _selfUser.AvatarUrl()
);
}
/// <summary>
/// Sets a 3 second timer for the given channel.
/// </summary>
private void SetTimer(ulong channelId, ConcurrentQueue<IEmbed> queue)
{
if (_timers.TryGetValue(channelId, out var existingTimer))
existingTimer.Dispose();
_timers[channelId] = new Timer(
_ =>
{
var __ = SendLogAsync(channelId, TakeFromQueue(channelId), []);
if (!queue.IsEmpty)
{
if (_timers.TryGetValue(channelId, out var timer))
timer.Dispose();
SetTimer(channelId, queue);
}
},
null,
3000,
Timeout.Infinite
);
}
private const int MaxContentLength = 6000;
/// <summary>
/// Takes as many embeds as possible from the queue for the given channel.
/// Up to ten embeds are returned, or less if their combined length is longer than 6000 characters.
/// Note that this locks the queue to prevent duplicate embeds from being sent.
/// </summary>
private List<IEmbed> TakeFromQueue(ulong channelId)
{
var queue = _cache.GetOrAdd(channelId, []);
var channelLock = _locks.GetOrAdd(channelId, channelId);
lock (channelLock)
{
var totalContentLength = 0;
var embeds = new List<IEmbed>();
while (embeds.Count < 10 && totalContentLength < MaxContentLength)
{
if (!queue.TryPeek(out var embed))
break;
var length = embed.TextLength();
if (length > MaxContentLength)
{
_logger.Warning(
"Queued embed for {ChannelId} exceeds maximum length, discarding it",
channelId
);
queue.TryDequeue(out _);
break;
}
if (totalContentLength + length > MaxContentLength)
break;
totalContentLength += length;
queue.TryDequeue(out _);
embeds.Add(embed);
}
if (embeds.Count == 0)
return embeds;
_logger.Debug(
"Took {EmbedCount} embeds from queue for {ChannelId}, total length is {TotalLength}",
embeds.Count,
channelId,
totalContentLength
);
return embeds;
}
}
// 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
)
{
var channelWebhooks = await webhookApi.GetChannelWebhooksAsync(channelId, ct).GetOrThrow();
var webhook = channelWebhooks.FirstOrDefault(w =>
w.ApplicationID == _applicationId && w.Token.IsDefined()
);
if (webhook != null)
return webhook;
return await webhookApi
.CreateWebhookAsync(
channelId,
"Catalogger",
default,
reason: "Creating logging webhook",
ct: ct
)
.GetOrThrow();
}
public ulong? GetLogChannel(
Guild guild,
LogChannelType logChannelType,
Snowflake? channelId = null,
ulong? userId = null
)
{
if (channelId == null)
return GetDefaultLogChannel(guild, logChannelType);
if (!channelCache.TryGet(channelId.Value, out var channel))
return null;
Snowflake? categoryId;
if (
channel.Type
is ChannelType.AnnouncementThread
or ChannelType.PrivateThread
or ChannelType.PublicThread
)
{
// parent_id should always have a value for threads
channelId = channel.ParentID.Value!.Value;
if (!channelCache.TryGet(channelId.Value, out var parentChannel))
return GetDefaultLogChannel(guild, logChannelType);
categoryId = parentChannel.ParentID.Value;
}
else
{
channelId = channel.ID;
categoryId = channel.ParentID.Value;
}
// Check if the channel, or its category, or the user is ignored
if (
guild.Channels.IgnoredChannels.Contains(channelId.Value.Value)
|| categoryId != null && guild.Channels.IgnoredChannels.Contains(categoryId.Value.Value)
)
return null;
if (userId != null)
{
if (guild.Channels.IgnoredUsers.Contains(userId.Value))
return null;
// Check the channel-local and category-local ignored users
var channelIgnoredUsers =
guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value)
?? [];
var categoryIgnoredUsers =
(
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.
// 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
or LogChannelType.MessageDeleteBulk
)
{
if (GetDefaultLogChannel(guild, logChannelType) == 0)
return null;
var categoryRedirect =
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0;
if (
guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect)
)
return channelRedirect;
if (categoryRedirect != 0)
return categoryRedirect;
return GetDefaultLogChannel(guild, logChannelType);
}
return GetDefaultLogChannel(guild, logChannelType);
}
public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) =>
channelType switch
{
LogChannelType.GuildUpdate => guild.Channels.GuildUpdate,
LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate,
LogChannelType.GuildRoleCreate => guild.Channels.GuildRoleCreate,
LogChannelType.GuildRoleUpdate => guild.Channels.GuildRoleUpdate,
LogChannelType.GuildRoleDelete => guild.Channels.GuildRoleDelete,
LogChannelType.ChannelCreate => guild.Channels.ChannelCreate,
LogChannelType.ChannelUpdate => guild.Channels.ChannelUpdate,
LogChannelType.ChannelDelete => guild.Channels.ChannelDelete,
LogChannelType.GuildMemberAdd => guild.Channels.GuildMemberAdd,
LogChannelType.GuildMemberUpdate => guild.Channels.GuildMemberUpdate,
LogChannelType.GuildKeyRoleUpdate => guild.Channels.GuildKeyRoleUpdate,
LogChannelType.GuildMemberNickUpdate => guild.Channels.GuildMemberNickUpdate,
LogChannelType.GuildMemberAvatarUpdate => guild.Channels.GuildMemberAvatarUpdate,
LogChannelType.GuildMemberTimeout => guild.Channels.GuildMemberTimeout,
LogChannelType.GuildMemberRemove => guild.Channels.GuildMemberRemove,
LogChannelType.GuildMemberKick => guild.Channels.GuildMemberKick,
LogChannelType.GuildBanAdd => guild.Channels.GuildBanAdd,
LogChannelType.GuildBanRemove => guild.Channels.GuildBanRemove,
LogChannelType.InviteCreate => guild.Channels.InviteCreate,
LogChannelType.InviteDelete => guild.Channels.InviteDelete,
LogChannelType.MessageUpdate => guild.Channels.MessageUpdate,
LogChannelType.MessageDelete => guild.Channels.MessageDelete,
LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk,
_ => throw new ArgumentOutOfRangeException(nameof(channelType)),
};
}
public enum LogChannelType
{
GuildUpdate,
GuildEmojisUpdate,
GuildRoleCreate,
GuildRoleUpdate,
GuildRoleDelete,
ChannelCreate,
ChannelUpdate,
ChannelDelete,
GuildMemberAdd,
GuildMemberUpdate,
GuildKeyRoleUpdate,
GuildMemberNickUpdate,
GuildMemberAvatarUpdate,
GuildMemberTimeout,
GuildMemberRemove,
GuildMemberKick,
GuildBanAdd,
GuildBanRemove,
InviteCreate,
InviteDelete,
MessageUpdate,
MessageDelete,
MessageDeleteBulk,
}