feat: add channel create responder and redis webhook cache
This commit is contained in:
parent
99c1587e7b
commit
e86b37ce2a
11 changed files with 201 additions and 10 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
63
Catalogger.Backend/Cache/RoleCacheService.cs
Normal file
63
Catalogger.Backend/Cache/RoleCacheService.cs
Normal 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!);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
24
Catalogger.Backend/Database/Redis/RedisService.cs
Normal file
24
Catalogger.Backend/Database/Redis/RedisService.cs
Normal 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
15
Catalogger.Backend/Services/RedisWebhookCache.cs
Normal file
15
Catalogger.Backend/Services/RedisWebhookCache.cs
Normal 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}";
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue