// Copyright (C) 2021-present sam (starshines.gay) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . 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(); 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; /// /// Sets the current user for this webhook executor service. This must be called as soon as possible, /// before any logs are sent, such as in a READY event. /// public void SetSelfUser(IUser user) => _selfUser = user; /// /// 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. /// 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); } /// /// Queues a log embed for the given channel ID. /// 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); } /// /// Sends multiple embeds and/or files to a channel, bypassing the embed queue. /// /// The channel ID to send the content to. /// The embeds to send. Must be under 6000 characters in length total. /// The files to send. public async Task SendLogAsync( ulong channelId, List embeds, IEnumerable files ) { if (channelId == 0) return; var attachments = files .Select>(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; } if (embeds.Select(e => e.TextLength()).Sum() > MaxContentLength) { _logger.Error( "SendLogAsync was called with embeds totaling more than 6000 characters, 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() ); } /// /// Sets a 3 second timer for the given channel. /// private void SetTimer(ulong channelId, ConcurrentQueue 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; /// /// 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. /// private List TakeFromQueue(ulong channelId) { var queue = _cache.GetOrAdd(channelId, []); var channelLock = _locks.GetOrAdd(channelId, channelId); lock (channelLock) { var totalContentLength = 0; var embeds = new List(); 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 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; return categoryRedirect != 0 ? categoryRedirect : 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, }