This commit is contained in:
sam 2024-08-13 13:08:50 +02:00
commit ded4f4db26
Signed by: sam
GPG key ID: 5F3C3C1B3166639D
43 changed files with 2052 additions and 0 deletions

View file

@ -0,0 +1,29 @@
using Catalogger.Backend.Extensions;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Services;
public interface IWebhookCache
{
Task<Webhook?> GetWebhookAsync(ulong channelId);
Task SetWebhookAsync(ulong channelId, Webhook webhook);
public async Task<Webhook> GetOrFetchWebhookAsync(ulong channelId, Func<Snowflake, Task<IWebhook>> fetch)
{
var webhook = await GetWebhookAsync(channelId);
if (webhook != null) return webhook.Value;
var discordWebhook = await fetch(DiscordSnowflake.New(channelId));
webhook = new Webhook { Id = discordWebhook.ID.ToUlong(), Token = discordWebhook.Token.Value};
await SetWebhookAsync(channelId, webhook.Value);
return webhook.Value;
}
}
public struct Webhook
{
public required ulong Id { get; init; }
public required string Token { get; init; }
}

View file

@ -0,0 +1,21 @@
using System.Collections.Concurrent;
namespace Catalogger.Backend.Services;
public class InMemoryWebhookCache : IWebhookCache
{
private readonly ConcurrentDictionary<ulong, Webhook> _cache = new();
public Task<Webhook?> GetWebhookAsync(ulong channelId)
{
return _cache.TryGetValue(channelId, out var webhook)
? Task.FromResult<Webhook?>(webhook)
: Task.FromResult<Webhook?>(null);
}
public Task SetWebhookAsync(ulong channelId, Webhook webhook)
{
_cache[channelId] = webhook;
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,73 @@
using System.Net;
using System.Text.Json;
using System.Threading.RateLimiting;
using Humanizer;
using NodaTime;
using Polly;
using Remora.Rest.Json.Policies;
namespace Catalogger.Backend.Services;
public class PluralkitApiService(ILogger logger)
{
private const string UserAgent = "Catalogger.NET (https://codeberg.org/starshine/catalogger)";
private const string ApiBaseUrl = "https://api.pluralkit.me/v2";
private readonly HttpClient _client = new();
private readonly ILogger _logger = logger.ForContext<PluralkitApiService>();
private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter(new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions()
{
Window = 1.Seconds(),
PermitLimit = 2,
QueueLimit = 64,
}))
.AddTimeout(20.Seconds())
.Build();
private async Task<T?> DoRequestAsync<T>(string path, bool allowNotFound = false,
CancellationToken ct = default) where T : class
{
var req = new HttpRequestMessage(HttpMethod.Get, $"{ApiBaseUrl}{path}");
req.Headers.Add("User-Agent", UserAgent);
_logger.Debug("Requesting {Path} from PluralKit API", path);
var resp = await _client.SendAsync(req, ct);
if (resp.StatusCode == HttpStatusCode.NotFound && allowNotFound)
{
_logger.Debug("PluralKit API path {Path} returned 404 but 404 response is valid", path);
return null;
}
if (!resp.IsSuccessStatusCode)
{
_logger.Error("Received non-200 status code {StatusCode} from PluralKit API path {Path}", resp.StatusCode,
req);
throw new CataloggerError("Non-200 status code from PluralKit API");
}
return await resp.Content.ReadFromJsonAsync<T>(new JsonSerializerOptions
{ PropertyNamingPolicy = new SnakeCaseNamingPolicy() }, ct) ??
throw new CataloggerError("JSON response from PluralKit API was null");
}
public async Task<PkMessage?> GetPluralKitMessageAsync(ulong id, CancellationToken ct = default) =>
await DoRequestAsync<PkMessage>($"/messages/{id}", allowNotFound: true, ct);
public async Task<PkSystem> GetPluralKitSystemAsync(ulong id, CancellationToken ct = default) =>
(await DoRequestAsync<PkSystem>($"/systems/{id}", allowNotFound: false, ct))!;
public record PkMessage(
ulong Id,
ulong Original,
ulong Sender,
ulong Channel,
ulong Guild,
PkSystem? System,
PkMember? Member);
public record PkSystem(string Id, Guid Uuid, string? Name, string? Tag, Instant? Created);
public record PkMember(string Id, Guid Uuid, string Name, string? DisplayName);
}

View file

@ -0,0 +1,186 @@
using System.Collections.Concurrent;
using Catalogger.Backend.Cache;
using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Extensions;
using Humanizer;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Rest.Core;
namespace Catalogger.Backend.Services;
public class WebhookExecutorService(
Config config,
ILogger logger,
IWebhookCache webhookCache,
ChannelCacheService 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, Timer> _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);
}
public async Task QueueLogAsync(ulong channelId, IEmbed embed)
{
_logger.Debug("Queueing embed for channel {ChannelId}", channelId);
var webhook = await webhookCache.GetOrFetchWebhookAsync(channelId, id => FetchWebhookAsync(id));
var queue = _cache.GetOrAdd(channelId, []);
if (queue.Count >= 5)
await SendLogsAsync(channelId);
queue.Enqueue(embed);
if (_timers.TryGetValue(channelId, out var existingTimer)) await existingTimer.DisposeAsync();
_timers[channelId] = new Timer(_ =>
{
var __ = SendLogsAsync(channelId);
}, null, 3000, Timeout.Infinite);
}
private async Task SendLogsAsync(ulong channelId)
{
var queue = _cache.GetValueOrDefault(channelId);
if (queue == null) return;
var embeds = queue.Take(5).ToList();
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());
}
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.GetChannel(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.GetChannel(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) == null) return null;
ulong 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);
}
private 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
}