diff --git a/Catalogger.Backend/Bot/DiscordUtils.cs b/Catalogger.Backend/Bot/DiscordUtils.cs index 2a93b53..94a8d07 100644 --- a/Catalogger.Backend/Bot/DiscordUtils.cs +++ b/Catalogger.Backend/Bot/DiscordUtils.cs @@ -10,4 +10,5 @@ public static class DiscordUtils public static readonly Color Red = Color.FromArgb(231, 76, 60); public static readonly Color Purple = Color.FromArgb(155, 89, 182); + public static readonly Color Green = Color.FromArgb(46, 204, 113); } \ No newline at end of file diff --git a/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs new file mode 100644 index 0000000..b1ba94a --- /dev/null +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs @@ -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 +{ + public async Task 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; + } +} \ No newline at end of file diff --git a/Catalogger.Backend/Bot/Responders/GuildCreateResponder.cs b/Catalogger.Backend/Bot/Responders/GuildCreateResponder.cs index 18d3174..e84f2ae 100644 --- a/Catalogger.Backend/Bot/Responders/GuildCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/GuildCreateResponder.cs @@ -17,6 +17,7 @@ public class GuildCreateResponder( ILogger logger, DatabaseContext db, GuildCacheService guildCache, + RoleCacheService roleCache, ChannelCacheService channelCache, WebhookExecutorService webhookExecutor) : IResponder, IResponder @@ -27,7 +28,7 @@ public class GuildCreateResponder( { ulong guildId; 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); guildId = guild.ID.ToUlong(); @@ -35,13 +36,13 @@ public class GuildCreateResponder( guildCache.Set(guild); 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); guildId = unavailableGuild.ID.ToUlong(); } - else throw new UnreachableException(); var tx = await db.Database.BeginTransactionAsync(ct); if (await db.Guilds.FindAsync([guildId], ct) != null) return Result.Success; diff --git a/Catalogger.Backend/Cache/RoleCacheService.cs b/Catalogger.Backend/Cache/RoleCacheService.cs new file mode 100644 index 0000000..a51cf94 --- /dev/null +++ b/Catalogger.Backend/Cache/RoleCacheService.cs @@ -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 _roles = new(); + private readonly ConcurrentDictionary> _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 _); + } + + /// + /// Gets all of a guild's cached roles. + /// + /// The guild to get the roles of + /// A list of cached roles + public IEnumerable GuildRoles(Snowflake guildId) => + !_guildRoles.TryGetValue(guildId, out var roleIds) + ? [] + : roleIds.Select(id => _roles.GetValueOrDefault(id)) + .Where(r => r != null).Select(r => r!); +} \ No newline at end of file diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index 362da66..d0bd5c2 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -29,7 +29,6 @@ - diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index 0235d49..b7aef8a 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -18,7 +18,7 @@ public class Config public class DatabaseConfig { 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? MaxPoolSize { get; init; } public string EncryptionKey { get; init; } = string.Empty; diff --git a/Catalogger.Backend/Database/Redis/RedisService.cs b/Catalogger.Backend/Database/Redis/RedisService.cs new file mode 100644 index 0000000..d3b3a11 --- /dev/null +++ b/Catalogger.Backend/Database/Redis/RedisService.cs @@ -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(string key, T value, TimeSpan? expiry = null) + { + var json = JsonSerializer.Serialize(value); + await GetDatabase().StringSetAsync(key, json, expiry); + } + + public async Task GetAsync(string key) + { + var value = await GetDatabase().StringGetAsync(key); + return value.IsNull ? default : JsonSerializer.Deserialize(value!); + } +} \ No newline at end of file diff --git a/Catalogger.Backend/Extensions/DiscordExtensions.cs b/Catalogger.Backend/Extensions/DiscordExtensions.cs index 723e484..27e721b 100644 --- a/Catalogger.Backend/Extensions/DiscordExtensions.cs +++ b/Catalogger.Backend/Extensions/DiscordExtensions.cs @@ -1,3 +1,4 @@ +using Humanizer; using OneOf; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; @@ -66,5 +67,8 @@ public static class DiscordExtensions new Optional>(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); } \ No newline at end of file diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index 33eb9bb..14c7d86 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -3,6 +3,7 @@ using Catalogger.Backend.Bot.Responders; using Catalogger.Backend.Cache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Queries; +using Catalogger.Backend.Database.Redis; using Catalogger.Backend.Services; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -21,10 +22,8 @@ public static class StartupExtensions /// /// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls. /// - public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder) + public static WebApplicationBuilder AddSerilog(this WebApplicationBuilder builder, Config config) { - var config = builder.Configuration.Get() ?? new(); - var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() .MinimumLevel.Is(config.Logging.LogEventLevel) @@ -67,10 +66,10 @@ public static class StartupExtensions public static IServiceCollection AddCustomServices(this IServiceCollection services) => services .AddSingleton(SystemClock.Instance) .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton() .AddScoped() .AddScoped() .AddSingleton() @@ -78,6 +77,17 @@ public static class StartupExtensions .AddSingleton(InMemoryDataService.Instance) .AddHostedService(); + public static IServiceCollection MaybeAddRedisCaches(this IServiceCollection services, Config config) + { + if (config.Database.Redis == null) + { + return services.AddSingleton(); + } + + return services.AddSingleton() + .AddScoped(); + } + public static async Task Initialize(this WebApplication app) { await using var scope = app.Services.CreateAsyncScope(); diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index d5a4a55..1967ce5 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -15,7 +15,7 @@ using Serilog; var builder = WebApplication.CreateBuilder(args); var config = builder.AddConfiguration(); -builder.AddSerilog(); +builder.AddSerilog(config); builder.Services .AddControllers() @@ -57,6 +57,7 @@ builder.Services.AddSingleton(metricsBuilder.Build()); builder.Services .AddDbContext() + .MaybeAddRedisCaches(config) .AddCustomServices() .AddEndpointsApiExplorer() .AddSwaggerGen(); diff --git a/Catalogger.Backend/Services/RedisWebhookCache.cs b/Catalogger.Backend/Services/RedisWebhookCache.cs new file mode 100644 index 0000000..4e13da6 --- /dev/null +++ b/Catalogger.Backend/Services/RedisWebhookCache.cs @@ -0,0 +1,15 @@ +using Catalogger.Backend.Database.Redis; +using Humanizer; + +namespace Catalogger.Backend.Services; + +public class RedisWebhookCache(RedisService redisService) : IWebhookCache +{ + public async Task GetWebhookAsync(ulong channelId) => + await redisService.GetAsync(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}"; +} \ No newline at end of file