using System.Collections.Concurrent; 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; // TODO: this entire class is a mess, clean it up public class WebhookExecutorService( Config config, ILogger logger, IWebhookCache webhookCache, ChannelCache channelCache, IDiscordRestWebhookAPI webhookApi) { private readonly ILogger _logger = logger.ForContext(); private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId); private readonly ConcurrentDictionary> _cache = new(); private readonly ConcurrentDictionary _locks = new(); private readonly ConcurrentDictionary _timers = new(); private IUser? _selfUser; public void SetSelfUser(IUser user) => _selfUser = user; public async Task QueueLogAsync(Guild guild, LogChannelType logChannelType, IEmbed embed) { var logChannel = GetLogChannel(guild, logChannelType, channelId: null, userId: null); if (logChannel == null) return; await QueueLogAsync(logChannel.Value, embed); } private List TakeFromQueue(ulong channelId) { var queue = _cache.GetOrAdd(channelId, []); var channelLock = _locks.GetOrAdd(channelId, channelId); lock (channelLock) { var embeds = new List(); for (var i = 0; i < 5; i++) { if (!queue.TryDequeue(out var embed)) break; embeds.Add(embed); } return embeds; } } public async Task QueueLogAsync(ulong channelId, IEmbed embed) { if (channelId == 0) return; var queue = _cache.GetOrAdd(channelId, []); queue.Enqueue(embed); _cache[channelId] = queue; await SetTimer(channelId, queue); } private async Task SetTimer(ulong channelId, ConcurrentQueue queue) { if (_timers.TryGetValue(channelId, out var existingTimer)) await existingTimer.DisposeAsync(); _timers[channelId] = new Timer(_ => { _logger.Debug("Sending 5 queued embeds"); var __ = SendLogsAsync(channelId, TakeFromQueue(channelId)); if (!queue.IsEmpty) { if (_timers.TryGetValue(channelId, out var timer)) timer.Dispose(); var ___ = SetTimer(channelId, queue); } }, null, 3000, Timeout.Infinite); } private async Task SendLogsAsync(ulong channelId, List embeds) { _logger.Debug("Sending {Count} embeds to channel {ChannelId}", embeds.Count, channelId); if (embeds.Count == 0) return; var webhook = await webhookCache.GetOrFetchWebhookAsync(channelId, id => FetchWebhookAsync(id)); await webhookApi.ExecuteWebhookAsync(DiscordSnowflake.New(webhook.Id), webhook.Token, shouldWait: false, embeds: embeds, username: _selfUser!.Username, avatarUrl: _selfUser.AvatarUrl()); } public async Task SendLogWithAttachmentsAsync(ulong channelId, List embeds, IEnumerable files) { if (channelId == 0) return; var attachments = files .Select>(f => f) .ToList(); 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()); } private async Task 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.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, GuildMemberRemove, GuildMemberKick, GuildBanAdd, GuildBanRemove, InviteCreate, InviteDelete, MessageUpdate, MessageDelete, MessageDeleteBulk }