feat: add channel create responder and redis webhook cache

This commit is contained in:
sam 2024-08-16 22:28:05 +02:00
parent 99c1587e7b
commit e86b37ce2a
11 changed files with 201 additions and 10 deletions

View file

@ -10,4 +10,5 @@ public static class DiscordUtils
public static readonly Color Red = Color.FromArgb(231, 76, 60); public static readonly Color Red = Color.FromArgb(231, 76, 60);
public static readonly Color Purple = Color.FromArgb(155, 89, 182); public static readonly Color Purple = Color.FromArgb(155, 89, 182);
public static readonly Color Green = Color.FromArgb(46, 204, 113);
} }

View file

@ -0,0 +1,73 @@
using Catalogger.Backend.Cache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders;
using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.Channels;
public class ChannelCreateResponder(
DatabaseContext db,
RoleCacheService roleCache,
ChannelCacheService channelCache,
WebhookExecutorService webhookExecutor) : IResponder<IChannelCreate>
{
public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default)
{
if (!ch.GuildID.IsDefined()) return Result.Success;
channelCache.Set(ch);
var builder = new EmbedBuilder()
.WithTitle(ch.Type switch
{
ChannelType.GuildVoice => "Voice channel created",
ChannelType.GuildCategory => "Category channel created",
ChannelType.GuildAnnouncement or ChannelType.GuildText => "Text channel created",
_ => "Chanel created"
})
.WithColour(DiscordUtils.Green)
.WithFooter($"ID: {ch.ID}");
if (ch.ParentID.IsDefined(out var parentId))
{
builder.WithDescription(channelCache.TryGet(parentId.Value, out var parentChannel)
? $"**Name:** {ch.Name}\n**Category:** {parentChannel.Name}"
: $"**Name:** {ch.Name}");
}
else builder.WithDescription($"**Name:** {ch.Name}");
foreach (var overwrite in ch.PermissionOverwrites.OrDefault() ?? [])
{
if (overwrite.Type == PermissionOverwriteType.Role)
{
var roleName = roleCache.TryGet(overwrite.ID, out var role) ? role.Name : $"role {overwrite.ID}";
var embedFieldValue = "";
if (overwrite.Allow.GetPermissions().Count != 0)
embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}";
if (overwrite.Deny.GetPermissions().Count != 0)
embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}";
builder.AddField($"Override for {roleName}", embedFieldValue.Trim());
}
else
{
var embedFieldValue = "";
if (overwrite.Allow.GetPermissions().Count != 0)
embedFieldValue += $"\u2705 {overwrite.Allow.ToPrettyString()}";
if (overwrite.Deny.GetPermissions().Count != 0)
embedFieldValue += $"\n\n\u274c {overwrite.Deny.ToPrettyString()}";
builder.AddField($"Override for user {overwrite.ID}", embedFieldValue.Trim());
}
}
var guildConfig = await db.GetGuildAsync(ch.GuildID.Value, ct);
await webhookExecutor.QueueLogAsync(guildConfig, LogChannelType.ChannelCreate, builder.Build().GetOrThrow());
return Result.Success;
}
}

View file

@ -17,6 +17,7 @@ public class GuildCreateResponder(
ILogger logger, ILogger logger,
DatabaseContext db, DatabaseContext db,
GuildCacheService guildCache, GuildCacheService guildCache,
RoleCacheService roleCache,
ChannelCacheService channelCache, ChannelCacheService channelCache,
WebhookExecutorService webhookExecutor) WebhookExecutorService webhookExecutor)
: IResponder<IGuildCreate>, IResponder<GuildDelete> : IResponder<IGuildCreate>, IResponder<GuildDelete>
@ -27,7 +28,7 @@ public class GuildCreateResponder(
{ {
ulong guildId; ulong guildId;
string? guildName = null; string? guildName = null;
if (evt.Guild.TryPickT0(out var guild, out _)) 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();
@ -35,13 +36,13 @@ public class GuildCreateResponder(
guildCache.Set(guild); guildCache.Set(guild);
foreach (var c in guild.Channels) channelCache.Set(c, guild.ID); foreach (var c in guild.Channels) channelCache.Set(c, guild.ID);
foreach (var r in guild.Roles) roleCache.Set(r, guild.ID);
} }
else if (evt.Guild.TryPickT1(out var unavailableGuild, out _)) 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();
} }
else throw new UnreachableException();
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;

View file

@ -0,0 +1,63 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core;
namespace Catalogger.Backend.Cache;
public class RoleCacheService
{
private readonly ConcurrentDictionary<Snowflake, IRole> _roles = new();
private readonly ConcurrentDictionary<Snowflake, HashSet<Snowflake>> _guildRoles = new();
public int Size => _roles.Count;
public void Set(IRole role, Snowflake guildId)
{
_roles[role.ID] = role;
// Add to set of guild channels
_guildRoles.AddOrUpdate(guildId,
_ => [role.ID],
(_, l) =>
{
l.Add(role.ID);
return l;
});
}
public bool TryGet(Snowflake id, [NotNullWhen(true)] out IRole? role) =>
_roles.TryGetValue(id, out role);
public void Remove(Snowflake guildId, Snowflake id, out IRole? role)
{
_roles.Remove(id, out role);
// Remove from set of guild channels
_guildRoles.AddOrUpdate(guildId, _ => [], (_, s) =>
{
s.Remove(id);
return s;
});
}
public void RemoveGuild(Snowflake guildId)
{
if (!_guildRoles.TryGetValue(guildId, out var roleIds)) return;
foreach (var id in roleIds)
{
_roles.Remove(id, out _);
}
_guildRoles.Remove(guildId, out _);
}
/// <summary>
/// Gets all of a guild's cached roles.
/// </summary>
/// <param name="guildId">The guild to get the roles of</param>
/// <returns>A list of cached roles</returns>
public IEnumerable<IRole> GuildRoles(Snowflake guildId) =>
!_guildRoles.TryGetValue(guildId, out var roleIds)
? []
: roleIds.Select(id => _roles.GetValueOrDefault(id))
.Where(r => r != null).Select(r => r!);
}

View file

@ -29,7 +29,6 @@
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/> <PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.0"/> <PackageReference Include="StackExchange.Redis" Version="2.8.0"/>
<PackageReference Include="StackExchange.Redis.Extensions.System.Text.Json" Version="10.2.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
</ItemGroup> </ItemGroup>

View file

@ -18,7 +18,7 @@ public class Config
public class DatabaseConfig public class DatabaseConfig
{ {
public string Url { get; init; } = string.Empty; public string Url { get; init; } = string.Empty;
public string Redis { get; init; } = string.Empty; public string? Redis { get; init; }
public int? Timeout { get; init; } public int? Timeout { get; init; }
public int? MaxPoolSize { get; init; } public int? MaxPoolSize { get; init; }
public string EncryptionKey { get; init; } = string.Empty; public string EncryptionKey { get; init; } = string.Empty;

View file

@ -0,0 +1,24 @@
using System.Text.Json;
using Humanizer;
using StackExchange.Redis;
namespace Catalogger.Backend.Database.Redis;
public class RedisService(Config config)
{
private readonly ConnectionMultiplexer _multiplexer = ConnectionMultiplexer.Connect(config.Database.Redis!);
public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db);
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{
var json = JsonSerializer.Serialize(value);
await GetDatabase().StringSetAsync(key, json, expiry);
}
public async Task<T?> GetAsync<T>(string key)
{
var value = await GetDatabase().StringGetAsync(key);
return value.IsNull ? default : JsonSerializer.Deserialize<T>(value!);
}
}

View file

@ -1,3 +1,4 @@
using Humanizer;
using OneOf; using OneOf;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
@ -66,5 +67,8 @@ public static class DiscordExtensions
new Optional<OneOf<IInteractionMessageCallbackData, IInteractionAutocompleteCallbackData, new Optional<OneOf<IInteractionMessageCallbackData, IInteractionAutocompleteCallbackData,
IInteractionModalCallbackData>>(data))); IInteractionModalCallbackData>>(data)));
public static string ToPrettyString(this IDiscordPermissionSet permissionSet) =>
string.Join(", ", permissionSet.GetPermissions().Select(p => p.Humanize(LetterCasing.Title)));
public class DiscordRestException(string message) : Exception(message); public class DiscordRestException(string message) : Exception(message);
} }

View file

@ -3,6 +3,7 @@ using Catalogger.Backend.Bot.Responders;
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries; using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Database.Redis;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
@ -21,10 +22,8 @@ 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) public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder, Config config)
{ {
var config = builder.Configuration.Get<Config>() ?? new();
var logCfg = new LoggerConfiguration() var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext() .Enrich.FromLogContext()
.MinimumLevel.Is(config.Logging.LogEventLevel) .MinimumLevel.Is(config.Logging.LogEventLevel)
@ -67,10 +66,10 @@ public static class StartupExtensions
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<GuildCacheService>() .AddSingleton<GuildCacheService>()
.AddSingleton<RoleCacheService>()
.AddSingleton<ChannelCacheService>() .AddSingleton<ChannelCacheService>()
.AddSingleton<UserCacheService>() .AddSingleton<UserCacheService>()
.AddSingleton<PluralkitApiService>() .AddSingleton<PluralkitApiService>()
.AddSingleton<IWebhookCache, InMemoryWebhookCache>()
.AddScoped<IEncryptionService, EncryptionService>() .AddScoped<IEncryptionService, EncryptionService>()
.AddScoped<MessageRepository>() .AddScoped<MessageRepository>()
.AddSingleton<WebhookExecutorService>() .AddSingleton<WebhookExecutorService>()
@ -78,6 +77,17 @@ public static class StartupExtensions
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance) .AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddHostedService<MetricsCollectionService>(); .AddHostedService<MetricsCollectionService>();
public static IServiceCollection MaybeAddRedisCaches(this IServiceCollection services, Config config)
{
if (config.Database.Redis == null)
{
return services.AddSingleton<IWebhookCache, InMemoryWebhookCache>();
}
return services.AddSingleton<RedisService>()
.AddScoped<IWebhookCache, RedisWebhookCache>();
}
public static async Task Initialize(this WebApplication app) public static async Task Initialize(this WebApplication app)
{ {
await using var scope = app.Services.CreateAsyncScope(); await using var scope = app.Services.CreateAsyncScope();

View file

@ -15,7 +15,7 @@ using Serilog;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration(); var config = builder.AddConfiguration();
builder.AddSerilog(); builder.AddSerilog(config);
builder.Services builder.Services
.AddControllers() .AddControllers()
@ -57,6 +57,7 @@ builder.Services.AddSingleton<IMetrics>(metricsBuilder.Build());
builder.Services builder.Services
.AddDbContext<DatabaseContext>() .AddDbContext<DatabaseContext>()
.MaybeAddRedisCaches(config)
.AddCustomServices() .AddCustomServices()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen(); .AddSwaggerGen();

View file

@ -0,0 +1,15 @@
using Catalogger.Backend.Database.Redis;
using Humanizer;
namespace Catalogger.Backend.Services;
public class RedisWebhookCache(RedisService redisService) : IWebhookCache
{
public async Task<Webhook?> GetWebhookAsync(ulong channelId) =>
await redisService.GetAsync<Webhook?>(WebhookKey(channelId));
public async Task SetWebhookAsync(ulong channelId, Webhook webhook) =>
await redisService.SetAsync(WebhookKey(channelId), webhook, 24.Hours());
private static string WebhookKey(ulong channelId) => $"webhook:{channelId}";
}