diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d2b1b96..b315638 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,14 +3,14 @@ "isRoot": true, "tools": { "csharpier": { - "version": "0.30.6", + "version": "0.29.2", "commands": [ "dotnet-csharpier" ], "rollForward": false }, "husky": { - "version": "0.7.2", + "version": "0.7.1", "commands": [ "husky" ], diff --git a/.editorconfig b/.editorconfig index 1e5c57b..d20e217 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,7 +14,4 @@ resharper_entity_framework_model_validation_unlimited_string_length_highlighting # This is raised for every single property of records returned by endpoints resharper_not_accessed_positional_property_local_highlighting = none # ReSharper yells at us for the name "GuildCache", for some reason -resharper_inconsistent_naming_highlighting = none -# From the docs: "You might consider excluding [FirstOrDefault or LastOrDefault] if readability is a concern, -# since the code you'd write to replace them is not easily readable." -dotnet_code_quality.CA1826.exclude_ordefault_methods = true \ No newline at end of file +resharper_inconsistent_naming_highlighting = none \ No newline at end of file diff --git a/.gitignore b/.gitignore index 383eadd..7d908b7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ riderModule.iml /_ReSharper.Caches/ config.ini *.DotSettings.user -.version diff --git a/.idea/.idea.catalogger/.idea/sqldialects.xml b/.idea/.idea.catalogger/.idea/sqldialects.xml index 4ea96ec..10eef95 100644 --- a/.idea/.idea.catalogger/.idea/sqldialects.xml +++ b/.idea/.idea.catalogger/.idea/sqldialects.xml @@ -2,7 +2,6 @@ - \ No newline at end of file diff --git a/Catalogger.Backend/Api/ApiCache.cs b/Catalogger.Backend/Api/ApiCache.cs index c4161ad..b790643 100644 --- a/Catalogger.Backend/Api/ApiCache.cs +++ b/Catalogger.Backend/Api/ApiCache.cs @@ -14,11 +14,43 @@ // along with this program. If not, see . using Catalogger.Backend.Database.Redis; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; namespace Catalogger.Backend.Api; -public class ApiCache(RedisService redisService) +public class ApiCache(RedisService redisService, IDiscordRestChannelAPI channelApi, Config config) { + private List? _news; + private readonly SemaphoreSlim _newsSemaphore = new(1); + + public async Task> GetNewsAsync() + { + await _newsSemaphore.WaitAsync(); + try + { + if (_news != null) + return _news; + + if (config.Web.NewsChannel == null) + return []; + + var res = await channelApi.GetChannelMessagesAsync( + DiscordSnowflake.New(config.Web.NewsChannel.Value), + limit: 5 + ); + if (res.IsSuccess) + return _news = res.Entity.ToList(); + + return []; + } + finally + { + _newsSemaphore.Release(); + } + } + private static string UserKey(string id) => $"api-user:{id}"; private static string GuildsKey(string userId) => $"api-user-guilds:{userId}"; diff --git a/Catalogger.Backend/Api/DiscordRequestService.cs b/Catalogger.Backend/Api/DiscordRequestService.cs index c63e04e..abde930 100644 --- a/Catalogger.Backend/Api/DiscordRequestService.cs +++ b/Catalogger.Backend/Api/DiscordRequestService.cs @@ -30,10 +30,8 @@ public class DiscordRequestService private readonly IClock _clock; private readonly ApiTokenRepository _tokenRepository; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - }; + private static readonly JsonSerializerOptions JsonOptions = + new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; public DiscordRequestService( ILogger logger, @@ -84,9 +82,8 @@ public class DiscordRequestService } private static readonly Uri DiscordUserUri = new("https://discord.com/api/v10/users/@me"); - private static readonly Uri DiscordGuildsUri = new( - "https://discord.com/api/v10/users/@me/guilds" - ); + private static readonly Uri DiscordGuildsUri = + new("https://discord.com/api/v10/users/@me/guilds"); private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token"); public async Task GetMeAsync(string token) => await GetAsync(DiscordUserUri, token); diff --git a/Catalogger.Backend/Api/GuildsController.Backup.cs b/Catalogger.Backend/Api/GuildsController.Backup.cs deleted file mode 100644 index f808b0c..0000000 --- a/Catalogger.Backend/Api/GuildsController.Backup.cs +++ /dev/null @@ -1,102 +0,0 @@ -// 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.Net; -using Catalogger.Backend.Api.Middleware; -using Catalogger.Backend.Database.Models; -using Microsoft.AspNetCore.Mvc; -using NodaTime; -using Remora.Discord.API; - -namespace Catalogger.Backend.Api; - -public partial class GuildsController -{ - [Authorize] - [HttpGet("config")] - public async Task ExportConfigAsync(string id) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - return Ok(await ToExport(guildConfig)); - } - - [Authorize] - [HttpPost("config")] - public async Task ImportConfigAsync(string id, [FromBody] ConfigExport export) - { - var (guildId, _) = await ParseGuildAsync(id); - if (export.Id != guildId.Value) - throw new ApiError( - HttpStatusCode.BadRequest, - ErrorCode.BadRequest, - "This backup is not from this server." - ); - - // Filter invites to *only* those that exist for this guild. - // Blame past me for not making (code, guild_id) a unique index >:| - var cachedInvites = (await inviteCache.TryGetAsync(guildId)).ToList(); - var invites = export.Invites.Where(i => cachedInvites.Any(ci => i.Code == ci.Code)); - - await guildRepository.ImportConfigAsync( - guildId.Value, - export.Channels.ToChannelConfig(), - export.Channels.ToMessageConfig(), - export.BannedSystems, - export.KeyRoles - ); - - await inviteRepository.ImportInvitesAsync( - guildId, - invites.Select(i => new Invite - { - Code = i.Code, - Name = i.Name, - GuildId = guildId.Value, - }) - ); - - await watchlistRepository.ImportWatchlistAsync( - guildId, - export.Watchlist.Select(w => new Watchlist - { - GuildId = guildId.Value, - UserId = w.UserId, - ModeratorId = w.ModeratorId, - AddedAt = w.AddedAt, - Reason = w.Reason, - }) - ); - - return NoContent(); - } - - private async Task ToExport(Database.Models.Guild config) - { - var id = DiscordSnowflake.New(config.Id); - var invites = await inviteRepository.GetGuildInvitesAsync(id); - var watchlist = await watchlistRepository.GetGuildWatchlistAsync(id); - - return new ConfigExport( - config.Id, - ChannelsBackup.FromGuildConfig(config), - config.BannedSystems, - config.KeyRoles, - invites.Select(i => new InviteExport(i.Code, i.Name)), - watchlist.Select(w => new WatchlistExport(w.UserId, w.AddedAt, w.ModeratorId, w.Reason)) - ); - } -} diff --git a/Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs b/Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs deleted file mode 100644 index 4114d5a..0000000 --- a/Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs +++ /dev/null @@ -1,166 +0,0 @@ -// 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 Microsoft.AspNetCore.Mvc; -using Remora.Discord.API.Abstractions.Objects; - -namespace Catalogger.Backend.Api; - -public partial class GuildsController -{ - [HttpPut("ignored-messages/channels/{channelId}")] - public async Task AddIgnoredMessageChannelAsync(string id, ulong channelId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredChannels.Contains(channelId)) - return NoContent(); - - var channel = channelCache - .GuildChannels(guildId) - .FirstOrDefault(c => - c.ID.Value == channelId - && c.Type - is ChannelType.GuildText - or ChannelType.GuildCategory - or ChannelType.GuildAnnouncement - or ChannelType.GuildForum - or ChannelType.GuildMedia - or ChannelType.GuildVoice - ); - if (channel == null) - return NoContent(); - - guildConfig.Messages.IgnoredChannels.Add(channelId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpDelete("ignored-messages/channels/{channelId}")] - public async Task RemoveIgnoredMessageChannelAsync(string id, ulong channelId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - guildConfig.Messages.IgnoredChannels.Remove(channelId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpPut("ignored-messages/roles/{roleId}")] - public async Task AddIgnoredMessageRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredRoles.Contains(roleId)) - return NoContent(); - - if (roleCache.GuildRoles(guildId).All(r => r.ID.Value != roleId)) - return NoContent(); - - guildConfig.Messages.IgnoredRoles.Add(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpDelete("ignored-messages/roles/{roleId}")] - public async Task RemoveIgnoredMessageRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - guildConfig.Messages.IgnoredRoles.Remove(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpPut("ignored-entities/channels/{channelId}")] - public async Task AddIgnoredEntityChannelAsync(string id, ulong channelId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.IgnoredChannels.Contains(channelId)) - return NoContent(); - - var channel = channelCache - .GuildChannels(guildId) - .FirstOrDefault(c => - c.ID.Value == channelId - && c.Type - is ChannelType.GuildText - or ChannelType.GuildCategory - or ChannelType.GuildAnnouncement - or ChannelType.GuildForum - or ChannelType.GuildMedia - or ChannelType.GuildVoice - ); - if (channel == null) - return NoContent(); - - guildConfig.IgnoredChannels.Add(channelId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpDelete("ignored-entities/channels/{channelId}")] - public async Task RemoveIgnoredEntityChannelAsync(string id, ulong channelId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - guildConfig.IgnoredChannels.Remove(channelId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpPut("ignored-entities/roles/{roleId}")] - public async Task AddIgnoredEntityRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredRoles.Contains(roleId)) - return NoContent(); - - if (roleCache.GuildRoles(guildId).All(r => r.ID.Value != roleId)) - return NoContent(); - - guildConfig.IgnoredRoles.Add(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } - - [HttpDelete("ignored-entities/roles/{roleId}")] - public async Task RemoveIgnoredEntityRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - guildConfig.IgnoredRoles.Remove(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return NoContent(); - } -} diff --git a/Catalogger.Backend/Api/GuildsController.Users.cs b/Catalogger.Backend/Api/GuildsController.Ignores.cs similarity index 50% rename from Catalogger.Backend/Api/GuildsController.Users.cs rename to Catalogger.Backend/Api/GuildsController.Ignores.cs index e67cb55..ea9e647 100644 --- a/Catalogger.Backend/Api/GuildsController.Users.cs +++ b/Catalogger.Backend/Api/GuildsController.Ignores.cs @@ -13,9 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using System.Net; -using Catalogger.Backend.Api.Middleware; -using Catalogger.Backend.Extensions; using Microsoft.AspNetCore.Mvc; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Objects; @@ -24,71 +21,44 @@ namespace Catalogger.Backend.Api; public partial class GuildsController { - [HttpGet("ignored-users")] - public async Task GetIgnoredUsersAsync(string id, CancellationToken ct = default) + [HttpPut("ignored-channels/{channelId}")] + public async Task AddIgnoredChannelAsync(string id, ulong channelId) { - var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - // not actually sure how long fetching members might take. timing it out after 10 seconds just in case - // the underlying redis library doesn't support CancellationTokens so we don't pass it down - // we just end the loop early if it expires - cts.CancelAfter(TimeSpan.FromSeconds(10)); - var (guildId, _) = await ParseGuildAsync(id); var guildConfig = await guildRepository.GetAsync(guildId); - var output = new List(); - foreach (var userId in guildConfig.Messages.IgnoredUsers) - { - if (cts.Token.IsCancellationRequested) - break; + if (guildConfig.Channels.IgnoredChannels.Contains(channelId)) + return NoContent(); - var member = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId)); - output.Add( - new IgnoredUser( - Id: userId, - Tag: member != null ? member.User.Value.Tag() : "unknown user" - ) + var channel = channelCache + .GuildChannels(guildId) + .FirstOrDefault(c => + c.ID.Value == channelId + && c.Type + is ChannelType.GuildText + or ChannelType.GuildCategory + or ChannelType.GuildAnnouncement + or ChannelType.GuildForum + or ChannelType.GuildMedia + or ChannelType.GuildVoice ); - } + if (channel == null) + return NoContent(); - return Ok(output.OrderBy(i => i.Id)); + guildConfig.Channels.IgnoredChannels.Add(channelId); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + + return NoContent(); } - private record IgnoredUser(ulong Id, string Tag); - - [HttpPut("ignored-users/{userId}")] - public async Task AddIgnoredUserAsync(string id, ulong userId) + [HttpDelete("ignored-channels/{channelId}")] + public async Task RemoveIgnoredChannelAsync(string id, ulong channelId) { var (guildId, _) = await ParseGuildAsync(id); var guildConfig = await guildRepository.GetAsync(guildId); - IUser? user; - var member = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId)); - if (member != null) - user = member.User.Value; - else - user = await userCache.GetUserAsync(DiscordSnowflake.New(userId)); - - if (user == null) - throw new ApiError(HttpStatusCode.NotFound, ErrorCode.BadRequest, "User not found"); - - if (guildConfig.Messages.IgnoredUsers.Contains(user.ID.Value)) - return Ok(new IgnoredUser(user.ID.Value, user.Tag())); - - guildConfig.Messages.IgnoredUsers.Add(user.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return Ok(new IgnoredUser(user.ID.Value, user.Tag())); - } - - [HttpDelete("ignored-users/{userId}")] - public async Task RemoveIgnoredUserAsync(string id, ulong userId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - guildConfig.Messages.IgnoredUsers.Remove(userId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + guildConfig.Channels.IgnoredChannels.Remove(channelId); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); return NoContent(); } @@ -103,4 +73,35 @@ public partial class GuildsController } private record UserQueryResponse(string Name, string Id); + + [HttpPut("ignored-users/{userId}")] + public async Task AddIgnoredUserAsync(string id, ulong userId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await guildRepository.GetAsync(guildId); + + if (guildConfig.Channels.IgnoredUsers.Contains(userId)) + return NoContent(); + + var user = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId)); + if (user == null) + return NoContent(); + + guildConfig.Channels.IgnoredUsers.Add(userId); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + + return NoContent(); + } + + [HttpDelete("ignored-users/{userId}")] + public async Task RemoveIgnoredUserAsync(string id, ulong userId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await guildRepository.GetAsync(guildId); + + guildConfig.Channels.IgnoredUsers.Remove(userId); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + + return NoContent(); + } } diff --git a/Catalogger.Backend/Api/GuildsController.KeyRoles.cs b/Catalogger.Backend/Api/GuildsController.KeyRoles.cs deleted file mode 100644 index f1e786e..0000000 --- a/Catalogger.Backend/Api/GuildsController.KeyRoles.cs +++ /dev/null @@ -1,63 +0,0 @@ -// 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.Net; -using Catalogger.Backend.Api.Middleware; -using Microsoft.AspNetCore.Mvc; -using Remora.Discord.API; - -namespace Catalogger.Backend.Api; - -public partial class GuildsController -{ - [HttpPut("key-roles/{roleId}")] - public async Task AddKeyRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (roleCache.GuildRoles(guildId).All(r => r.ID.Value != roleId)) - throw new ApiError(HttpStatusCode.BadRequest, ErrorCode.BadRequest, "Role not found"); - - if (guildConfig.KeyRoles.Contains(roleId)) - throw new ApiError( - HttpStatusCode.BadRequest, - ErrorCode.BadRequest, - "Role is already a key role" - ); - - guildConfig.KeyRoles.Add(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - return NoContent(); - } - - [HttpDelete("key-roles/{roleId}")] - public async Task RemoveKeyRoleAsync(string id, ulong roleId) - { - var (guildId, _) = await ParseGuildAsync(id); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.KeyRoles.Contains(roleId)) - throw new ApiError( - HttpStatusCode.BadRequest, - ErrorCode.BadRequest, - "Role is already not a key role" - ); - - guildConfig.KeyRoles.Remove(roleId); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - return NoContent(); - } -} diff --git a/Catalogger.Backend/Api/GuildsController.Redirects.cs b/Catalogger.Backend/Api/GuildsController.Redirects.cs index 4131343..9ce84de 100644 --- a/Catalogger.Backend/Api/GuildsController.Redirects.cs +++ b/Catalogger.Backend/Api/GuildsController.Redirects.cs @@ -61,7 +61,7 @@ public partial class GuildsController ); guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value; - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); return NoContent(); } @@ -80,7 +80,7 @@ public partial class GuildsController ); guildConfig.Channels.Redirects.Remove(channelId, out _); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); return NoContent(); } diff --git a/Catalogger.Backend/Api/GuildsController.Remove.cs b/Catalogger.Backend/Api/GuildsController.Remove.cs index 1002efa..2d2ff5e 100644 --- a/Catalogger.Backend/Api/GuildsController.Remove.cs +++ b/Catalogger.Backend/Api/GuildsController.Remove.cs @@ -14,15 +14,12 @@ // along with this program. If not, see . using System.Net; -using System.Text; -using System.Text.Json; using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Bot; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Dapper; using Microsoft.AspNetCore.Mvc; -using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.Extensions.Embeds; namespace Catalogger.Backend.Api; @@ -43,8 +40,6 @@ public partial class GuildsController } var guildConfig = await guildRepository.GetAsync(guildId); - var export = await ToExport(guildConfig); - var logChannelId = webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildUpdate) ?? webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildMemberRemove); @@ -55,25 +50,15 @@ public partial class GuildsController var embed = new EmbedBuilder() .WithTitle("Catalogger is leaving this server") .WithDescription( - $""" - A moderator in this server ({currentUser.Tag}, <@{currentUser.Id}>) requested that Catalogger leave. - All data related to this server will be deleted. - - A backup of this server's configuration is attached to this message, - in case you want to use the bot again later. - """ + $"A moderator in this server ({currentUser.Tag}, <@{currentUser.Id}>) requested that Catalogger leave. " + + "All data related to this server will be deleted." ) .WithColour(DiscordUtils.Red) .WithCurrentTimestamp() .Build() .GetOrThrow(); - var exportData = JsonSerializer.Serialize(export, JsonUtils.ApiJsonOptions); - var file = new FileData( - "config-backup.json", - new MemoryStream(Encoding.UTF8.GetBytes(exportData)) - ); - await webhookExecutor.SendLogAsync(logChannelId.Value, [embed], [file]); + await webhookExecutor.SendLogAsync(logChannelId.Value, [embed], []); } else { @@ -130,9 +115,17 @@ public partial class GuildsController guildId ); + // Clear out the caches for this guild + guildCache.Remove(guildId, out _); + emojiCache.Remove(guildId); + channelCache.RemoveGuild(guildId); + roleCache.RemoveGuild(guildId); + await memberCache.RemoveAllMembersAsync(guildId); + await inviteCache.RemoveAsync(guildId); + _logger.Information("Left guild {GuildId} and removed all data for it", guildId); - return Ok(export); + return NoContent(); } public record LeaveGuildRequest(string Name); diff --git a/Catalogger.Backend/Api/GuildsController.cs b/Catalogger.Backend/Api/GuildsController.cs index 73d81ef..560456f 100644 --- a/Catalogger.Backend/Api/GuildsController.cs +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -19,7 +19,6 @@ using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Microsoft.AspNetCore.Mvc; using Remora.Discord.API; @@ -34,13 +33,12 @@ public partial class GuildsController( ILogger logger, DatabaseConnection dbConn, GuildRepository guildRepository, - InviteRepository inviteRepository, - WatchlistRepository watchlistRepository, + GuildCache guildCache, + EmojiCache emojiCache, ChannelCache channelCache, RoleCache roleCache, IMemberCache memberCache, IInviteCache inviteCache, - UserCache userCache, DiscordRequestService discordRequestService, IDiscordRestUserAPI userApi, WebhookExecutorService webhookExecutor @@ -93,16 +91,6 @@ public partial class GuildsController( .Select(ToChannel) )); - var roles = roleCache - .GuildRoles(guildId) - .OrderByDescending(r => r.Position) - .Select(r => new GuildRole( - r.ID.ToString(), - r.Name, - r.Position, - r.Colour.ToPrettyString() - )); - return Ok( new GuildResponse( guild.Id, @@ -110,12 +98,7 @@ public partial class GuildsController( guild.IconUrl, categories, channelsWithoutCategories, - roles, - guildConfig.IgnoredChannels, - guildConfig.IgnoredRoles, - guildConfig.Messages, - guildConfig.Channels, - guildConfig.KeyRoles + guildConfig.Channels ) ); } @@ -139,20 +122,13 @@ public partial class GuildsController( string IconUrl, IEnumerable Categories, IEnumerable ChannelsWithoutCategory, - IEnumerable Roles, - List IgnoredChannels, - List IgnoredRoles, - Database.Models.Guild.MessageConfig Messages, - Database.Models.Guild.ChannelConfig Channels, - List KeyRoles + Database.Models.Guild.ChannelConfig Config ); private record GuildCategory(string Id, string Name, IEnumerable Channels); private record GuildChannel(string Id, string Name, bool CanLogTo, bool CanRedirectFrom); - private record GuildRole(string Id, string Name, int Position, string Colour); - [Authorize] [HttpPatch] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] @@ -165,6 +141,28 @@ public partial class GuildsController( .ToList(); var guildConfig = await guildRepository.GetAsync(guildId); + if (req.IgnoredChannels != null) + { + var categories = channelCache + .GuildChannels(guildId) + .Where(c => c.Type is ChannelType.GuildCategory) + .ToList(); + + if ( + req.IgnoredChannels.Any(cId => + guildChannels.All(c => c.ID.Value != cId) + && categories.All(c => c.ID.Value != cId) + ) + ) + throw new ApiError( + HttpStatusCode.BadRequest, + ErrorCode.BadRequest, + "One or more ignored channels are unknown" + ); + + guildConfig.Channels.IgnoredChannels = req.IgnoredChannels.ToList(); + } + // i love repeating myself wheeeeee if ( req.GuildUpdate == null @@ -318,11 +316,12 @@ public partial class GuildsController( ) guildConfig.Channels.MessageDeleteBulk = req.MessageDeleteBulk ?? 0; - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); return Ok(guildConfig.Channels); } public record ChannelRequest( + ulong[]? IgnoredChannels = null, ulong? GuildUpdate = null, ulong? GuildEmojisUpdate = null, ulong? GuildRoleCreate = null, diff --git a/Catalogger.Backend/Api/MetaController.cs b/Catalogger.Backend/Api/MetaController.cs index 9d7a7b1..17d3c27 100644 --- a/Catalogger.Backend/Api/MetaController.cs +++ b/Catalogger.Backend/Api/MetaController.cs @@ -65,10 +65,6 @@ public class MetaController( ); } - [HttpGet("coffee")] - public IActionResult BrewCoffee() => - Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); - private record MetaResponse( int Guilds, string InviteUrl, diff --git a/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs b/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs index 5d2c888..47447dc 100644 --- a/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs +++ b/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs @@ -16,10 +16,12 @@ using System.Net; using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Repositories; +using NodaTime; namespace Catalogger.Backend.Api.Middleware; -public class AuthenticationMiddleware(ApiTokenRepository tokenRepository) : IMiddleware +public class AuthenticationMiddleware(ApiTokenRepository tokenRepository, IClock clock) + : IMiddleware { public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs index 7f2ec0d..fcf54b9 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs @@ -43,7 +43,6 @@ public class ChannelCommands( Config config, GuildRepository guildRepository, GuildCache guildCache, - GuildFetchService guildFetchService, ChannelCache channelCache, IMemberCache memberCache, IFeedbackService feedbackService, @@ -69,11 +68,8 @@ public class ChannelCommands( public async Task CheckPermissionsAsync() { var (userId, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - { - return CataloggerError.Result($"Guild {guildId} not in cache"); - } + throw new CataloggerError("Guild not in cache"); var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}"); @@ -82,18 +78,8 @@ public class ChannelCommands( DiscordSnowflake.New(config.Discord.ApplicationId) ); var currentUser = await memberCache.TryGetAsync(guildId, userId); - if (botUser == null || currentUser == null) - { - // If this happens, something has gone wrong when fetching members. Refetch the guild's members. - guildFetchService.EnqueueGuild(guildId); - _logger.Error( - "Either our own user {BotId} or the invoking user {UserId} is not in cache, aborting permission check", - config.Discord.ApplicationId, - userId - ); - return CataloggerError.Result("Bot member or invoking member not found in cache"); - } + throw new CataloggerError("Bot member or invoking member not found in cache"); // We don't want to check categories or threads var guildChannels = channelCache @@ -218,7 +204,7 @@ public class ChannelCommands( { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -236,93 +222,6 @@ public class ChannelCommands( return Result.Success; } - public static IStringSelectComponent LogTypeSelect => - new StringSelectComponent( - CustomID: CustomIDHelpers.CreateSelectMenuID("select-log-type"), - MinValues: 1, - MaxValues: 1, - Options: - [ - new SelectOption( - Label: "Server changes", - Value: nameof(LogChannelType.GuildUpdate) - ), - new SelectOption( - Label: "Emoji changes", - Value: nameof(LogChannelType.GuildEmojisUpdate) - ), - new SelectOption(Label: "New roles", Value: nameof(LogChannelType.GuildRoleCreate)), - new SelectOption( - Label: "Edited roles", - Value: nameof(LogChannelType.GuildRoleUpdate) - ), - new SelectOption( - Label: "Deleted roles", - Value: nameof(LogChannelType.GuildRoleDelete) - ), - new SelectOption( - Label: "New channels", - Value: nameof(LogChannelType.ChannelCreate) - ), - new SelectOption( - Label: "Edited channels", - Value: nameof(LogChannelType.ChannelUpdate) - ), - new SelectOption( - Label: "Deleted channels", - Value: nameof(LogChannelType.ChannelDelete) - ), - new SelectOption( - Label: "Members joining", - Value: nameof(LogChannelType.GuildMemberAdd) - ), - new SelectOption( - Label: "Members leaving", - Value: nameof(LogChannelType.GuildMemberRemove) - ), - new SelectOption( - Label: "Member role changes", - Value: nameof(LogChannelType.GuildMemberUpdate) - ), - new SelectOption( - Label: "Key role changes", - Value: nameof(LogChannelType.GuildKeyRoleUpdate) - ), - new SelectOption( - Label: "Member name changes", - Value: nameof(LogChannelType.GuildMemberNickUpdate) - ), - new SelectOption( - Label: "Member avatar changes", - Value: nameof(LogChannelType.GuildMemberAvatarUpdate) - ), - new SelectOption( - Label: "Timeouts", - Value: nameof(LogChannelType.GuildMemberTimeout) - ), - new SelectOption(Label: "Kicks", Value: nameof(LogChannelType.GuildMemberKick)), - new SelectOption(Label: "Bans", Value: nameof(LogChannelType.GuildBanAdd)), - new SelectOption(Label: "Unbans", Value: nameof(LogChannelType.GuildBanRemove)), - new SelectOption(Label: "New invites", Value: nameof(LogChannelType.InviteCreate)), - new SelectOption( - Label: "Deleted invites", - Value: nameof(LogChannelType.InviteDelete) - ), - new SelectOption( - Label: "Edited messages", - Value: nameof(LogChannelType.MessageUpdate) - ), - new SelectOption( - Label: "Deleted messages", - Value: nameof(LogChannelType.MessageDelete) - ), - new SelectOption( - Label: "Bulk deleted messages", - Value: nameof(LogChannelType.MessageDeleteBulk) - ), - ] - ); - public static (List, List) BuildRootMenu( List guildChannels, IGuild guild, @@ -458,9 +357,208 @@ public class ChannelCommands( List components = [ - new ActionRowComponent([LogTypeSelect]), new ActionRowComponent( [ + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Server changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Emoji changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildEmojisUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "New roles", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildRoleCreate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Edited roles", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildRoleUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Deleted roles", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildRoleDelete) + ) + ), + ] + ), + new ActionRowComponent( + [ + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "New channels", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.ChannelCreate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Edited channels", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.ChannelUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Deleted channels", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.ChannelDelete) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Members joining", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberAdd) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Members leaving", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberRemove) + ) + ), + ] + ), + new ActionRowComponent( + [ + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Member role changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Key role changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildKeyRoleUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Member name changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberNickUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Member avatar changes", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberAvatarUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Timeouts", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberTimeout) + ) + ), + ] + ), + new ActionRowComponent( + [ + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Kicks", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildMemberKick) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Bans", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildBanAdd) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Unbans", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.GuildBanRemove) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "New invites", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.InviteCreate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Deleted invites", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.InviteDelete) + ) + ), + ] + ), + new ActionRowComponent( + [ + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Edited messages", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.MessageUpdate) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Deleted messages", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.MessageDelete) + ) + ), + new ButtonComponent( + ButtonComponentStyle.Primary, + Label: "Bulk deleted messages", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + nameof(LogChannelType.MessageDeleteBulk) + ) + ), new ButtonComponent( ButtonComponentStyle.Secondary, Label: "Close", diff --git a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs index 1104c82..1bda457 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs @@ -45,117 +45,20 @@ public class ChannelCommandsComponents( { private readonly ILogger _logger = logger.ForContext(); - [SelectMenu("select-log-type")] - [SuppressInteractionResponse(true)] - public async Task OnMenuSelectionAsync(IReadOnlyList values) - { - if (contextInjection.Context is not IInteractionCommandContext ctx) - return CataloggerError.Result("No context"); - if (!ctx.TryGetUserID(out var userId)) - return CataloggerError.Result("No user ID in context"); - if (!ctx.Interaction.Message.TryGet(out var msg)) - return CataloggerError.Result("No message ID in context"); - if (!ctx.TryGetGuildID(out var guildId)) - return CataloggerError.Result("No guild ID in context"); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - var guildChannels = channelCache.GuildChannels(guildId).ToList(); - var guildConfig = await guildRepository.GetAsync(guildId); - - var result = await dataService.LeaseDataAsync(msg.ID); - await using var lease = result.GetOrThrow(); - if (lease.Data.UserId != userId) - { - return (Result) - await feedbackService.ReplyAsync( - "This is not your configuration menu.", - isEphemeral: true - ); - } - - var state = values[0]; - - if (!Enum.TryParse(state, out var logChannelType)) - return CataloggerError.Result($"Invalid config-channels state {state}"); - - var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType); - string? channelMention; - if (channelId is 0) - channelMention = null; - else if (guildChannels.All(c => c.ID != channelId)) - channelMention = $"unknown channel {channelId}"; - else - channelMention = $"<#{channelId}>"; - - List embeds = - [ - new Embed( - Title: ChannelCommands.PrettyLogTypeName(logChannelType), - Description: channelMention == null - ? "This event is not currently logged.\nTo start logging it somewhere, select a channel below." - : $"This event is currently set to log to {channelMention}." - + "\nTo change where it is logged, select a channel below." - + "\nTo disable logging this event entirely, select \"Stop logging\" below.", - Colour: DiscordUtils.Purple - ), - ]; - - List components = - [ - new ActionRowComponent( - new[] - { - new ChannelSelectComponent( - CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"), - ChannelTypes: new[] { ChannelType.GuildText } - ), - } - ), - new ActionRowComponent( - new[] - { - new ButtonComponent( - ButtonComponentStyle.Danger, - Label: "Stop logging", - CustomID: CustomIDHelpers.CreateButtonIDWithState( - "config-channels", - "reset" - ), - IsDisabled: channelMention == null - ), - new ButtonComponent( - ButtonComponentStyle.Secondary, - Label: "Return to menu", - CustomID: CustomIDHelpers.CreateButtonIDWithState( - "config-channels", - "return" - ) - ), - } - ), - ]; - - lease.Data = new ChannelCommandData(userId, CurrentPage: state); - return await interactionApi.UpdateMessageAsync( - ctx.Interaction, - new InteractionMessageCallbackData(Embeds: embeds, Components: components) - ); - } - [Button("config-channels")] [SuppressInteractionResponse(true)] public async Task OnButtonPressedAsync(string state) { if (contextInjection.Context is not IInteractionCommandContext ctx) - return CataloggerError.Result("No context"); + throw new CataloggerError("No context"); if (!ctx.TryGetUserID(out var userId)) - return CataloggerError.Result("No user ID in context"); + throw new CataloggerError("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - return CataloggerError.Result("No message ID in context"); + throw new CataloggerError("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - return CataloggerError.Result("No guild ID in context"); + throw new CataloggerError("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -179,9 +82,9 @@ public class ChannelCommandsComponents( ); case "reset": if (lease.Data.CurrentPage == null) - return CataloggerError.Result("CurrentPage was null in reset button callback"); + throw new CataloggerError("CurrentPage was null in reset button callback"); if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - return CataloggerError.Result( + throw new CataloggerError( $"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'" ); @@ -261,7 +164,7 @@ public class ChannelCommandsComponents( throw new ArgumentOutOfRangeException(); } - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); goto case "return"; case "return": var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig); @@ -273,7 +176,71 @@ public class ChannelCommandsComponents( return Result.Success; } - return Result.Success; + if (!Enum.TryParse(state, out var logChannelType)) + throw new CataloggerError($"Invalid config-channels state {state}"); + + var channelId = WebhookExecutorService.GetDefaultLogChannel(guildConfig, logChannelType); + string? channelMention; + if (channelId is 0) + channelMention = null; + else if (guildChannels.All(c => c.ID != channelId)) + channelMention = $"unknown channel {channelId}"; + else + channelMention = $"<#{channelId}>"; + + List embeds = + [ + new Embed( + Title: ChannelCommands.PrettyLogTypeName(logChannelType), + Description: channelMention == null + ? "This event is not currently logged.\nTo start logging it somewhere, select a channel below." + : $"This event is currently set to log to {channelMention}." + + "\nTo change where it is logged, select a channel below." + + "\nTo disable logging this event entirely, select \"Stop logging\" below.", + Colour: DiscordUtils.Purple + ), + ]; + + List components = + [ + new ActionRowComponent( + new[] + { + new ChannelSelectComponent( + CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"), + ChannelTypes: new[] { ChannelType.GuildText } + ), + } + ), + new ActionRowComponent( + new[] + { + new ButtonComponent( + ButtonComponentStyle.Danger, + Label: "Stop logging", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + "reset" + ), + IsDisabled: channelMention == null + ), + new ButtonComponent( + ButtonComponentStyle.Secondary, + Label: "Return to menu", + CustomID: CustomIDHelpers.CreateButtonIDWithState( + "config-channels", + "return" + ) + ), + } + ), + ]; + + lease.Data = new ChannelCommandData(userId, CurrentPage: state); + return await interactionApi.UpdateMessageAsync( + ctx.Interaction, + new InteractionMessageCallbackData(Embeds: embeds, Components: components) + ); } [SelectMenu("config-channels")] @@ -281,15 +248,15 @@ public class ChannelCommandsComponents( public async Task OnMenuSelectionAsync(IReadOnlyList channels) { if (contextInjection.Context is not IInteractionCommandContext ctx) - return CataloggerError.Result("No context"); + throw new CataloggerError("No context"); if (!ctx.TryGetUserID(out var userId)) - return CataloggerError.Result("No user ID in context"); + throw new CataloggerError("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - return CataloggerError.Result("No message ID in context"); + throw new CataloggerError("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - return CataloggerError.Result("No guild ID in context"); + throw new CataloggerError("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); var channelId = channels[0].ID.ToUlong(); @@ -305,7 +272,7 @@ public class ChannelCommandsComponents( } if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - return CataloggerError.Result( + throw new CataloggerError( $"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'" ); @@ -384,7 +351,7 @@ public class ChannelCommandsComponents( throw new ArgumentOutOfRangeException(); } - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); List embeds = [ diff --git a/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs b/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs new file mode 100644 index 0000000..63dce5e --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs @@ -0,0 +1,210 @@ +// 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.ComponentModel; +using Catalogger.Backend.Cache; +using Catalogger.Backend.Cache.InMemoryCache; +using Catalogger.Backend.Database.Repositories; +using Catalogger.Backend.Extensions; +using Catalogger.Backend.Services; +using Remora.Commands.Attributes; +using Remora.Commands.Groups; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.Commands.Attributes; +using Remora.Discord.Commands.Feedback.Services; +using Remora.Discord.Commands.Services; +using Remora.Discord.Extensions.Embeds; +using Remora.Rest.Core; +using IResult = Remora.Results.IResult; + +namespace Catalogger.Backend.Bot.Commands; + +[Group("ignored-channels")] +[Description("Manage channels ignored for logging.")] +[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] +public class IgnoreChannelCommands( + ILogger logger, + GuildRepository guildRepository, + IMemberCache memberCache, + GuildCache guildCache, + ChannelCache channelCache, + PermissionResolverService permissionResolver, + ContextInjectionService contextInjection, + FeedbackService feedbackService +) : CommandGroup +{ + private readonly ILogger _logger = logger.ForContext(); + + [Command("add")] + [Description("Add a channel to the list of ignored channels.")] + public async Task AddIgnoredChannelAsync( + [ChannelTypes( + ChannelType.GuildCategory, + ChannelType.GuildText, + ChannelType.GuildAnnouncement, + ChannelType.GuildForum, + ChannelType.GuildMedia, + ChannelType.GuildVoice, + ChannelType.GuildStageVoice + )] + [Description("The channel to ignore")] + IChannel channel + ) + { + var (_, guildId) = contextInjection.GetUserAndGuild(); + var guildConfig = await guildRepository.GetAsync(guildId); + + if (guildConfig.Channels.IgnoredChannels.Contains(channel.ID.Value)) + return await feedbackService.ReplyAsync( + "That channel is already being ignored.", + isEphemeral: true + ); + + guildConfig.Channels.IgnoredChannels.Add(channel.ID.Value); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + + return await feedbackService.ReplyAsync( + $"Successfully added {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} to the list of ignored channels." + ); + } + + [Command("remove")] + [Description("Remove a channel from the list of ignored channels.")] + public async Task RemoveIgnoredChannelAsync( + [Description("The channel to stop ignoring")] IChannel channel + ) + { + var (_, guildId) = contextInjection.GetUserAndGuild(); + var guildConfig = await guildRepository.GetAsync(guildId); + + if (!guildConfig.Channels.IgnoredChannels.Contains(channel.ID.Value)) + return await feedbackService.ReplyAsync( + "That channel is already not ignored.", + isEphemeral: true + ); + + guildConfig.Channels.IgnoredChannels.Remove(channel.ID.Value); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + + return await feedbackService.ReplyAsync( + $"Successfully removed {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} from the list of ignored channels." + ); + } + + [Command("list")] + [Description("List channels ignored for logging.")] + public async Task ListIgnoredChannelsAsync() + { + var (userId, guildId) = contextInjection.GetUserAndGuild(); + if (!guildCache.TryGet(guildId, out var guild)) + throw new CataloggerError("Guild not in cache"); + + var guildChannels = channelCache.GuildChannels(guildId).ToList(); + var guildConfig = await guildRepository.GetAsync(guildId); + + var member = await memberCache.TryGetAsync(guildId, userId); + if (member == null) + throw new CataloggerError("Executing member not found"); + + var ignoredChannels = guildConfig + .Channels.IgnoredChannels.Select(id => + { + var channel = guildChannels.FirstOrDefault(c => c.ID.Value == id); + if (channel == null) + return new IgnoredChannel(IgnoredChannelType.Unknown, DiscordSnowflake.New(id)); + + var type = channel.Type switch + { + ChannelType.GuildCategory => IgnoredChannelType.Category, + _ => IgnoredChannelType.Base, + }; + + return new IgnoredChannel( + type, + channel.ID, + permissionResolver + .GetChannelPermissions(guildId, member, channel) + .HasPermission(DiscordPermission.ViewChannel) + ); + }) + .ToList(); + + foreach (var ch in ignoredChannels) + { + _logger.Debug("Channel: {ChannelId}, type: {Type}", ch.Id, ch.Type); + } + + var embed = new EmbedBuilder() + .WithTitle($"Ignored channels in {guild.Name}") + .WithColour(DiscordUtils.Purple); + + var nonVisibleCategories = ignoredChannels.Count(c => + c is { Type: IgnoredChannelType.Category, CanSee: false } + ); + var visibleCategories = ignoredChannels + .Where(c => c is { Type: IgnoredChannelType.Category, CanSee: true }) + .ToList(); + + if (nonVisibleCategories != 0 || visibleCategories.Count != 0) + { + var value = string.Join("\n", visibleCategories.Select(c => $"<#{c.Id}>")); + if (nonVisibleCategories != 0) + value += + $"\n\n{nonVisibleCategories} channel(s) are not shown as you do not have access to them."; + + embed.AddField("Categories", value); + } + + var nonVisibleBase = ignoredChannels.Count(c => + c is { Type: IgnoredChannelType.Base, CanSee: false } + ); + var visibleBase = ignoredChannels + .Where(c => c is { Type: IgnoredChannelType.Base, CanSee: true }) + .ToList(); + + if (nonVisibleBase != 0 || visibleBase.Count != 0) + { + var value = string.Join("\n", visibleBase.Select(c => $"<#{c.Id}>")); + if (nonVisibleBase != 0) + value += + $"\n\n{nonVisibleBase} channel(s) are not shown as you do not have access to them."; + + embed.AddField("Channels", value); + } + + var unknownChannels = string.Join( + "\n", + ignoredChannels + .Where(c => c.Type == IgnoredChannelType.Unknown) + .Select(c => $"{c.Id} <#{c.Id}>") + ); + if (!string.IsNullOrWhiteSpace(unknownChannels)) + { + embed.AddField("Unknown", unknownChannels); + } + + return await feedbackService.ReplyAsync(embeds: [embed.Build().GetOrThrow()]); + } + + private record struct IgnoredChannel(IgnoredChannelType Type, Snowflake Id, bool CanSee = true); + + private enum IgnoredChannelType + { + Unknown, + Base, + Category, + } +} diff --git a/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs b/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs deleted file mode 100644 index 7e8987c..0000000 --- a/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs +++ /dev/null @@ -1,304 +0,0 @@ -// 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.ComponentModel; -using Catalogger.Backend.Cache; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Catalogger.Backend.Services; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.Commands.Attributes; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Services; -using Remora.Discord.Extensions.Embeds; -using Remora.Rest.Core; -using IResult = Remora.Results.IResult; - -namespace Catalogger.Backend.Bot.Commands; - -[Group("ignore")] -[Description("Manage the ignored channels and roles in this server.")] -[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] -public class IgnoreEntitiesCommands : CommandGroup -{ - [Group("role")] - public class Roles( - GuildRepository guildRepository, - GuildCache guildCache, - RoleCache roleCache, - ContextInjectionService contextInjection, - FeedbackService feedbackService - ) : CommandGroup - { - [Command("add")] - [Description("Add a role to the list of ignored roles.")] - public async Task AddIgnoredRoleAsync( - [Description("The role to ignore")] IRole role - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.IgnoredRoles.Contains(role.ID.Value)) - return await feedbackService.ReplyAsync( - "That role is already being ignored.", - isEphemeral: true - ); - - guildConfig.IgnoredRoles.Add(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully added {role.Name} to the list of ignored roles." - ); - } - - [Command("remove")] - [Description("Remove a role from the list of ignored roles.")] - public async Task RemoveIgnoredRoleAsync( - [Description("The role to stop ignoring")] IRole role - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.IgnoredRoles.Contains(role.ID.Value)) - return await feedbackService.ReplyAsync( - "That role is already not ignored.", - isEphemeral: true - ); - - guildConfig.IgnoredRoles.Remove(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully removed {role.Name} from the list of ignored roles." - ); - } - - [Command("list")] - [Description("List roles ignored for logging.")] - public async Task ListIgnoredRolesAsync() - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var guildConfig = await guildRepository.GetAsync(guildId); - - var roles = roleCache - .GuildRoles(guildId) - .Where(r => guildConfig.IgnoredRoles.Contains(r.ID.Value)) - .OrderByDescending(r => r.Position) - .Select(r => $"<@&{r.ID}>") - .ToList(); - if (roles.Count == 0) - return await feedbackService.ReplyAsync( - "No roles are being ignored right now.", - isEphemeral: true - ); - - return await feedbackService.ReplyAsync( - embeds: - [ - new EmbedBuilder() - .WithTitle($"Ignored roles in {guild.Name}") - .WithDescription(string.Join("\n", roles)) - .WithColour(DiscordUtils.Purple) - .Build() - .GetOrThrow(), - ] - ); - } - } - - [Group("channel")] - public class Channels( - GuildRepository guildRepository, - IMemberCache memberCache, - GuildCache guildCache, - ChannelCache channelCache, - PermissionResolverService permissionResolver, - ContextInjectionService contextInjection, - FeedbackService feedbackService - ) : CommandGroup - { - [Command("add")] - [Description("Add a channel to the list of ignored channels.")] - public async Task AddIgnoredChannelAsync( - [ChannelTypes( - ChannelType.GuildCategory, - ChannelType.GuildText, - ChannelType.GuildAnnouncement, - ChannelType.GuildForum, - ChannelType.GuildMedia, - ChannelType.GuildVoice, - ChannelType.GuildStageVoice - )] - [Description("The channel to ignore")] - IChannel channel - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.IgnoredChannels.Contains(channel.ID.Value)) - return await feedbackService.ReplyAsync( - "That channel is already being ignored.", - isEphemeral: true - ); - - guildConfig.IgnoredChannels.Add(channel.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully added {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} to the list of ignored channels." - ); - } - - [Command("remove")] - [Description("Remove a channel from the list of ignored channels.")] - public async Task RemoveIgnoredChannelAsync( - [Description("The channel to stop ignoring")] IChannel channel - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.IgnoredChannels.Contains(channel.ID.Value)) - return await feedbackService.ReplyAsync( - "That channel is already not ignored.", - isEphemeral: true - ); - - guildConfig.IgnoredChannels.Remove(channel.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully removed {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} from the list of ignored channels." - ); - } - - [Command("list")] - [Description("List channels ignored for logging.")] - public async Task ListIgnoredChannelsAsync() - { - var (userId, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var guildChannels = channelCache.GuildChannels(guildId).ToList(); - var guildConfig = await guildRepository.GetAsync(guildId); - - var member = await memberCache.TryGetAsync(guildId, userId); - if (member == null) - return CataloggerError.Result("Executing member not found"); - - var ignoredChannels = guildConfig - .IgnoredChannels.Select(id => - { - var channel = guildChannels.FirstOrDefault(c => c.ID.Value == id); - if (channel == null) - return new IgnoredChannel( - IgnoredChannelType.Unknown, - DiscordSnowflake.New(id) - ); - - var type = channel.Type switch - { - ChannelType.GuildCategory => IgnoredChannelType.Category, - _ => IgnoredChannelType.Base, - }; - - return new IgnoredChannel( - type, - channel.ID, - permissionResolver - .GetChannelPermissions(guildId, member, channel) - .HasPermission(DiscordPermission.ViewChannel) - ); - }) - .ToList(); - - var embed = new EmbedBuilder() - .WithTitle($"Ignored channels in {guild.Name}") - .WithColour(DiscordUtils.Purple); - - var nonVisibleCategories = ignoredChannels.Count(c => - c is { Type: IgnoredChannelType.Category, CanSee: false } - ); - var visibleCategories = ignoredChannels - .Where(c => c is { Type: IgnoredChannelType.Category, CanSee: true }) - .ToList(); - - if (nonVisibleCategories != 0 || visibleCategories.Count != 0) - { - var value = string.Join("\n", visibleCategories.Select(c => $"<#{c.Id}>")); - if (nonVisibleCategories != 0) - value += - $"\n\n{nonVisibleCategories} channel(s) are not shown as you do not have access to them."; - - embed.AddField("Categories", value); - } - - var nonVisibleBase = ignoredChannels.Count(c => - c is { Type: IgnoredChannelType.Base, CanSee: false } - ); - var visibleBase = ignoredChannels - .Where(c => c is { Type: IgnoredChannelType.Base, CanSee: true }) - .ToList(); - - if (nonVisibleBase != 0 || visibleBase.Count != 0) - { - var value = string.Join("\n", visibleBase.Select(c => $"<#{c.Id}>")); - if (nonVisibleBase != 0) - value += - $"\n\n{nonVisibleBase} channel(s) are not shown as you do not have access to them."; - - embed.AddField("Channels", value); - } - - var unknownChannels = string.Join( - "\n", - ignoredChannels - .Where(c => c.Type == IgnoredChannelType.Unknown) - .Select(c => $"{c.Id} <#{c.Id}>") - ); - if (!string.IsNullOrWhiteSpace(unknownChannels)) - { - embed.AddField("Unknown", unknownChannels); - } - - return await feedbackService.ReplyAsync(embeds: [embed.Build().GetOrThrow()]); - } - - private record struct IgnoredChannel( - IgnoredChannelType Type, - Snowflake Id, - bool CanSee = true - ); - - private enum IgnoredChannelType - { - Unknown, - Base, - Category, - } - } -} diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs deleted file mode 100644 index b61fabc..0000000 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs +++ /dev/null @@ -1,213 +0,0 @@ -// 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.ComponentModel; -using Catalogger.Backend.Cache; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Catalogger.Backend.Services; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.Commands.Attributes; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Services; -using Remora.Discord.Extensions.Embeds; -using Remora.Rest.Core; -using IResult = Remora.Results.IResult; - -namespace Catalogger.Backend.Bot.Commands; - -[Group("ignore-messages")] -[Description("Manage users, roles, and channels whose messages are not logged.")] -[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] -public partial class IgnoreMessageCommands : CommandGroup -{ - [Group("channels")] - public class Channels( - GuildRepository guildRepository, - IMemberCache memberCache, - GuildCache guildCache, - ChannelCache channelCache, - PermissionResolverService permissionResolver, - ContextInjectionService contextInjection, - FeedbackService feedbackService - ) : CommandGroup - { - [Command("add")] - [Description("Add a channel to the list of ignored channels.")] - public async Task AddIgnoredChannelAsync( - [ChannelTypes( - ChannelType.GuildCategory, - ChannelType.GuildText, - ChannelType.GuildAnnouncement, - ChannelType.GuildForum, - ChannelType.GuildMedia, - ChannelType.GuildVoice, - ChannelType.GuildStageVoice - )] - [Description("The channel to ignore")] - IChannel channel - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredChannels.Contains(channel.ID.Value)) - return await feedbackService.ReplyAsync( - "That channel is already being ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredChannels.Add(channel.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully added {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} to the list of ignored channels." - ); - } - - [Command("remove")] - [Description("Remove a channel from the list of ignored channels.")] - public async Task RemoveIgnoredChannelAsync( - [Description("The channel to stop ignoring")] IChannel channel - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.Messages.IgnoredChannels.Contains(channel.ID.Value)) - return await feedbackService.ReplyAsync( - "That channel is already not ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredChannels.Remove(channel.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully removed {(channel.Type == ChannelType.GuildCategory ? channel.Name : $"<#{channel.ID}>")} from the list of ignored channels." - ); - } - - [Command("list")] - [Description("List channels ignored for logging.")] - public async Task ListIgnoredChannelsAsync() - { - var (userId, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var guildChannels = channelCache.GuildChannels(guildId).ToList(); - var guildConfig = await guildRepository.GetAsync(guildId); - - var member = await memberCache.TryGetAsync(guildId, userId); - if (member == null) - return CataloggerError.Result("Executing member not found"); - - var ignoredChannels = guildConfig - .Messages.IgnoredChannels.Select(id => - { - var channel = guildChannels.FirstOrDefault(c => c.ID.Value == id); - if (channel == null) - return new IgnoredChannel( - IgnoredChannelType.Unknown, - DiscordSnowflake.New(id) - ); - - var type = channel.Type switch - { - ChannelType.GuildCategory => IgnoredChannelType.Category, - _ => IgnoredChannelType.Base, - }; - - return new IgnoredChannel( - type, - channel.ID, - permissionResolver - .GetChannelPermissions(guildId, member, channel) - .HasPermission(DiscordPermission.ViewChannel) - ); - }) - .ToList(); - - var embed = new EmbedBuilder() - .WithTitle($"Ignored channels in {guild.Name}") - .WithColour(DiscordUtils.Purple); - - var nonVisibleCategories = ignoredChannels.Count(c => - c is { Type: IgnoredChannelType.Category, CanSee: false } - ); - var visibleCategories = ignoredChannels - .Where(c => c is { Type: IgnoredChannelType.Category, CanSee: true }) - .ToList(); - - if (nonVisibleCategories != 0 || visibleCategories.Count != 0) - { - var value = string.Join("\n", visibleCategories.Select(c => $"<#{c.Id}>")); - if (nonVisibleCategories != 0) - value += - $"\n\n{nonVisibleCategories} channel(s) are not shown as you do not have access to them."; - - embed.AddField("Categories", value); - } - - var nonVisibleBase = ignoredChannels.Count(c => - c is { Type: IgnoredChannelType.Base, CanSee: false } - ); - var visibleBase = ignoredChannels - .Where(c => c is { Type: IgnoredChannelType.Base, CanSee: true }) - .ToList(); - - if (nonVisibleBase != 0 || visibleBase.Count != 0) - { - var value = string.Join("\n", visibleBase.Select(c => $"<#{c.Id}>")); - if (nonVisibleBase != 0) - value += - $"\n\n{nonVisibleBase} channel(s) are not shown as you do not have access to them."; - - embed.AddField("Channels", value); - } - - var unknownChannels = string.Join( - "\n", - ignoredChannels - .Where(c => c.Type == IgnoredChannelType.Unknown) - .Select(c => $"{c.Id} <#{c.Id}>") - ); - if (!string.IsNullOrWhiteSpace(unknownChannels)) - { - embed.AddField("Unknown", unknownChannels); - } - - return await feedbackService.ReplyAsync(embeds: [embed.Build().GetOrThrow()]); - } - - private record struct IgnoredChannel( - IgnoredChannelType Type, - Snowflake Id, - bool CanSee = true - ); - - private enum IgnoredChannelType - { - Unknown, - Base, - Category, - } - } -} diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs deleted file mode 100644 index 2cc46b7..0000000 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs +++ /dev/null @@ -1,122 +0,0 @@ -// 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.ComponentModel; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Services; -using Remora.Discord.Extensions.Embeds; -using IResult = Remora.Results.IResult; - -namespace Catalogger.Backend.Bot.Commands; - -public partial class IgnoreMessageCommands -{ - [Group("roles")] - public class Roles( - GuildRepository guildRepository, - GuildCache guildCache, - RoleCache roleCache, - ContextInjectionService contextInjection, - FeedbackService feedbackService - ) : CommandGroup - { - [Command("add")] - [Description("Add a role to the list of ignored roles.")] - public async Task AddIgnoredRoleAsync( - [Description("The role to ignore")] IRole role - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredRoles.Contains(role.ID.Value)) - return await feedbackService.ReplyAsync( - "That role is already being ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredRoles.Add(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully added {role.Name} to the list of ignored roles." - ); - } - - [Command("remove")] - [Description("Remove a role from the list of ignored roles.")] - public async Task RemoveIgnoredRoleAsync( - [Description("The role to stop ignoring")] IRole role - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.Messages.IgnoredRoles.Contains(role.ID.Value)) - return await feedbackService.ReplyAsync( - "That role is already not ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredRoles.Remove(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully removed {role.Name} from the list of ignored roles." - ); - } - - [Command("list")] - [Description("List roles ignored for logging.")] - public async Task ListIgnoredRolesAsync() - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var guildConfig = await guildRepository.GetAsync(guildId); - - var roles = roleCache - .GuildRoles(guildId) - .Where(r => guildConfig.Messages.IgnoredRoles.Contains(r.ID.Value)) - .OrderByDescending(r => r.Position) - .Select(r => $"<@&{r.ID}>") - .ToList(); - if (roles.Count == 0) - return await feedbackService.ReplyAsync( - "No roles are being ignored right now.", - isEphemeral: true - ); - - return await feedbackService.ReplyAsync( - embeds: - [ - new EmbedBuilder() - .WithTitle($"Ignored roles in {guild.Name}") - .WithDescription(string.Join("\n", roles)) - .WithColour(DiscordUtils.Purple) - .Build() - .GetOrThrow(), - ] - ); - } - } -} diff --git a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs deleted file mode 100644 index 15ae280..0000000 --- a/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs +++ /dev/null @@ -1,124 +0,0 @@ -// 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.ComponentModel; -using Catalogger.Backend.Cache; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Services; -using Remora.Discord.Pagination.Extensions; -using Remora.Rest.Core; -using IResult = Remora.Results.IResult; - -namespace Catalogger.Backend.Bot.Commands; - -public partial class IgnoreMessageCommands -{ - [Group("users")] - public class Users( - GuildRepository guildRepository, - IMemberCache memberCache, - GuildCache guildCache, - UserCache userCache, - ContextInjectionService contextInjection, - FeedbackService feedbackService - ) : CommandGroup - { - [Command("add")] - [Description("Add a user to the list of ignored users.")] - public async Task AddIgnoredUserAsync( - [Description("The user to ignore")] IUser user - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredUsers.Contains(user.ID.Value)) - return await feedbackService.ReplyAsync( - "That user is already being ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredUsers.Add(user.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully added {user.PrettyFormat()} to the list of ignored users." - ); - } - - [Command("remove")] - [Description("Remove a user from the list of ignored users.")] - public async Task RemoveIgnoredUserAsync( - [Description("The user to stop ignoring")] IUser user - ) - { - var (_, guildId) = contextInjection.GetUserAndGuild(); - var guildConfig = await guildRepository.GetAsync(guildId); - - if (!guildConfig.Messages.IgnoredUsers.Contains(user.ID.Value)) - return await feedbackService.ReplyAsync( - "That user is already not ignored.", - isEphemeral: true - ); - - guildConfig.Messages.IgnoredUsers.Remove(user.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); - - return await feedbackService.ReplyAsync( - $"Successfully removed {user.PrettyFormat()} from the list of ignored users." - ); - } - - [Command("list")] - [Description("List currently ignored users.")] - public async Task ListIgnoredUsersAsync() - { - var (userId, guildId) = contextInjection.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var guildConfig = await guildRepository.GetAsync(guildId); - - if (guildConfig.Messages.IgnoredUsers.Count == 0) - return await feedbackService.ReplyAsync("No users are being ignored right now."); - - var users = new List(); - foreach (var id in guildConfig.Messages.IgnoredUsers) - { - var user = await TryGetUserAsync(guildId, DiscordSnowflake.New(id)); - users.Add(user?.PrettyFormat() ?? $"*(unknown user {id})* <@{id}>"); - } - - return await feedbackService.SendContextualPaginatedMessageAsync( - userId, - DiscordUtils.PaginateStrings( - users, - $"Ignored users for {guild.Name} ({users.Count})" - ) - ); - } - - private async Task TryGetUserAsync(Snowflake guildId, Snowflake userId) => - (await memberCache.TryGetAsync(guildId, userId))?.User.Value - ?? await userCache.GetUserAsync(userId); - } -} diff --git a/Catalogger.Backend/Bot/Commands/InviteCommands.cs b/Catalogger.Backend/Bot/Commands/InviteCommands.cs index 6ec6991..77757f6 100644 --- a/Catalogger.Backend/Bot/Commands/InviteCommands.cs +++ b/Catalogger.Backend/Bot/Commands/InviteCommands.cs @@ -43,7 +43,6 @@ public class InviteCommands( InviteRepository inviteRepository, GuildCache guildCache, IInviteCache inviteCache, - UserCache userCache, IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, FeedbackService feedbackService, @@ -59,7 +58,7 @@ public class InviteCommands( var (userId, guildId) = contextInjection.GetUserAndGuild(); var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild not in cache"); var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId); @@ -114,22 +113,21 @@ public class InviteCommands( ); [Command("create")] - [Description("Create a new invite.")] + [Description("Create a new invite.`")] public async Task CreateInviteAsync( [Description("The channel to create the invite in")] [ChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)] IChannel channel, - [Description("What to name the new invite")] string? name = null, - [Description("How long the invite should be valid for")] InviteDuration? duration = null + [Description("What to name the new invite")] string? name = null ) { var (userId, guildId) = contextInjection.GetUserAndGuild(); var inviteResult = await channelApi.CreateChannelInviteAsync( channel.ID, - maxAge: duration?.ToTimespan() ?? TimeSpan.Zero, + maxAge: TimeSpan.Zero, isUnique: true, - reason: $"Create invite command by {await userCache.TryFormatUserAsync(userId, addMention: false)}" + reason: $"Create invite command by {userId}" ); if (inviteResult.Error != null) { @@ -146,20 +144,17 @@ public class InviteCommands( ); } - var durationText = - duration != null ? $"\nThis invite {duration.ToHumanString()}." : string.Empty; - if (name == null) return await feedbackService.ReplyAsync( $"Created a new invite in <#{channel.ID}>!" - + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}{durationText}" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}" ); await inviteRepository.SetInviteNameAsync(guildId, inviteResult.Entity.Code, name); return await feedbackService.ReplyAsync( $"Created a new invite in <#{channel.ID}> with the name **{name}**!" - + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}{durationText}" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}" ); } @@ -258,51 +253,3 @@ public class InviteAutocompleteProvider( .ToList(); } } - -public enum InviteDuration -{ - [Description("30 minutes")] - ThirtyMinutes, - - [Description("1 hour")] - OneHour, - - [Description("6 hours")] - SixHours, - - [Description("12 hours")] - TwelveHours, - - [Description("1 day")] - OneDay, - - [Description("1 week")] - OneWeek, -} - -internal static class InviteEnumExtensions -{ - internal static TimeSpan ToTimespan(this InviteDuration dur) => - dur switch - { - InviteDuration.ThirtyMinutes => TimeSpan.FromMinutes(30), - InviteDuration.OneHour => TimeSpan.FromHours(1), - InviteDuration.SixHours => TimeSpan.FromHours(6), - InviteDuration.TwelveHours => TimeSpan.FromHours(12), - InviteDuration.OneDay => TimeSpan.FromDays(1), - InviteDuration.OneWeek => TimeSpan.FromDays(7), - _ => TimeSpan.Zero, - }; - - internal static string ToHumanString(this InviteDuration? dur) => - dur switch - { - InviteDuration.ThirtyMinutes => "expires after 30 minutes", - InviteDuration.OneHour => "expires after 1 hour", - InviteDuration.SixHours => "expires after 6 hours", - InviteDuration.TwelveHours => "expires after 12 hours", - InviteDuration.OneDay => "expires after 1 day", - InviteDuration.OneWeek => "expires after 1 week", - _ => "does not expire", - }; -} diff --git a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs index dd2ff90..67d3ab8 100644 --- a/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs +++ b/Catalogger.Backend/Bot/Commands/KeyRoleCommands.cs @@ -45,11 +45,11 @@ public class KeyRoleCommands( { var (_, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild not in cache"); var guildRoles = roleCache.GuildRoles(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); - if (guildConfig.KeyRoles.Count == 0) + if (guildConfig.KeyRoles.Length == 0) return await feedbackService.ReplyAsync( "There are no key roles to list. Add some with `/key-roles add`.", isEphemeral: true @@ -76,16 +76,13 @@ public class KeyRoleCommands( [Command("add")] [Description("Add a new key role.")] public async Task AddKeyRoleAsync( - [Option("role")] - [Description("The role to add.")] - [DiscordTypeHint(TypeHint.Role)] - Snowflake roleId + [Description("The role to add.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId ) { var (_, guildId) = contextInjection.GetUserAndGuild(); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); if (role == null) - return CataloggerError.Result("Role is not cached"); + throw new CataloggerError("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.Any(id => role.ID.Value == id)) @@ -94,24 +91,20 @@ public class KeyRoleCommands( isEphemeral: true ); - guildConfig.KeyRoles.Add(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.AddKeyRoleAsync(guildId, role.ID); return await feedbackService.ReplyAsync($"Added {role.Name} to this server's key roles!"); } [Command("remove")] [Description("Remove a key role.")] public async Task RemoveKeyRoleAsync( - [Option("role")] - [Description("The role to remove.")] - [DiscordTypeHint(TypeHint.Role)] - Snowflake roleId + [Description("The role to remove.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId ) { var (_, guildId) = contextInjection.GetUserAndGuild(); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); if (role == null) - return CataloggerError.Result("Role is not cached"); + throw new CataloggerError("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.All(id => role.ID != id)) @@ -120,8 +113,7 @@ public class KeyRoleCommands( isEphemeral: true ); - guildConfig.KeyRoles.Remove(role.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.RemoveKeyRoleAsync(guildId, role.ID); return await feedbackService.ReplyAsync( $"Removed {role.Name} from this server's key roles!" ); diff --git a/Catalogger.Backend/Bot/Commands/MetaCommands.cs b/Catalogger.Backend/Bot/Commands/MetaCommands.cs index 8b543af..8f516f1 100644 --- a/Catalogger.Backend/Bot/Commands/MetaCommands.cs +++ b/Catalogger.Backend/Bot/Commands/MetaCommands.cs @@ -176,7 +176,7 @@ public class MetaCommands( var embed = new EmbedBuilder() .WithColour(DiscordUtils.Purple) .WithFooter( - $"{BuildInfo.Version}, {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" + $"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" ) .WithCurrentTimestamp(); embed.AddField( @@ -209,7 +209,8 @@ public class MetaCommands( "Numbers", $"{CataloggerMetrics.MessagesStored.Value:N0} messages " + $"from {guildCache.Size:N0} servers\n" - + $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis" + + $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis", + false ); IEmbed[] embeds = [embed.Build().GetOrThrow()]; @@ -218,7 +219,7 @@ public class MetaCommands( await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); } - // TODO: add more checks around response format + // TODO: add more checks around response format, configurable prometheus endpoint private async Task MessagesRate() { if (!config.Logging.EnableMetrics) @@ -227,8 +228,7 @@ public class MetaCommands( try { var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); - var prometheusUrl = config.Logging.PrometheusUrl ?? "http://localhost:9090"; - var resp = await _client.GetAsync($"{prometheusUrl}/api/v1/query?query={query}"); + var resp = await _client.GetAsync($"http://localhost:9090/api/v1/query?query={query}"); resp.EnsureSuccessStatusCode(); var data = await resp.Content.ReadFromJsonAsync(); diff --git a/Catalogger.Backend/Bot/Commands/RedirectCommands.cs b/Catalogger.Backend/Bot/Commands/RedirectCommands.cs index 3864c54..c776675 100644 --- a/Catalogger.Backend/Bot/Commands/RedirectCommands.cs +++ b/Catalogger.Backend/Bot/Commands/RedirectCommands.cs @@ -61,7 +61,7 @@ public class RedirectCommands( var (_, guildId) = contextInjectionService.GetUserAndGuild(); var guildConfig = await guildRepository.GetAsync(guildId); guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value; - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); var output = $"Success! Edited and deleted messages from {FormatChannel(source)} will now be redirected to <#{target.ID}>."; @@ -101,7 +101,7 @@ public class RedirectCommands( var guildConfig = await guildRepository.GetAsync(guildId); var wasSet = guildConfig.Channels.Redirects.Remove(source.ID.Value); - await guildRepository.UpdateConfigAsync(guildId, guildConfig); + await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); var output = wasSet ? $"Removed the redirect for {FormatChannel(source)}! Message logs from" @@ -141,7 +141,7 @@ public class RedirectCommands( { var (userId, guildId) = contextInjectionService.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); + throw new CataloggerError("Guild was not cached"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); diff --git a/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs deleted file mode 100644 index c92a886..0000000 --- a/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs +++ /dev/null @@ -1,136 +0,0 @@ -// 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.ComponentModel; -using Catalogger.Backend.Cache; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Models; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Remora.Commands.Attributes; -using Remora.Commands.Groups; -using Remora.Discord.API; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Discord.API.Objects; -using Remora.Discord.Commands.Attributes; -using Remora.Discord.Commands.Feedback.Services; -using Remora.Discord.Commands.Services; -using Remora.Discord.Pagination.Extensions; -using Remora.Rest.Core; -using IResult = Remora.Results.IResult; - -namespace Catalogger.Backend.Bot.Commands; - -[Group("watchlist")] -[Description("Commands for managing the server's watchlist.")] -[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)] -public class WatchlistCommands( - WatchlistRepository watchlistRepository, - GuildCache guildCache, - IMemberCache memberCache, - UserCache userCache, - ContextInjectionService contextInjectionService, - FeedbackService feedbackService -) : CommandGroup -{ - [Command("add")] - [Description("Add a user to the watchlist.")] - public async Task AddAsync( - [Description("The user to add")] IUser user, - [Description("The reason for adding this user to the watchlist")] string reason - ) - { - var (userId, guildId) = contextInjectionService.GetUserAndGuild(); - - var entry = await watchlistRepository.CreateEntryAsync(guildId, user.ID, userId, reason); - return await feedbackService.ReplyAsync( - $"Added {user.PrettyFormat()} to this server's watchlist, with the following reason:\n>>> {entry.Reason}" - ); - } - - [Command("remove")] - [Description("Remove a user from the watchlist.")] - public async Task RemoveAsync([Description("The user to remove")] IUser user) - { - var (userId, guildId) = contextInjectionService.GetUserAndGuild(); - if (!await watchlistRepository.RemoveEntryAsync(guildId, user.ID)) - { - return await feedbackService.ReplyAsync( - $"{user.PrettyFormat()} is not on the watchlist, so you can't remove them from it." - ); - } - - return await feedbackService.ReplyAsync( - $"Removed {user.PrettyFormat()} from the watchlist!" - ); - } - - [Command("show")] - [Description("Show the current watchlist.")] - public async Task ShowAsync() - { - var (userId, guildId) = contextInjectionService.GetUserAndGuild(); - if (!guildCache.TryGet(guildId, out var guild)) - return CataloggerError.Result("Guild not in cache"); - - var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId); - if (watchlist.Count == 0) - return await feedbackService.ReplyAsync( - "There are no entries on the watchlist right now." - ); - - var fields = new List(); - foreach (var entry in watchlist) - fields.Add(await GenerateWatchlistEntryFieldAsync(guildId, entry)); - - return await feedbackService.SendContextualPaginatedMessageAsync( - userId, - DiscordUtils.PaginateFields( - fields, - title: $"Watchlist for {guild.Name} ({fields.Count})", - fieldsPerPage: 5 - ) - ); - } - - private async Task GenerateWatchlistEntryFieldAsync( - Snowflake guildId, - Watchlist entry - ) - { - var user = await TryGetUserAsync(guildId, DiscordSnowflake.New(entry.UserId)); - var fieldName = user != null ? user.Tag() : $"unknown user {entry.UserId}"; - - var moderator = await TryGetUserAsync(guildId, DiscordSnowflake.New(entry.ModeratorId)); - var modName = - moderator != null - ? moderator.PrettyFormat() - : $"*(unknown user {entry.ModeratorId})* <@{entry.ModeratorId}>"; - - return new EmbedField( - Name: fieldName, - Value: $""" - **Moderator:** {modName} - **Added:** - **Reason:** - >>> {entry.Reason} - """ - ); - } - - private async Task TryGetUserAsync(Snowflake guildId, Snowflake userId) => - (await memberCache.TryGetAsync(guildId, userId))?.User.Value - ?? await userCache.GetUserAsync(userId); -} diff --git a/Catalogger.Backend/Bot/DiscordUtils.cs b/Catalogger.Backend/Bot/DiscordUtils.cs index b6512d1..8e0a867 100644 --- a/Catalogger.Backend/Bot/DiscordUtils.cs +++ b/Catalogger.Backend/Bot/DiscordUtils.cs @@ -44,28 +44,4 @@ public static class DiscordUtils description, new Embed(Title: title, Colour: Purple) ); - - public static List PaginateStrings( - IEnumerable strings, - Optional title = default, - int stringsPerPage = 20 - ) - { - var pages = strings.ToArray().Split(stringsPerPage); - return pages - .Select(p => new Embed( - Title: title, - Colour: Purple, - Description: string.Join("\n", p.Select((row, i) => $"{i + 1}. {row}")) - )) - .ToList(); - } - - private static IEnumerable> Split(this T[] arr, int size) - { - for (var i = 0; i < arr.Length / size + 1; i++) - { - yield return arr.Skip(i * size).Take(size); - } - } } diff --git a/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs index 0fc9d7f..93666fe 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs @@ -35,8 +35,6 @@ public class ChannelCreateResponder( { public async Task RespondAsync(IChannelCreate ch, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(ch); - if (!ch.GuildID.IsDefined()) return Result.Success; channelCache.Set(ch); @@ -99,11 +97,8 @@ public class ChannelCreateResponder( var guildConfig = await guildRepository.GetAsync(ch.GuildID); webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.ChannelCreate, - channelId: ch.ID - ), + guildConfig, + LogChannelType.ChannelCreate, builder.Build().GetOrThrow() ); return Result.Success; diff --git a/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs index 06b4727..aaee939 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs @@ -35,8 +35,6 @@ public class ChannelDeleteResponder( public async Task RespondAsync(IChannelDelete evt, CancellationToken ct = default) { - using var __ = LogUtils.Enrich(evt); - if (!evt.GuildID.IsDefined()) { _logger.Debug("Deleted channel {ChannelId} is not in a guild", evt.ID); @@ -70,11 +68,8 @@ public class ChannelDeleteResponder( embed.AddField("Description", topic); webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.ChannelDelete, - channelId: channel.ID - ), + guildConfig, + LogChannelType.ChannelDelete, embed.Build().GetOrThrow() ); return Result.Success; diff --git a/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs index 5f0ef62..0ad0c08 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs @@ -40,8 +40,6 @@ public class ChannelUpdateResponder( public async Task RespondAsync(IChannelUpdate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - try { if (!channelCache.TryGet(evt.ID, out var oldChannel)) @@ -181,13 +179,9 @@ public class ChannelUpdateResponder( // If that happens, there will be no embed fields, so just check for that if (builder.Fields.Count == 0) return Result.Success; - webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.ChannelUpdate, - channelId: evt.ID - ), + guildConfig, + LogChannelType.ChannelUpdate, builder.Build().GetOrThrow() ); diff --git a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs index 1fd3b9e..b038cfe 100644 --- a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs +++ b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs @@ -13,27 +13,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Catalogger.Backend.Extensions; using Microsoft.Extensions.Options; using Remora.Commands.Services; using Remora.Commands.Tokenization; using Remora.Commands.Trees; using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; -using Remora.Discord.API.Objects; using Remora.Discord.Commands.Responders; using Remora.Discord.Commands.Services; using Remora.Discord.Gateway.Responders; -using Remora.Rest.Core; using Remora.Results; -using Serilog.Context; namespace Catalogger.Backend.Bot.Responders; /// -/// Wrapper for Remora.Discord's default interaction responder, that ignores all events if test mode is enabled, -/// and handles results returned by commands. +/// Wrapper for Remora.Discord's default interaction responder, that ignores all events if test mode is enabled. /// public class CustomInteractionResponder( Config config, @@ -51,78 +45,34 @@ public class CustomInteractionResponder( { private readonly ILogger _logger = logger.ForContext(); - private readonly InteractionResponder _inner = new( - commandService, - options, - interactionAPI, - eventCollector, - services, - contextInjection, - tokenizerOptions, - treeSearchOptions, - treeNameResolver - ); + private readonly InteractionResponder _inner = + new( + commandService, + options, + interactionAPI, + eventCollector, + services, + contextInjection, + tokenizerOptions, + treeSearchOptions, + treeNameResolver + ); - public async Task RespondAsync(IInteractionCreate evt, CancellationToken ct = default) + public async Task RespondAsync( + IInteractionCreate gatewayEvent, + CancellationToken ct = default + ) { if (config.Discord.TestMode) { _logger.Information( "Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled", - evt.ID, - evt.Channel.Map(c => c.ID).OrDefault() + gatewayEvent.ID, + gatewayEvent.Channel.Map(c => c.ID).OrDefault() ); return Result.Success; } - using var _ = LogUtils.PushProperties( - ("Event", nameof(IInteractionCreate)), - ("InteractionId", evt.ID), - ("GuildId", evt.GuildID), - ("UserId", evt.User.Map(u => u.ID)), - ("MemberId", evt.Member.Map(m => m.User.Map(u => u.ID).OrDefault())), - ("ChannelId", evt.Channel.Map(c => c.ID)), - ("InteractionType", evt.Type) - ); - - using var __ = LogContext.PushProperty( - "InteractionData", - evt.Data.HasValue ? (object?)evt.Data.Value : null, - true - ); - - var result = await _inner.RespondAsync(evt, ct); - if (result.Error is not CataloggerError cataloggerError) - return result; - - return await interactionAPI.CreateInteractionResponseAsync( - evt.ID, - evt.Token, - new InteractionResponse( - Type: InteractionCallbackType.ChannelMessageWithSource, - Data: new Optional>( - new InteractionMessageCallbackData( - Embeds: new Optional>( - [ - new Embed( - Colour: DiscordUtils.Red, - Title: "Something went wrong", - Description: $""" - Something went wrong while running this command. - > {cataloggerError.Message} - Please try again later. - """ - ), - ] - ) - ) - ) - ), - ct: ct - ); + return await _inner.RespondAsync(gatewayEvent, ct); } } diff --git a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs index fba79eb..1d88db1 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs @@ -14,7 +14,6 @@ // along with this program. If not, see . using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Extensions; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Gateway.Responders; @@ -29,8 +28,6 @@ public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger) public Task RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - if (evt.TargetID == null || evt.UserID == null) return Task.FromResult(Result.Success); diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs index 2feb745..0100b1f 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs @@ -37,7 +37,6 @@ public class GuildBanAddResponder( public async Task RespondAsync(IGuildBanAdd evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); var guildConfig = await guildRepository.GetAsync(evt.GuildID); // Delay 2 seconds for the audit log @@ -77,12 +76,7 @@ public class GuildBanAddResponder( evt.GuildID ); - await guildRepository.BanSystemAsync( - evt.GuildID, - evt.User.ID, - pkSystem.Id, - pkSystem.Uuid - ); + await guildRepository.BanSystemAsync(evt.GuildID, pkSystem.Id, pkSystem.Uuid); } embed.AddField( diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs index cc50908..ee1d1bc 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs @@ -37,7 +37,6 @@ public class GuildBanRemoveResponder( public async Task RespondAsync(IGuildBanRemove evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); var guildConfig = await guildRepository.GetAsync(evt.GuildID); // Delay 2 seconds for the audit log @@ -68,52 +67,20 @@ public class GuildBanRemoveResponder( var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct); if (pkSystem != null) { - await guildRepository.UnbanSystemAsync( - evt.GuildID, - evt.User.ID, - pkSystem.Id, - pkSystem.Uuid + await guildRepository.UnbanSystemAsync(evt.GuildID, pkSystem.Id, pkSystem.Uuid); + + embed.AddField( + "PluralKit system", + $""" + **ID:** {pkSystem.Id} + **UUID:** `{pkSystem.Uuid}` + **Name:** {pkSystem.Name ?? "*(none)*"} + **Tag:** {pkSystem.Tag ?? "*(none)*"} + + This system has been unbanned. + Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts. + """ ); - - var systemUsers = await guildRepository.GetSystemAccountsAsync( - evt.GuildID, - pkSystem.Uuid - ); - if (systemUsers.Length == 0) - { - embed.AddField( - "PluralKit system", - $""" - **ID:** {pkSystem.Id} - **UUID:** `{pkSystem.Uuid}` - **Name:** {pkSystem.Name ?? "*(none)*"} - **Tag:** {pkSystem.Tag ?? "*(none)*"} - - This system has been unbanned. - Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts. - """ - ); - } - else - { - var users = new List(); - foreach (var id in systemUsers) - users.Add("- " + await userCache.TryFormatUserAsync(id)); - - embed.AddField( - "PluralKit system", - $""" - **ID:** {pkSystem.Id} - **UUID:** `{pkSystem.Uuid}` - **Name:** {pkSystem.Name ?? "*(none)*"} - **Tag:** {pkSystem.Tag ?? "*(none)*"} - - This system has been unbanned. - Note that the following accounts are known to be linked to this system and banned from this server: - {string.Join("\n", users)} - """ - ); - } } webhookExecutor.QueueLog( diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs index 55bac7a..ab12125 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs @@ -35,7 +35,6 @@ public class GuildCreateResponder( RoleCache roleCache, IMemberCache memberCache, IInviteCache inviteCache, - IWebhookCache webhookCache, WebhookExecutorService webhookExecutor, GuildFetchService guildFetchService ) : IResponder, IResponder @@ -44,8 +43,6 @@ public class GuildCreateResponder( public async Task RespondAsync(IGuildCreate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - ulong guildId; string? guildName = null; if (evt.Guild.TryPickT0(out var guild, out var unavailableGuild)) @@ -103,8 +100,6 @@ public class GuildCreateResponder( public async Task RespondAsync(IGuildDelete evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - if (evt.IsUnavailable.OrDefault(false)) { _logger.Debug("Guild {GuildId} became unavailable", evt.ID); @@ -112,18 +107,14 @@ public class GuildCreateResponder( } // Clear the cache for this guild - var wasCached = guildCache.Remove(evt.ID, out var guild); + guildCache.Remove(evt.ID, out _); emojiCache.Remove(evt.ID); channelCache.RemoveGuild(evt.ID); roleCache.RemoveGuild(evt.ID); await memberCache.RemoveAllMembersAsync(evt.ID); await inviteCache.RemoveAsync(evt.ID); - // Also clear the webhook cache - var guildConfig = await guildRepository.GetAsync(evt.ID); - await webhookCache.RemoveWebhooksAsync(guildConfig.Channels.AllChannels); - - if (!wasCached || guild == null) + if (!guildCache.TryGet(evt.ID, out var guild)) { _logger.Information("Left uncached guild {GuildId}", evt.ID); return Result.Success; diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs index c70bd41..0ccc859 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs @@ -37,8 +37,6 @@ public class GuildEmojisUpdateResponder( public async Task RespondAsync(IGuildEmojisUpdate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - try { if (!emojiCache.TryGet(evt.GuildID, out var oldEmoji)) diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs index 8a43b46..8a7c786 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs @@ -14,7 +14,6 @@ // along with this program. If not, see . using Catalogger.Backend.Cache; -using Catalogger.Backend.Extensions; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.Gateway.Responders; using Remora.Results; @@ -28,8 +27,6 @@ public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache public async Task RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - _logger.Debug( "Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}", evt.ChunkIndex + 1, diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs index b2992c4..ee43e98 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs @@ -37,8 +37,6 @@ public class GuildUpdateResponder( public async Task RespondAsync(IGuildUpdate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - try { if (!guildCache.TryGet(evt.ID, out var oldGuild)) diff --git a/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs index d50f81d..329d764 100644 --- a/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs @@ -37,7 +37,6 @@ public class InviteCreateResponder( public async Task RespondAsync(IInviteCreate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); var guildId = evt.GuildID.Value; var invitesResult = await guildApi.GetGuildInvitesAsync(guildId, ct); diff --git a/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs index d34b3c8..bb9d855 100644 --- a/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs @@ -38,7 +38,6 @@ public class InviteDeleteResponder( public async Task RespondAsync(IInviteDelete evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); var guildId = evt.GuildID.Value; var dbDeleteCount = await inviteRepository.DeleteInviteAsync(guildId, evt.Code); diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs index 6d8901c..5ea725a 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs @@ -48,8 +48,6 @@ public class GuildMemberAddResponder( public async Task RespondAsync(IGuildMemberAdd member, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(member); - await memberCache.SetAsync(member.GuildID, member); await memberCache.SetMemberNamesAsync(member.GuildID, [member]); @@ -128,7 +126,7 @@ public class GuildMemberAddResponder( goto afterInvite; } - var inviteName = await inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); + var inviteName = inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); var inviteDescription = $""" **Code:** {usedInvite.Code} @@ -158,7 +156,7 @@ public class GuildMemberAddResponder( ); } - var watchlist = await watchlistRepository.GetEntryAsync(member.GuildID, user.ID); + var watchlist = await watchlistRepository.GetWatchlistEntryAsync(member.GuildID, user.ID); if (watchlist != null) { var moderator = await userCache.GetUserAsync( diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs index a2e61d1..94cf8cc 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs @@ -39,8 +39,6 @@ public class GuildMemberRemoveResponder( public async Task RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - try { var embed = new EmbedBuilder() diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs index 649c945..4a6e6b8 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs @@ -15,11 +15,9 @@ using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; -using NodaTime.Extensions; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.Extensions.Embeds; @@ -32,8 +30,6 @@ namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberUpdateResponder( ILogger logger, GuildRepository guildRepository, - TimeoutRepository timeoutRepository, - TimeoutService timeoutService, UserCache userCache, RoleCache roleCache, IMemberCache memberCache, @@ -48,8 +44,6 @@ public class GuildMemberUpdateResponder( CancellationToken ct = default ) { - using var _ = LogUtils.Enrich(newMember); - try { var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID); @@ -251,15 +245,11 @@ public class GuildMemberUpdateResponder( var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId); embed.AddField("Responsible moderator", moderator); embed.AddField("Reason", actionData.Reason ?? "No reason given"); - - await UpdateTimeoutDatabaseAsync(member, actionData.ModeratorId); } else { embed.AddField("Responsible moderator", "*(unknown)*"); embed.AddField("Reason", "*(unknown)*"); - - await UpdateTimeoutDatabaseAsync(member, null); } var guildConfig = await guildRepository.GetAsync(member.GuildID); @@ -271,27 +261,6 @@ public class GuildMemberUpdateResponder( return Result.Success; } - private async Task UpdateTimeoutDatabaseAsync(IGuildMemberUpdate member, Snowflake? moderatorId) - { - var until = member.CommunicationDisabledUntil.OrDefault(); - if (until == null) - { - // timeout was ended early, delete database entry - var oldTimeout = await timeoutRepository.RemoveAsync(member.GuildID, member.User.ID); - if (oldTimeout != null) - timeoutService.RemoveTimer(oldTimeout.Id); - return; - } - - var dbTimeout = await timeoutRepository.SetAsync( - member.GuildID, - member.User.ID, - until.Value.ToInstant(), - moderatorId - ); - timeoutService.AddTimer(dbTimeout); - } - private async Task HandleRoleUpdateAsync( IGuildMemberUpdate member, IReadOnlyList oldRoles, @@ -317,20 +286,19 @@ public class GuildMemberUpdateResponder( .WithFooter($"User ID: {member.User.ID}") .WithCurrentTimestamp(); - var addedRoles = member.Roles.Except(oldRoles).ToList(); - var removedRoles = oldRoles.Except(member.Roles).ToList(); + var addedRoles = member.Roles.Except(oldRoles).Select(s => s.Value).ToList(); + var removedRoles = oldRoles.Except(member.Roles).Select(s => s.Value).ToList(); if (addedRoles.Count != 0) { roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>"))); // Add all added key roles to the log - if (!addedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any()) + if (!addedRoles.Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", addedRoles - .Select(s => s.Value) .Where(guildConfig.KeyRoles.Contains) .Select(id => { @@ -351,12 +319,11 @@ public class GuildMemberUpdateResponder( ); // Add all removed key roles to the log - if (!removedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any()) + if (!removedRoles.Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", removedRoles - .Select(s => s.Value) .Where(guildConfig.KeyRoles.Contains) .Select(id => { @@ -365,7 +332,7 @@ public class GuildMemberUpdateResponder( }) ); - keyRoleUpdate.AddField("Removed", value); + keyRoleUpdate.AddField("Added", value); } } @@ -373,12 +340,8 @@ public class GuildMemberUpdateResponder( if (roleUpdate.Fields.Count != 0) { webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.GuildMemberUpdate, - // Check for all added and removed roles - roleIds: addedRoles.Concat(removedRoles).ToList() - ), + guildConfig, + LogChannelType.GuildMemberUpdate, roleUpdate.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs index e54d12b..cc45edc 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs @@ -38,8 +38,6 @@ public class MessageCreateResponder( public async Task RespondAsync(IMessageCreate msg, CancellationToken ct = default) { - using var __ = LogUtils.Enrich(msg); - userCache.UpdateUser(msg.Author); CataloggerMetrics.MessagesReceived.Inc(); @@ -55,13 +53,7 @@ public class MessageCreateResponder( var guild = await guildRepository.GetAsync(msg.GuildID); // The guild needs to have enabled at least one of the message logging events, // and the channel must not be ignored, to store the message. - if ( - guild.IsMessageIgnored( - msg.ChannelID, - msg.Author.ID, - msg.Member.OrDefault()?.Roles.OrDefault() - ) - ) + if (guild.IsMessageIgnored(msg.ChannelID, msg.Author.ID)) { await messageRepository.IgnoreMessageAsync(msg.ID.Value); return Result.Success; @@ -77,7 +69,7 @@ public class MessageCreateResponder( return Result.Success; } - await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct); + await messageRepository.SaveMessageAsync(msg, ct); return Result.Success; } } @@ -96,11 +88,18 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) public async Task HandlePkMessageAsync(IMessageCreate msg) { + _logger.Debug("Received PluralKit message"); + await Task.Delay(500.Milliseconds()); + _logger.Debug("Starting handling PluralKit message"); + // Check if the content matches a Discord link--if not, it's not a log message (we already check if this is a PluralKit message earlier) if (!LinkRegex().IsMatch(msg.Content)) + { + _logger.Debug("PluralKit message is not a log message because content is not a link"); return; + } // The first (only, I think always?) embed's footer must match the expected format var firstEmbed = msg.Embeds.FirstOrDefault(); @@ -109,7 +108,12 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) || !firstEmbed.Footer.TryGet(out var footer) || !FooterRegex().IsMatch(footer.Text) ) + { + _logger.Debug( + "PK message is not a log message because there is no first embed or its footer doesn't match the regex" + ); return; + } var match = FooterRegex().Match(footer.Text); @@ -144,33 +148,16 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) await using var messageRepository = scope.ServiceProvider.GetRequiredService(); - if (await messageRepository.IsMessageIgnoredAsync(originalId)) - { - _logger.Debug( - "Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored", + await Task.WhenAll( + messageRepository.SetProxiedMessageDataAsync( msgId, - originalId - ); - - await messageRepository.IgnoreMessageAsync(originalId); - await messageRepository.IgnoreMessageAsync(msgId); - return; - } - - _logger.Debug( - "Setting proxy data for {MessageId} and ignoring {OriginalId}", - msgId, - originalId + originalId, + authorId, + systemId: match.Groups[1].Value, + memberId: match.Groups[2].Value + ), + messageRepository.IgnoreMessageAsync(originalId) ); - - await messageRepository.SetProxiedMessageDataAsync( - msgId, - originalId, - authorId, - systemId: match.Groups[1].Value, - memberId: match.Groups[2].Value - ); - await messageRepository.IgnoreMessageAsync(originalId); } public async Task HandleProxiedMessageAsync(ulong msgId) @@ -202,32 +189,15 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) return; } - _logger.Debug( - "Setting proxy data for {MessageId} and ignoring {OriginalId}", - msgId, - pkMessage.Original + await Task.WhenAll( + messageRepository.SetProxiedMessageDataAsync( + msgId, + pkMessage.Original, + pkMessage.Sender, + pkMessage.System?.Id, + pkMessage.Member?.Id + ), + messageRepository.IgnoreMessageAsync(pkMessage.Original) ); - - if (await messageRepository.IsMessageIgnoredAsync(pkMessage.Original)) - { - _logger.Debug( - "Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored", - pkMessage.Id, - pkMessage.Original - ); - - await messageRepository.IgnoreMessageAsync(pkMessage.Original); - await messageRepository.IgnoreMessageAsync(msgId); - return; - } - - await messageRepository.SetProxiedMessageDataAsync( - msgId, - pkMessage.Original, - pkMessage.Sender, - pkMessage.System?.Id, - pkMessage.Member?.Id - ); - await messageRepository.IgnoreMessageAsync(pkMessage.Original); } } diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs index 157d30d..6676839 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs @@ -41,10 +41,8 @@ public class MessageDeleteBulkResponder( public async Task RespondAsync(IMessageDeleteBulk evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - var guild = await guildRepository.GetAsync(evt.GuildID); - if (guild.IsMessageIgnored(evt.ChannelID, null, null)) + if (guild.IsMessageIgnored(evt.ChannelID, null)) return Result.Success; var logChannel = webhookExecutor.GetLogChannel( diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs index 55a5a68..f866b94 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs @@ -18,6 +18,7 @@ using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Humanizer; +using NodaTime; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; @@ -36,6 +37,7 @@ public class MessageDeleteResponder( WebhookExecutorService webhookExecutor, ChannelCache channelCache, UserCache userCache, + IClock clock, PluralkitApiService pluralkitApi ) : IResponder { @@ -46,8 +48,6 @@ public class MessageDeleteResponder( public async Task RespondAsync(IMessageDelete evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - if (!evt.GuildID.IsDefined()) return Result.Success; @@ -64,22 +64,27 @@ public class MessageDeleteResponder( return Result.Success; var guild = await guildRepository.GetAsync(evt.GuildID); + if (guild.IsMessageIgnored(evt.ChannelID, evt.ID)) + return Result.Success; + + var logChannel = webhookExecutor.GetLogChannel( + guild, + LogChannelType.MessageDelete, + evt.ChannelID + ); var msg = await messageRepository.GetMessageAsync(evt.ID.Value, ct); // Sometimes a message that *should* be logged isn't stored in the database, notify the user of that if (msg == null) { - _logger.Debug( - "Deleted message {MessageId} should be logged but is not in the database", - evt.ID - ); - + if (logChannel == null) + return Result.Success; webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, evt.ChannelID), + logChannel.Value, new Embed( Title: "Message deleted", Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).", - Footer: new EmbedFooter(Text: $"ID: {evt.ID} | Original sent at"), - Timestamp: evt.ID.Timestamp + Footer: new EmbedFooter(Text: $"ID: {evt.ID}"), + Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() ) ); @@ -102,26 +107,21 @@ public class MessageDeleteResponder( } } - var logChannel = webhookExecutor.GetLogChannel( + logChannel = webhookExecutor.GetLogChannel( guild, LogChannelType.MessageDelete, evt.ChannelID, msg.UserId ); - if (logChannel is null or 0) - { - _logger.Debug( - "Message {MessageId} should not be logged; either ignored or message delete logs are disabled", - evt.ID - ); - } + if (logChannel == null) + return Result.Success; var user = await userCache.GetUserAsync(DiscordSnowflake.New(msg.UserId)); var builder = new EmbedBuilder() .WithTitle("Message deleted") .WithDescription(msg.Content) .WithColour(DiscordUtils.Red) - .WithFooter($"ID: {msg.Id} | Original sent at") + .WithFooter($"ID: {msg.Id}") .WithTimestamp(evt.ID); if (user != null) @@ -173,7 +173,7 @@ public class MessageDeleteResponder( builder.AddField("Attachments", attachmentInfo, false); } - webhookExecutor.QueueLog(logChannel, builder.Build().GetOrThrow()); + webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow()); return Result.Success; } } diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs index 0bf2c28..1dd9290 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs @@ -20,6 +20,7 @@ using Catalogger.Backend.Services; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Gateway.Events; using Remora.Discord.API.Objects; using Remora.Discord.Extensions.Embeds; using Remora.Discord.Gateway.Responders; @@ -39,9 +40,11 @@ public class MessageUpdateResponder( { private readonly ILogger _logger = logger.ForContext(); - public async Task RespondAsync(IMessageUpdate msg, CancellationToken ct = default) + public async Task RespondAsync(IMessageUpdate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(msg); + // Discord only *very* recently changed message update events to have all fields, + // so we convert the event to a MessageCreate to avoid having to unwrap every single field + var msg = ConvertToMessageCreate(evt); if (!msg.GuildID.IsDefined()) { @@ -55,7 +58,10 @@ public class MessageUpdateResponder( var guildConfig = await guildRepository.GetAsync(msg.GuildID); if (await messageRepository.IsMessageIgnoredAsync(msg.ID.Value)) + { + _logger.Debug("Message {MessageId} should be ignored", msg.ID); return Result.Success; + } try { @@ -129,7 +135,7 @@ public class MessageUpdateResponder( if (oldMessage is { System: not null, Member: not null }) { embedBuilder.WithTitle($"Message by {msg.Author.Username} edited"); - embedBuilder.AddField("\u200b", "**PluralKit information**"); + embedBuilder.AddField("\u200b", "**PluralKit information**", false); embedBuilder.AddField("System ID", oldMessage.System, true); embedBuilder.AddField("Member ID", oldMessage.Member, true); } @@ -169,7 +175,7 @@ public class MessageUpdateResponder( ) { if ( - !await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct) + !await messageRepository.SaveMessageAsync(msg, ct) && msg.ApplicationID.Is(DiscordUtils.PkUserId) ) { @@ -191,6 +197,44 @@ public class MessageUpdateResponder( } } + private static MessageCreate ConvertToMessageCreate(IMessageUpdate evt) => + new( + evt.GuildID, + evt.Member, + evt.Mentions.GetOrThrow(), + evt.ID.GetOrThrow(), + evt.ChannelID.GetOrThrow(), + evt.Author.GetOrThrow(), + evt.Content.GetOrThrow(), + evt.Timestamp.GetOrThrow(), + evt.EditedTimestamp.GetOrThrow(), + IsTTS: false, + evt.MentionsEveryone.GetOrThrow(), + evt.MentionedRoles.GetOrThrow(), + evt.MentionedChannels, + evt.Attachments.GetOrThrow(), + evt.Embeds.GetOrThrow(), + evt.Reactions, + evt.Nonce, + evt.IsPinned.GetOrThrow(), + evt.WebhookID, + evt.Type.GetOrThrow(), + evt.Activity, + evt.Application, + evt.ApplicationID, + evt.MessageReference, + evt.Flags, + evt.ReferencedMessage, + evt.Interaction, + evt.Thread, + evt.Components, + evt.StickerItems, + evt.Position, + evt.Resolved, + evt.InteractionMetadata, + evt.Poll + ); + private static IEnumerable ChunksUpTo(string str, int maxChunkSize) { for (var i = 0; i < str.Length; i += maxChunkSize) diff --git a/Catalogger.Backend/Bot/Responders/ReadyResponder.cs b/Catalogger.Backend/Bot/Responders/ReadyResponder.cs index 8c43140..192ce72 100644 --- a/Catalogger.Backend/Bot/Responders/ReadyResponder.cs +++ b/Catalogger.Backend/Bot/Responders/ReadyResponder.cs @@ -26,19 +26,19 @@ public class ReadyResponder(ILogger logger, WebhookExecutorService webhookExecut { private readonly ILogger _logger = logger.ForContext(); - public Task RespondAsync(IReady evt, CancellationToken ct = default) + public Task RespondAsync(IReady gatewayEvent, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - - var shardId = evt.Shard.TryGet(out var shard) ? (shard.ShardID, shard.ShardCount) : (0, 1); + var shardId = gatewayEvent.Shard.TryGet(out var shard) + ? (shard.ShardID, shard.ShardCount) + : (0, 1); _logger.Information( "Ready as {User} on shard {ShardId}/{ShardCount}", - evt.User.Tag(), + gatewayEvent.User.Tag(), shardId.Item1, shardId.Item2 ); if (shardId.Item1 == 0) - webhookExecutorService.SetSelfUser(evt.User); + webhookExecutorService.SetSelfUser(gatewayEvent.User); return Task.FromResult(Result.Success); } diff --git a/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs index 8d079d7..4df76ea 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs @@ -35,8 +35,6 @@ public class RoleCreateResponder( public async Task RespondAsync(IGuildRoleCreate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - _logger.Debug("Received new role {RoleId} in guild {GuildId}", evt.Role.ID, evt.GuildID); roleCache.Set(evt.Role, evt.GuildID); @@ -56,11 +54,8 @@ public class RoleCreateResponder( } webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.GuildRoleCreate, - roleId: evt.Role.ID - ), + guildConfig, + LogChannelType.GuildRoleCreate, embed.Build().GetOrThrow() ); diff --git a/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs index 5f9b648..8566434 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs @@ -35,8 +35,6 @@ public class RoleDeleteResponder( public async Task RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default) { - using var __ = LogUtils.Enrich(evt); - try { if (!roleCache.TryGet(evt.RoleID, out var role)) @@ -72,11 +70,8 @@ public class RoleDeleteResponder( } webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.GuildRoleDelete, - roleId: role.ID - ), + guildConfig, + LogChannelType.GuildRoleDelete, embed.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs index 656bb02..828ef22 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs @@ -37,8 +37,6 @@ public class RoleUpdateResponder( public async Task RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default) { - using var _ = LogUtils.Enrich(evt); - try { var newRole = evt.Role; @@ -98,11 +96,8 @@ public class RoleUpdateResponder( var guildConfig = await guildRepository.GetAsync(evt.GuildID); webhookExecutor.QueueLog( - webhookExecutor.GetLogChannel( - guildConfig, - LogChannelType.GuildRoleUpdate, - roleId: evt.Role.ID - ), + guildConfig, + LogChannelType.GuildRoleUpdate, embed.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/ShardedGatewayClient.cs b/Catalogger.Backend/Bot/ShardedGatewayClient.cs index 5621389..2de70cd 100644 --- a/Catalogger.Backend/Bot/ShardedGatewayClient.cs +++ b/Catalogger.Backend/Bot/ShardedGatewayClient.cs @@ -115,9 +115,7 @@ public class ShardedGatewayClient( _logger.Information("Started shard {ShardId}/{ShardCount}", shardIndex, TotalShards); } - var taskResult = await await Task.WhenAny(tasks); - Disconnect(); - return taskResult; + return await await Task.WhenAny(tasks); } public int ShardIdFor(ulong guildId) => (int)((guildId >> 22) % (ulong)TotalShards); @@ -138,17 +136,6 @@ public class ShardedGatewayClient( client.Dispose(); } - private void Disconnect() - { - _logger.Information("Disconnecting from Discord"); - foreach (var shardId in _gatewayClients.Keys) - { - _logger.Debug("Disposing shard {shardId}", shardId); - if (_gatewayClients.Remove(shardId, out var client)) - client.Dispose(); - } - } - private IOptions CloneOptions( DiscordGatewayClientOptions options, int shardId diff --git a/Catalogger.Backend/BuildInfo.cs b/Catalogger.Backend/BuildInfo.cs deleted file mode 100644 index 95ca05f..0000000 --- a/Catalogger.Backend/BuildInfo.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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 . - -namespace Catalogger.Backend; - -public static class BuildInfo -{ - public static string Hash { get; private set; } = "(unknown)"; - public static string Version { get; private set; } = "(unknown)"; - - public static async Task ReadBuildInfo() - { - await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); - if (stream == null) - return; - - using var reader = new StreamReader(stream); - var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); - if (data.Length < 3) - return; - - Hash = data[0]; - var dirty = data[2] == "dirty"; - - var versionData = data[1].Split("-"); - if (versionData.Length < 3) - return; - Version = versionData[0]; - if (versionData[1] != "0" || dirty) - Version += $"+{versionData[2]}"; - if (dirty) - Version += ".dirty"; - } -} diff --git a/Catalogger.Backend/Cache/IWebhookCache.cs b/Catalogger.Backend/Cache/IWebhookCache.cs index 69a256a..4267260 100644 --- a/Catalogger.Backend/Cache/IWebhookCache.cs +++ b/Catalogger.Backend/Cache/IWebhookCache.cs @@ -24,7 +24,6 @@ public interface IWebhookCache { Task GetWebhookAsync(ulong channelId); Task SetWebhookAsync(ulong channelId, Webhook webhook); - Task RemoveWebhooksAsync(ulong[] channelIds); public async Task GetOrFetchWebhookAsync( ulong channelId, diff --git a/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs b/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs index 3a6208b..b4f1a2f 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs @@ -33,11 +33,4 @@ public class InMemoryWebhookCache : IWebhookCache _cache[channelId] = webhook; return Task.CompletedTask; } - - public Task RemoveWebhooksAsync(ulong[] channelIds) - { - foreach (var id in channelIds) - _cache.TryRemove(id, out _); - return Task.CompletedTask; - } } diff --git a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs index 0e08c1f..dfa9694 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs @@ -234,7 +234,6 @@ internal record RedisMember( User.ToRemoraUser(), Nickname, Avatar != null ? new ImageHash(Avatar) : null, - Banner: null, Roles.Select(DiscordSnowflake.New).ToList(), JoinedAt, PremiumSince, diff --git a/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs b/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs index 0e2962c..9c07f62 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs @@ -26,8 +26,5 @@ public class RedisWebhookCache(RedisService redisService) : IWebhookCache public async Task SetWebhookAsync(ulong channelId, Webhook webhook) => await redisService.SetAsync(WebhookKey(channelId), webhook, 24.Hours()); - public async Task RemoveWebhooksAsync(ulong[] channelIds) => - await redisService.DeleteAsync(channelIds.Select(WebhookKey).ToArray()); - private static string WebhookKey(ulong channelId) => $"webhook:{channelId}"; } diff --git a/Catalogger.Backend/Catalogger.Backend.csproj b/Catalogger.Backend/Catalogger.Backend.csproj index b377418..436899d 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -1,44 +1,44 @@ - net9.0 + net8.0 enable enable + true - - - - - - - - + + - + - - - - + + + + - - - + + + + + + + + diff --git a/Catalogger.Backend/CataloggerError.cs b/Catalogger.Backend/CataloggerError.cs index 40322a4..31abf6f 100644 --- a/Catalogger.Backend/CataloggerError.cs +++ b/Catalogger.Backend/CataloggerError.cs @@ -13,13 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Remora.Results; -using RemoraResult = Remora.Results.Result; - namespace Catalogger.Backend; -public class CataloggerError(string message) : Exception(message), IResultError -{ - public static RemoraResult Result(string message) => - RemoraResult.FromError(new CataloggerError(message)); -} +public class CataloggerError(string message) : Exception(message) { } diff --git a/Catalogger.Backend/CataloggerMetrics.cs b/Catalogger.Backend/CataloggerMetrics.cs index ce55805..b8726b5 100644 --- a/Catalogger.Backend/CataloggerMetrics.cs +++ b/Catalogger.Backend/CataloggerMetrics.cs @@ -29,11 +29,6 @@ public static class CataloggerMetrics public static long MessageRateMinute { get; set; } - public static readonly Gauge DatabaseConnections = Metrics.CreateGauge( - "catalogger_open_database_connections", - "Number of open database connections" - ); - public static readonly Gauge GuildsCached = Metrics.CreateGauge( "catalogger_cache_guilds", "Number of guilds in the cache" @@ -44,11 +39,6 @@ public static class CataloggerMetrics "Number of channels in the cache" ); - public static readonly Gauge RolesCached = Metrics.CreateGauge( - "catalogger_cache_roles", - "Number of roles in the cache" - ); - public static readonly Gauge UsersCached = Metrics.CreateGauge( "catalogger_cache_users", "Number of users in the cache" diff --git a/Catalogger.Backend/Config.cs b/Catalogger.Backend/Config.cs index 831c439..2a60d6a 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -33,7 +33,6 @@ public class Config public bool EnableMetrics { get; init; } = true; public string? SeqLogUrl { get; init; } - public string? PrometheusUrl { get; init; } } public class DatabaseConfig @@ -61,9 +60,6 @@ public class Config // If enabled, nothing will be logged. public bool TestMode { get; init; } = false; - - // Token for discord.bots.gg stats - public string? BotsGgToken { get; init; } } public class WebConfig diff --git a/Catalogger.Backend/Database/DatabaseConnection.cs b/Catalogger.Backend/Database/DatabaseConnection.cs index b2488f9..61d2a7e 100644 --- a/Catalogger.Backend/Database/DatabaseConnection.cs +++ b/Catalogger.Backend/Database/DatabaseConnection.cs @@ -17,13 +17,16 @@ using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; using Npgsql; -using Serilog; namespace Catalogger.Backend.Database; -public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposable +public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) + : DbConnection, + IDisposable { - public NpgsqlConnection Inner => inner; + public Guid ConnectionId => id; + private readonly ILogger _logger = logger.ForContext(); + private readonly DateTimeOffset _openTime = DateTimeOffset.UtcNow; private bool _hasClosed; @@ -39,6 +42,8 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa } DatabasePool.DecrementConnections(); + var openFor = DateTimeOffset.UtcNow - _openTime; + _logger.Verbose("Closing connection {ConnId}, open for {OpenFor}", ConnectionId, openFor); _hasClosed = true; await inner.CloseAsync(); } @@ -46,20 +51,17 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa protected override async ValueTask BeginDbTransactionAsync( IsolationLevel isolationLevel, CancellationToken cancellationToken - ) => await inner.BeginTransactionAsync(isolationLevel, cancellationToken); + ) + { + _logger.Verbose("Beginning transaction on connection {ConnId}", ConnectionId); + return await inner.BeginTransactionAsync(isolationLevel, cancellationToken); + } public new void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected override void Dispose(bool disposing) - { - Log.Error("Called Dispose method on DbConnection, should call DisposeAsync!"); - Log.Warning("CloseAsync will be called synchronously."); - CloseAsync().Wait(); + Close(); inner.Dispose(); + GC.SuppressFinalize(this); } public override async ValueTask DisposeAsync() @@ -70,13 +72,13 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa } protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => - throw new SyncException(nameof(BeginDbTransaction)); + inner.BeginTransaction(isolationLevel); public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); - public override void Close() => throw new SyncException(nameof(Close)); + public override void Close() => inner.Close(); - public override void Open() => throw new SyncException(nameof(Open)); + public override void Open() => inner.Open(); [AllowNull] public override string ConnectionString @@ -91,6 +93,4 @@ public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposa public override string ServerVersion => inner.ServerVersion; protected override DbCommand CreateDbCommand() => inner.CreateCommand(); - - public class SyncException(string method) : Exception($"Tried to use sync method {method}"); } diff --git a/Catalogger.Backend/Database/DatabaseMigrator.cs b/Catalogger.Backend/Database/DatabaseMigrator.cs index 183723d..76a0dbc 100644 --- a/Catalogger.Backend/Database/DatabaseMigrator.cs +++ b/Catalogger.Backend/Database/DatabaseMigrator.cs @@ -19,9 +19,6 @@ using NodaTime; namespace Catalogger.Backend.Database; -/// -/// Manages database migrations. -/// public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection conn) : IDisposable, IAsyncDisposable @@ -29,10 +26,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c private const string RootPath = "Catalogger.Backend.Database"; private static readonly int MigrationsPathLength = $"{RootPath}.Migrations.".Length; - /// - /// Migrates the database to the latest version. - /// - public async Task MigrateUp() + public async Task Migrate() { var migrations = GetMigrationNames().ToArray(); logger.Debug("Getting current database migration"); @@ -71,62 +65,17 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c await tx.CommitAsync(); } - /// - /// Migrates the database to a previous version. - /// - /// The number of migrations to revert. If higher than the number of applied migrations, - /// reverts the database to a clean slate. - public async Task MigrateDown(int count = 1) + private async Task ExecuteMigration(DbTransaction tx, string migrationName) { - await using var tx = await conn.BeginTransactionAsync(); - - var migrationCount = 0; - var totalStartTime = clock.GetCurrentInstant(); - for (var i = count; i > 0; i--) - { - var migration = await GetCurrentMigration(); - if (migration == null) - { - logger.Information( - "More down migrations requested than were in the database, finishing early" - ); - break; - } - - logger.Debug("Reverting migration {Migration}", migration); - var startTime = clock.GetCurrentInstant(); - await ExecuteMigration(tx, migration.MigrationName, up: false); - var took = clock.GetCurrentInstant() - startTime; - logger.Debug("Reverted migration {Migration} in {Took}", migration, took); - migrationCount++; - } - - var totalTook = clock.GetCurrentInstant() - totalStartTime; - logger.Information("Reverted {Count} migrations in {Took}", migrationCount, totalTook); - - // Finally, commit the transaction - await tx.CommitAsync(); - } - - private async Task ExecuteMigration(DbTransaction tx, string migrationName, bool up = true) - { - var query = await GetResource( - $"{RootPath}.Migrations.{migrationName}.{(up ? "up" : "down")}.sql" - ); + var query = await GetResource($"{RootPath}.Migrations.{migrationName}.up.sql"); // Run the migration await conn.ExecuteAsync(query, transaction: tx); - // Store that we ran the migration (or reverted it) - if (up) - await conn.ExecuteAsync( - "INSERT INTO migrations (migration_name, applied_at) VALUES (@MigrationName, now())", - new { MigrationName = migrationName } - ); - else - await conn.ExecuteAsync( - "DELETE FROM migrations WHERE migration_name = @MigrationName", - new { MigrationName = migrationName } - ); + // Store that we ran the migration + await conn.ExecuteAsync( + "INSERT INTO migrations (migration_name, applied_at) VALUES (@MigrationName, @AppliedAt)", + new { MigrationName = migrationName, AppliedAt = clock.GetCurrentInstant() } + ); } /// Returns the current migration. If no migrations have been applied, returns null @@ -141,7 +90,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c if (hasMigrationTable) { return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM migrations ORDER BY applied_at DESC, migration_name DESC LIMIT 1" + "SELECT * FROM migrations ORDER BY applied_at DESC LIMIT 1" ); } @@ -163,7 +112,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c return await reader.ReadToEndAsync(); } - private static IEnumerable GetMigrationNames() => + public static IEnumerable GetMigrationNames() => typeof(DatabasePool) .Assembly.GetManifestResourceNames() .Where(s => s.StartsWith($"{RootPath}.Migrations")) diff --git a/Catalogger.Backend/Database/DatabasePool.cs b/Catalogger.Backend/Database/DatabasePool.cs index 29a845e..82072f8 100644 --- a/Catalogger.Backend/Database/DatabasePool.cs +++ b/Catalogger.Backend/Database/DatabasePool.cs @@ -24,13 +24,18 @@ namespace Catalogger.Backend.Database; public class DatabasePool { + private readonly ILogger _rootLogger; + private readonly ILogger _logger; private readonly NpgsqlDataSource _dataSource; private static int _openConnections; public static int OpenConnections => _openConnections; - public DatabasePool(Config config, ILoggerFactory? loggerFactory) + public DatabasePool(Config config, ILogger logger, ILoggerFactory? loggerFactory) { + _rootLogger = logger; + _logger = logger.ForContext(); + var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { Timeout = config.Database.Timeout ?? 5, @@ -46,14 +51,24 @@ public class DatabasePool public async Task AcquireAsync(CancellationToken ct = default) { - IncrementConnections(); - return new DatabaseConnection(await _dataSource.OpenConnectionAsync(ct)); + return new DatabaseConnection( + LogOpen(), + _rootLogger, + await _dataSource.OpenConnectionAsync(ct) + ); } public DatabaseConnection Acquire() { + return new DatabaseConnection(LogOpen(), _rootLogger, _dataSource.OpenConnection()); + } + + private Guid LogOpen() + { + var connId = Guid.NewGuid(); + _logger.Verbose("Opening database connection {ConnId}", connId); IncrementConnections(); - return new DatabaseConnection(_dataSource.OpenConnection()); + return connId; } public async Task ExecuteAsync( @@ -97,12 +112,10 @@ public class DatabasePool SqlMapper.RemoveTypeMap(typeof(ulong)); SqlMapper.AddTypeHandler(new UlongEncodeAsLongHandler()); + SqlMapper.AddTypeHandler(new UlongArrayHandler()); SqlMapper.AddTypeHandler(new PassthroughTypeHandler()); SqlMapper.AddTypeHandler(new JsonTypeHandler()); - SqlMapper.AddTypeHandler(new JsonTypeHandler()); - - SqlMapper.AddTypeHandler(new UlongListHandler()); } // Copied from PluralKit: @@ -118,34 +131,36 @@ public class DatabasePool private class UlongEncodeAsLongHandler : SqlMapper.TypeHandler { - public override void SetValue(IDbDataParameter parameter, ulong value) => - parameter.Value = (long)value; - public override ulong Parse(object value) => // Cast to long to unbox, then to ulong (???) (ulong)(long)value; + + public override void SetValue(IDbDataParameter parameter, ulong value) => + parameter.Value = (long)value; } - private class UlongListHandler : SqlMapper.TypeHandler> + private class UlongArrayHandler : SqlMapper.TypeHandler { - public override void SetValue(IDbDataParameter parameter, List? value) => - parameter.Value = value?.Select(i => (long)i).ToArray(); + public override void SetValue(IDbDataParameter parameter, ulong[]? value) => + parameter.Value = value != null ? Array.ConvertAll(value, i => (long)i) : null; - public override List? Parse(object value) => - ((long[])value).Select(i => (ulong)i).ToList(); + public override ulong[] Parse(object value) => + Array.ConvertAll((long[])value, i => (ulong)i); } - private class JsonTypeHandler : SqlMapper.TypeHandler + public class JsonTypeHandler : SqlMapper.TypeHandler { - public override void SetValue(IDbDataParameter parameter, T? value) => - parameter.Value = JsonSerializer.Serialize(value); - public override T Parse(object value) { - var json = (string)value; + string json = (string)value; return JsonSerializer.Deserialize(json) ?? throw new CataloggerError("JsonTypeHandler returned null"); } + + public override void SetValue(IDbDataParameter parameter, T? value) + { + parameter.Value = JsonSerializer.Serialize(value); + } } } diff --git a/Catalogger.Backend/Database/Migrations/002_store_pk_systems.down.sql b/Catalogger.Backend/Database/Migrations/002_store_pk_systems.down.sql deleted file mode 100644 index c411602..0000000 --- a/Catalogger.Backend/Database/Migrations/002_store_pk_systems.down.sql +++ /dev/null @@ -1 +0,0 @@ -drop table pluralkit_systems; diff --git a/Catalogger.Backend/Database/Migrations/002_store_pk_systems.up.sql b/Catalogger.Backend/Database/Migrations/002_store_pk_systems.up.sql deleted file mode 100644 index d095e36..0000000 --- a/Catalogger.Backend/Database/Migrations/002_store_pk_systems.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table pluralkit_systems ( - system_id uuid not null, - user_id bigint not null, - guild_id bigint not null, - - primary key (system_id, user_id, guild_id) -); - -create index ix_pluralkit_systems_user_guild on pluralkit_systems (user_id, guild_id); diff --git a/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql b/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql deleted file mode 100644 index d5f53c0..0000000 --- a/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql +++ /dev/null @@ -1 +0,0 @@ -drop table timeouts; \ No newline at end of file diff --git a/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql b/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql deleted file mode 100644 index 41b4dd5..0000000 --- a/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -create table timeouts ( - id integer generated by default as identity primary key, - user_id bigint not null, - guild_id bigint not null, - moderator_id bigint, - until timestamptz not null -); - -create unique index ix_timeouts_user_guild on timeouts (user_id, guild_id); diff --git a/Catalogger.Backend/Database/Migrations/004_split_message_config.down.sql b/Catalogger.Backend/Database/Migrations/004_split_message_config.down.sql deleted file mode 100644 index a9f1de1..0000000 --- a/Catalogger.Backend/Database/Migrations/004_split_message_config.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -update guilds set channels = (channels || messages) - 'IgnoredRoles'; - -alter table guilds drop column messages; diff --git a/Catalogger.Backend/Database/Migrations/004_split_message_config.up.sql b/Catalogger.Backend/Database/Migrations/004_split_message_config.up.sql deleted file mode 100644 index 6cd35f0..0000000 --- a/Catalogger.Backend/Database/Migrations/004_split_message_config.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -alter table guilds - add column messages jsonb not null default '{}'; - --- Extract the current message-related configuration options into the new "messages" column --- noinspection SqlWithoutWhere -update guilds -set messages = jsonb_build_object('IgnoredUsers', channels['IgnoredUsers'], 'IgnoredChannels', - channels['IgnoredChannels'], 'IgnoredUsersPerChannel', - channels['IgnoredUsersPerChannel']); - --- We don't update the "channels" column as it will be cleared out automatically over time, --- as channel configurations are updated by the bot diff --git a/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.down.sql b/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.down.sql deleted file mode 100644 index 30f833d..0000000 --- a/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table guilds drop column ignored_channels; -alter table guilds drop column ignored_roles; diff --git a/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.up.sql b/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.up.sql deleted file mode 100644 index 8d39d94..0000000 --- a/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table guilds add column ignored_channels bigint[] not null default array[]::bigint[]; -alter table guilds add column ignored_roles bigint[] not null default array[]::bigint[]; diff --git a/Catalogger.Backend/Database/Models/ConfigExport.cs b/Catalogger.Backend/Database/Models/ConfigExport.cs deleted file mode 100644 index dfbe95c..0000000 --- a/Catalogger.Backend/Database/Models/ConfigExport.cs +++ /dev/null @@ -1,120 +0,0 @@ -using NodaTime; - -namespace Catalogger.Backend.Database.Models; - -public record ConfigExport( - ulong Id, - ChannelsBackup Channels, - string[] BannedSystems, - List KeyRoles, - IEnumerable Invites, - IEnumerable Watchlist -); - -public record InviteExport(string Code, string Name); - -public record WatchlistExport(ulong UserId, Instant AddedAt, ulong ModeratorId, string Reason); - -public class ChannelsBackup -{ - public List IgnoredChannels { get; init; } = []; - public List IgnoredUsers { get; init; } = []; - public List IgnoredRoles { get; init; } = []; - public Dictionary> IgnoredUsersPerChannel { get; init; } = []; - public Dictionary Redirects { get; init; } = []; - - public ulong GuildUpdate { get; init; } - public ulong GuildEmojisUpdate { get; init; } - public ulong GuildRoleCreate { get; init; } - public ulong GuildRoleUpdate { get; init; } - public ulong GuildRoleDelete { get; init; } - public ulong ChannelCreate { get; init; } - public ulong ChannelUpdate { get; init; } - public ulong ChannelDelete { get; init; } - public ulong GuildMemberAdd { get; init; } - public ulong GuildMemberUpdate { get; init; } - public ulong GuildKeyRoleUpdate { get; init; } - public ulong GuildMemberNickUpdate { get; init; } - public ulong GuildMemberAvatarUpdate { get; init; } - public ulong GuildMemberTimeout { get; init; } - public ulong GuildMemberRemove { get; init; } - public ulong GuildMemberKick { get; init; } - public ulong GuildBanAdd { get; init; } - public ulong GuildBanRemove { get; init; } - public ulong InviteCreate { get; init; } - public ulong InviteDelete { get; init; } - public ulong MessageUpdate { get; init; } - public ulong MessageDelete { get; init; } - public ulong MessageDeleteBulk { get; init; } - - public Guild.MessageConfig ToMessageConfig() => - new() - { - IgnoredChannels = IgnoredChannels, - IgnoredUsers = IgnoredUsers, - IgnoredRoles = IgnoredRoles, - IgnoredUsersPerChannel = IgnoredUsersPerChannel, - }; - - public Guild.ChannelConfig ToChannelConfig() => - new() - { - Redirects = Redirects, - GuildUpdate = GuildUpdate, - GuildEmojisUpdate = GuildEmojisUpdate, - GuildRoleCreate = GuildRoleCreate, - GuildRoleUpdate = GuildRoleUpdate, - GuildRoleDelete = GuildRoleDelete, - ChannelCreate = ChannelCreate, - ChannelUpdate = ChannelUpdate, - ChannelDelete = ChannelDelete, - GuildMemberAdd = GuildMemberAdd, - GuildMemberUpdate = GuildMemberUpdate, - GuildKeyRoleUpdate = GuildKeyRoleUpdate, - GuildMemberNickUpdate = GuildMemberNickUpdate, - GuildMemberAvatarUpdate = GuildMemberAvatarUpdate, - GuildMemberTimeout = GuildMemberTimeout, - GuildMemberRemove = GuildMemberRemove, - GuildMemberKick = GuildMemberKick, - GuildBanAdd = GuildBanAdd, - GuildBanRemove = GuildBanRemove, - InviteCreate = InviteCreate, - InviteDelete = InviteDelete, - MessageUpdate = MessageUpdate, - MessageDelete = MessageDelete, - MessageDeleteBulk = MessageDeleteBulk, - }; - - public static ChannelsBackup FromGuildConfig(Guild guild) => - new() - { - IgnoredChannels = guild.Messages.IgnoredChannels, - IgnoredUsers = guild.Messages.IgnoredUsers, - IgnoredRoles = guild.Messages.IgnoredRoles, - IgnoredUsersPerChannel = guild.Messages.IgnoredUsersPerChannel, - Redirects = guild.Channels.Redirects, - GuildUpdate = guild.Channels.GuildUpdate, - GuildEmojisUpdate = guild.Channels.GuildEmojisUpdate, - GuildRoleCreate = guild.Channels.GuildRoleCreate, - GuildRoleUpdate = guild.Channels.GuildRoleUpdate, - GuildRoleDelete = guild.Channels.GuildRoleDelete, - ChannelCreate = guild.Channels.ChannelCreate, - ChannelUpdate = guild.Channels.ChannelUpdate, - ChannelDelete = guild.Channels.ChannelDelete, - GuildMemberAdd = guild.Channels.GuildMemberAdd, - GuildMemberUpdate = guild.Channels.GuildMemberUpdate, - GuildKeyRoleUpdate = guild.Channels.GuildKeyRoleUpdate, - GuildMemberNickUpdate = guild.Channels.GuildMemberNickUpdate, - GuildMemberAvatarUpdate = guild.Channels.GuildMemberAvatarUpdate, - GuildMemberTimeout = guild.Channels.GuildMemberTimeout, - GuildMemberRemove = guild.Channels.GuildMemberRemove, - GuildMemberKick = guild.Channels.GuildMemberKick, - GuildBanAdd = guild.Channels.GuildBanAdd, - GuildBanRemove = guild.Channels.GuildBanRemove, - InviteCreate = guild.Channels.InviteCreate, - InviteDelete = guild.Channels.InviteDelete, - MessageUpdate = guild.Channels.MessageUpdate, - MessageDelete = guild.Channels.MessageDelete, - MessageDeleteBulk = guild.Channels.MessageDeleteBulk, - }; -} diff --git a/Catalogger.Backend/Database/Models/DiscordTimeout.cs b/Catalogger.Backend/Database/Models/DiscordTimeout.cs deleted file mode 100644 index 21ed5c5..0000000 --- a/Catalogger.Backend/Database/Models/DiscordTimeout.cs +++ /dev/null @@ -1,12 +0,0 @@ -using NodaTime; - -namespace Catalogger.Backend.Database.Models; - -public class DiscordTimeout -{ - public int Id { get; init; } - public ulong UserId { get; init; } - public ulong GuildId { get; init; } - public ulong? ModeratorId { get; init; } - public Instant Until { get; init; } -} diff --git a/Catalogger.Backend/Database/Models/Guild.cs b/Catalogger.Backend/Database/Models/Guild.cs index 0558da2..9ac8042 100644 --- a/Catalogger.Backend/Database/Models/Guild.cs +++ b/Catalogger.Backend/Database/Models/Guild.cs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using System.ComponentModel.DataAnnotations.Schema; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; using Remora.Rest.Core; @@ -21,31 +22,23 @@ namespace Catalogger.Backend.Database.Models; public class Guild { + [DatabaseGenerated(DatabaseGeneratedOption.None)] public required ulong Id { get; init; } + [Column(TypeName = "jsonb")] public ChannelConfig Channels { get; init; } = new(); - public MessageConfig Messages { get; init; } = new(); public string[] BannedSystems { get; set; } = []; - public List KeyRoles { get; set; } = []; - - // These channels and roles are ignored for channel/role update/delete events. - public List IgnoredChannels { get; set; } = []; - public List IgnoredRoles { get; set; } = []; + public ulong[] KeyRoles { get; set; } = []; public bool IsSystemBanned(PluralkitApiService.PkSystem system) => BannedSystems.Contains(system.Id) || BannedSystems.Contains(system.Uuid.ToString()); - public bool IsMessageIgnored( - Snowflake channelId, - Snowflake? userId, - IReadOnlyList? roleIds - ) + public bool IsMessageIgnored(Snowflake channelId, Snowflake? userId) { if ( Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 } - || Messages.IgnoredChannels.Contains(channelId.ToUlong()) - || (userId != null && Messages.IgnoredUsers.Contains(userId.Value.ToUlong())) - || (roleIds != null && roleIds.Any(r => Messages.IgnoredRoles.Any(id => r.Value == id))) + || Channels.IgnoredChannels.Contains(channelId.ToUlong()) + || (userId != null && Channels.IgnoredUsers.Contains(userId.Value.ToUlong())) ) return true; @@ -53,7 +46,7 @@ public class Guild return false; if ( - Messages.IgnoredUsersPerChannel.TryGetValue( + Channels.IgnoredUsersPerChannel.TryGetValue( channelId.ToUlong(), out var thisChannelIgnoredUsers ) @@ -63,16 +56,11 @@ public class Guild return false; } - public class MessageConfig - { - public List IgnoredChannels { get; set; } = []; - public List IgnoredRoles { get; set; } = []; - public List IgnoredUsers { get; init; } = []; - public Dictionary> IgnoredUsersPerChannel { get; init; } = []; - } - public class ChannelConfig { + public List IgnoredChannels { get; set; } = []; + public List IgnoredUsers { get; init; } = []; + public Dictionary> IgnoredUsersPerChannel { get; init; } = []; public Dictionary Redirects { get; init; } = []; public ulong GuildUpdate { get; set; } @@ -98,35 +86,5 @@ public class Guild public ulong MessageUpdate { get; set; } public ulong MessageDelete { get; set; } public ulong MessageDeleteBulk { get; set; } - - private ulong[] _allUnfilteredChannels => - [ - GuildUpdate, - GuildEmojisUpdate, - GuildRoleCreate, - GuildRoleUpdate, - GuildRoleDelete, - ChannelCreate, - ChannelUpdate, - ChannelDelete, - GuildMemberAdd, - GuildMemberUpdate, - GuildKeyRoleUpdate, - GuildMemberNickUpdate, - GuildMemberAvatarUpdate, - GuildMemberTimeout, - GuildMemberRemove, - GuildMemberKick, - GuildBanAdd, - GuildBanRemove, - InviteCreate, - InviteDelete, - MessageUpdate, - MessageDelete, - MessageDeleteBulk, - .. Redirects.Values, - ]; - - public ulong[] AllChannels => _allUnfilteredChannels.Where(c => c != 0).ToArray(); } } diff --git a/Catalogger.Backend/Database/Models/Message.cs b/Catalogger.Backend/Database/Models/Message.cs index 81e94b1..2bbb658 100644 --- a/Catalogger.Backend/Database/Models/Message.cs +++ b/Catalogger.Backend/Database/Models/Message.cs @@ -13,10 +13,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using System.ComponentModel.DataAnnotations.Schema; + namespace Catalogger.Backend.Database.Models; public class Message { + [DatabaseGenerated(DatabaseGeneratedOption.None)] public required ulong Id { get; init; } public ulong? OriginalId { get; set; } @@ -35,3 +38,5 @@ public class Message public int AttachmentSize { get; set; } = 0; } + +public record IgnoredMessage([property: DatabaseGenerated(DatabaseGeneratedOption.None)] ulong Id); diff --git a/Catalogger.Backend/Database/Redis/RedisService.cs b/Catalogger.Backend/Database/Redis/RedisService.cs index e8e474c..1ed24af 100644 --- a/Catalogger.Backend/Database/Redis/RedisService.cs +++ b/Catalogger.Backend/Database/Redis/RedisService.cs @@ -24,10 +24,8 @@ public class RedisService(Config config) config.Database.Redis! ); - private readonly JsonSerializerOptions _options = new() - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - }; + private readonly JsonSerializerOptions _options = + new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db); @@ -46,9 +44,6 @@ public class RedisService(Config config) await GetDatabase().StringSetAsync(key, json, expiry); } - public async Task DeleteAsync(string[] keys) => - await GetDatabase().KeyDeleteAsync(keys.Select(k => new RedisKey(k)).ToArray()); - public async Task GetAsync(string key) { var value = await GetDatabase().StringGetAsync(key); diff --git a/Catalogger.Backend/Database/Repositories/GuildRepository.cs b/Catalogger.Backend/Database/Repositories/GuildRepository.cs index 71502e2..de62b1d 100644 --- a/Catalogger.Backend/Database/Repositories/GuildRepository.cs +++ b/Catalogger.Backend/Database/Repositories/GuildRepository.cs @@ -15,7 +15,6 @@ using Catalogger.Backend.Database.Models; using Dapper; -using Remora.Discord.API; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Repositories; @@ -32,7 +31,7 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) public async Task GetAsync(ulong id) { - _logger.Verbose("Getting guild config for {GuildId}", id); + _logger.Debug("Getting guild config for {GuildId}", id); var guild = await conn.QueryFirstOrDefaultAsync( "select * from guilds where id = @Id", @@ -52,37 +51,20 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) public async Task AddGuildAsync(ulong id) => await conn.ExecuteAsync( """ - insert into guilds (id, key_roles, banned_systems, channels) - values (@Id, array[]::bigint[], array[]::text[], @Channels::jsonb) + insert into guilds (id, key_roles, banned_systems, key_roles, channels) + values (@Id, array[]::bigint[], array[]::text[], array[]::bigint[], @Channels) on conflict do nothing """, new { Id = id, Channels = new Guild.ChannelConfig() } ); - public async Task BanSystemAsync(Snowflake guildId, Snowflake userId, string hid, Guid uuid) - { + public async Task BanSystemAsync(Snowflake guildId, string hid, Guid uuid) => await conn.ExecuteAsync( "update guilds set banned_systems = array_cat(banned_systems, @SystemIds) where id = @GuildId", new { GuildId = guildId.Value, SystemIds = (string[])[hid, uuid.ToString()] } ); - await conn.ExecuteAsync( - """ - insert into pluralkit_systems (system_id, user_id, guild_id) - values (@SystemId, @UserId, @GuildId) - on conflict (system_id, user_id, guild_id) do nothing - """, - new - { - SystemId = uuid, - UserId = userId.Value, - GuildId = guildId.Value, - } - ); - } - - public async Task UnbanSystemAsync(Snowflake guildId, Snowflake userId, string hid, Guid uuid) - { + public async Task UnbanSystemAsync(Snowflake guildId, string hid, Guid uuid) => await conn.ExecuteAsync( "update guilds set banned_systems = array_remove(array_remove(banned_systems, @Hid), @Uuid) where id = @Id", new @@ -93,70 +75,22 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) } ); + public async Task AddKeyRoleAsync(Snowflake guildId, Snowflake roleId) => await conn.ExecuteAsync( - """ - delete from pluralkit_systems where system_id = @SystemId - and user_id = @UserId - and guild_id = @GuildId - """, - new - { - SystemId = uuid, - UserId = userId.Value, - GuildId = guildId.Value, - } - ); - } - - public async Task GetSystemAccountsAsync(Snowflake guildId, Guid systemId) - { - var bannedAccounts = await conn.QueryAsync( - "select * from pluralkit_systems where system_id = @SystemId and guild_id = @GuildId", - new { SystemId = systemId, GuildId = guildId.Value } - ); - return bannedAccounts.Select(s => DiscordSnowflake.New(s.UserId)).ToArray(); - } - - private record BannedSystem(Guid SystemId, ulong UserId, ulong GuildId); - - public async Task UpdateConfigAsync(Snowflake id, Guild config) => - await conn.ExecuteAsync( - """ - update guilds set channels = @Channels::jsonb, - messages = @Messages::jsonb, - ignored_channels = @IgnoredChannels, - ignored_roles = @IgnoredRoles, - key_roles = @KeyRoles - where id = @Id - """, - new - { - Id = id.Value, - config.Channels, - config.Messages, - config.IgnoredChannels, - config.IgnoredRoles, - config.KeyRoles, - } + "update guilds set key_roles = array_append(key_roles, @RoleId) where id = @GuildId", + new { GuildId = guildId.Value, RoleId = roleId.Value } ); - public async Task ImportConfigAsync( - ulong id, - Guild.ChannelConfig channels, - Guild.MessageConfig messages, - string[] bannedSystems, - List keyRoles - ) => + public async Task RemoveKeyRoleAsync(Snowflake guildId, Snowflake roleId) => await conn.ExecuteAsync( - "update guilds set channels = @channels::jsonb, messages = @messages::jsonb, banned_systems = @bannedSystems, key_roles = @keyRoles where id = @id", - new - { - id, - channels, - messages, - bannedSystems, - keyRoles, - } + "update guilds set key_roles = array_remove(key_roles, @RoleId) where id = @GuildId", + new { GuildId = guildId.Value, RoleId = roleId.Value } + ); + + public async Task UpdateChannelConfigAsync(Snowflake id, Guild.ChannelConfig config) => + await conn.ExecuteAsync( + "update guilds set channels = @Channels::jsonb where id = @Id", + new { Id = id.Value, Channels = config } ); public void Dispose() diff --git a/Catalogger.Backend/Database/Repositories/InviteRepository.cs b/Catalogger.Backend/Database/Repositories/InviteRepository.cs index 914588f..9ea7382 100644 --- a/Catalogger.Backend/Database/Repositories/InviteRepository.cs +++ b/Catalogger.Backend/Database/Repositories/InviteRepository.cs @@ -37,7 +37,7 @@ public class InviteRepository(ILogger logger, DatabaseConnection conn) await conn.ExecuteAsync( """ insert into invites (code, guild_id, name) values - (@Code, @GuildId, @Name) on conflict (code) do update set name = @Name + (@Code, @GuildId, @Name) on conflict (code, guild_id) do update set name = @Name """, new { @@ -65,34 +65,6 @@ public class InviteRepository(ILogger logger, DatabaseConnection conn) new { GuildId = guildId.Value, Code = code } ); - /// - /// Bulk imports an array of invite codes and names. - /// The GuildId property in the Invite object is ignored. - /// - public async Task ImportInvitesAsync(Snowflake guildId, IEnumerable invites) - { - await using var tx = await conn.BeginTransactionAsync(); - foreach (var invite in invites) - { - await conn.ExecuteAsync( - """ - insert into invites (code, guild_id, name) - values (@Code, @GuildId, @Name) on conflict (code) - do update set name = @Name - """, - new - { - GuildId = guildId.Value, - invite.Code, - invite.Name, - }, - transaction: tx - ); - } - - await tx.CommitAsync(); - } - public void Dispose() { conn.Dispose(); diff --git a/Catalogger.Backend/Database/Repositories/MessageRepository.cs b/Catalogger.Backend/Database/Repositories/MessageRepository.cs index fc45e4f..17925c3 100644 --- a/Catalogger.Backend/Database/Repositories/MessageRepository.cs +++ b/Catalogger.Backend/Database/Repositories/MessageRepository.cs @@ -18,7 +18,6 @@ using Catalogger.Backend.Extensions; using Dapper; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Objects; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Repositories; @@ -64,11 +63,7 @@ public class MessageRepository( /// /// Adds a new message. If the message is already in the database, updates the existing message instead. /// - public async Task SaveMessageAsync( - IMessage msg, - Optional guildId, - CancellationToken ct = default - ) + public async Task SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) { var content = await Task.Run( () => @@ -112,9 +107,7 @@ public class MessageRepository( Id = msg.ID.Value, UserId = msg.Author.ID.Value, ChannelId = msg.ChannelID.Value, - GuildId = guildId.IsDefined(out var guildIdValue) - ? guildIdValue.Value - : (ulong?)null, + GuildId = msg.GuildID.Map(s => s.Value).OrDefault(), Content = content, Username = username, Metadata = metadata, diff --git a/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs b/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs deleted file mode 100644 index f50a177..0000000 --- a/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Catalogger.Backend.Database.Models; -using Dapper; -using NodaTime; -using Remora.Rest.Core; - -namespace Catalogger.Backend.Database.Repositories; - -public class TimeoutRepository(DatabaseConnection conn, IClock clock) - : IDisposable, - IAsyncDisposable -{ - public async Task GetAsync(int id) => - await conn.QueryFirstOrDefaultAsync( - "select * from timeouts where id = @id", - new { id } - ); - - public async Task GetAsync(Snowflake guildId, Snowflake userId) => - await conn.QueryFirstOrDefaultAsync( - "select * from timeouts where guild_id = @GuildId and user_id = @UserId", - new { GuildId = guildId.Value, UserId = userId.Value } - ); - - public async Task> GetAllAsync() => - ( - await conn.QueryAsync( - "select * from timeouts where until > now() order by id" - ) - ).ToList(); - - public async Task SetAsync( - Snowflake guildId, - Snowflake userId, - Instant until, - Snowflake? moderatorId - ) => - await conn.QueryFirstAsync( - """ - insert into timeouts (user_id, guild_id, moderator_id, until) - values (@UserId, @GuildId, @ModeratorId, @Until) - on conflict (user_id, guild_id) do update - set moderator_id = @ModeratorId, - until = @Until - returning * - """, - new - { - UserId = userId.Value, - GuildId = guildId.Value, - ModeratorId = moderatorId?.Value, - Until = until, - } - ); - - public async Task RemoveAsync(int id) => - await conn.QueryFirstOrDefaultAsync( - "delete from timeouts where id = @id returning *", - new { id } - ); - - public async Task RemoveAsync(Snowflake guildId, Snowflake userId) => - await conn.QueryFirstOrDefaultAsync( - "delete from timeouts where guild_id = @GuildId and user_id = @UserId returning *", - new { GuildId = guildId.Value, UserId = userId.Value } - ); - - public async Task RemoveExpiredTimeoutsAsync() => - await conn.ExecuteAsync( - "delete from timeouts where until < @Expiry", - new { Expiry = clock.GetCurrentInstant() - Duration.FromMinutes(5) } - ); - - public void Dispose() - { - conn.Dispose(); - GC.SuppressFinalize(this); - } - - public async ValueTask DisposeAsync() - { - await conn.DisposeAsync(); - GC.SuppressFinalize(this); - } -} diff --git a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs index 653fd85..8177f78 100644 --- a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs +++ b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs @@ -33,76 +33,12 @@ public class WatchlistRepository(ILogger logger, DatabaseConnection conn) ) ).ToList(); - public async Task GetEntryAsync(Snowflake guildId, Snowflake userId) => + public async Task GetWatchlistEntryAsync(Snowflake guildId, Snowflake userId) => await conn.QueryFirstOrDefaultAsync( "select * from watchlists where guild_id = @GuildId and user_id = @UserId", new { GuildId = guildId.Value, UserId = userId.Value } ); - public async Task CreateEntryAsync( - Snowflake guildId, - Snowflake userId, - Snowflake moderatorId, - string reason - ) => - await conn.QueryFirstAsync( - """ - insert into watchlists (guild_id, user_id, added_at, moderator_id, reason) - values (@GuildId, @UserId, now(), @ModeratorId, @Reason) - on conflict (guild_id, user_id) do update - set moderator_id = @ModeratorId, added_at = now(), reason = @Reason - returning * - """, - new - { - GuildId = guildId.Value, - UserId = userId.Value, - ModeratorId = moderatorId.Value, - Reason = reason, - } - ); - - public async Task RemoveEntryAsync(Snowflake guildId, Snowflake userId) => - ( - await conn.ExecuteAsync( - "delete from watchlists where guild_id = @GuildId and user_id = @UserId", - new { GuildId = guildId.Value, UserId = userId.Value } - ) - ) != 0; - - /// - /// Bulk imports an array of watchlist entries. - /// The GuildId property in the Watchlist object is ignored. - /// - public async Task ImportWatchlistAsync(Snowflake guildId, IEnumerable watchlist) - { - await using var tx = await conn.BeginTransactionAsync(); - foreach (var entry in watchlist) - { - await conn.ExecuteAsync( - """ - insert into watchlists (guild_id, user_id, added_at, moderator_id, reason) - values (@GuildId, @UserId, @AddedAt, @ModeratorId, @Reason) - on conflict (guild_id, user_id) do update - set added_at = @AddedAt, - moderator_id = @ModeratorId, - reason = @Reason - """, - new - { - GuildId = guildId.Value, - entry.UserId, - entry.AddedAt, - entry.ModeratorId, - entry.Reason, - }, - transaction: tx - ); - } - - await tx.CommitAsync(); - } - public void Dispose() { conn.Dispose(); diff --git a/Catalogger.Backend/Extensions/LogUtils.cs b/Catalogger.Backend/Extensions/LogUtils.cs deleted file mode 100644 index 330e5f7..0000000 --- a/Catalogger.Backend/Extensions/LogUtils.cs +++ /dev/null @@ -1,152 +0,0 @@ -using Remora.Discord.API.Abstractions.Gateway.Events; -using Remora.Discord.API.Abstractions.Objects; -using Remora.Rest.Core; -using Serilog.Context; - -namespace Catalogger.Backend.Extensions; - -public static class LogUtils -{ - public static IDisposable Enrich(T evt) - where T : IGatewayEvent - { - var type = ("Event", typeof(T).Name); - - return evt switch - { - IMessageDelete md => PushProperties( - type, - ("GuildId", md.GuildID), - ("ChannelId", md.ChannelID), - ("MessageId", md.ID) - ), - IMessageUpdate mu => PushProperties( - type, - ("GuildId", mu.GuildID), - ("ChannelId", mu.ChannelID), - ("MessageId", mu.ID) - ), - IMessageCreate mc => PushProperties( - type, - ("GuildId", mc.GuildID), - ("ChannelId", mc.ChannelID), - ("MessageId", mc.ID) - ), - IMessageDeleteBulk mdb => PushProperties( - type, - ("GuildId", mdb.GuildID), - ("ChannelId", mdb.ChannelID), - ("MessageIds", mdb.IDs) - ), - IGuildRoleCreate grc => PushProperties( - type, - ("GuildId", grc.GuildID), - ("RoleId", grc.Role.ID) - ), - IGuildRoleUpdate gru => PushProperties( - type, - ("GuildId", gru.GuildID), - ("RoleId", gru.Role.ID) - ), - IGuildRoleDelete grd => PushProperties( - type, - ("GuildId", grd.GuildID), - ("RoleId", grd.RoleID) - ), - IGuildMemberAdd gma => PushProperties( - type, - ("GuildId", gma.GuildID), - ("UserId", gma.User.Map(u => u.ID)) - ), - IGuildMemberUpdate gmu => PushProperties( - type, - ("GuildId", gmu.GuildID), - ("UserId", gmu.User.ID) - ), - IGuildMemberRemove gmr => PushProperties( - type, - ("GuildId", gmr.GuildID), - ("UserId", gmr.User.ID) - ), - IInviteCreate ic => PushProperties( - type, - ("GuildId", ic.GuildID), - ("ChannelId", ic.ChannelID), - ("InviteCode", ic.Code) - ), - IInviteDelete id => PushProperties( - type, - ("GuildId", id.GuildID), - ("ChannelId", id.ChannelID), - ("Code", id.Code) - ), - IChannelCreate cc => PushProperties( - type, - ("GuildId", cc.GuildID), - ("ChannelId", cc.ID) - ), - IChannelUpdate cu => PushProperties( - type, - ("GuildId", cu.GuildID), - ("ChannelId", cu.ID) - ), - IChannelDelete cd => PushProperties( - type, - ("GuildId", cd.GuildID), - ("ChannelId", cd.ID) - ), - IGuildAuditLogEntryCreate ale => PushProperties( - type, - ("GuildId", ale.GuildID), - ("AuditLogEntryId", ale.ID), - ("ActionType", ale.ActionType) - ), - IGuildBanAdd gba => PushProperties( - type, - ("GuildId", gba.GuildID), - ("UserId", gba.User.ID) - ), - IGuildBanRemove gbr => PushProperties( - type, - ("GuildId", gbr.GuildID), - ("UserId", gbr.User.ID) - ), - IGuildCreate gc => PushProperties( - type, - ("GuildId", gc.Guild.Match(g => g.ID, g => g.ID)) - ), - IGuildDelete gd => PushProperties(type, ("GuildId", gd.ID)), - IGuildEmojisUpdate geu => PushProperties(type, ("GuildId", geu.GuildID)), - IGuildMembersChunk gmc => PushProperties( - type, - ("GuildId", gmc.GuildID), - ("MemberCount", gmc.Members.Count), - ("ChunkIndex", gmc.ChunkIndex), - ("ChunkCount", gmc.ChunkCount) - ), - IGuildUpdate gu => PushProperties(type, ("GuildId", gu.ID)), - _ => PushProperties(type), - }; - } - - public static IDisposable PushProperties(params (string, object?)[] properties) => - new MultiDisposable( - properties - .Select(p => - { - if (p.Item2 is Optional s) - return LogContext.PushProperty(p.Item1, s.IsDefined() ? s.Value : null); - return LogContext.PushProperty(p.Item1, p.Item2); - }) - .ToArray() - ); - - private record MultiDisposable(IDisposable[] Entries) : IDisposable - { - public void Dispose() - { - foreach (var e in Entries) - e.Dispose(); - } - } -} diff --git a/Catalogger.Backend/Extensions/StartupExtensions.cs b/Catalogger.Backend/Extensions/StartupExtensions.cs index f9a226c..176d821 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -51,22 +51,19 @@ public static class StartupExtensions { var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Verbose() - // Most Microsoft.* package logs are needlessly verbose, so we restrict them to INFO level and up - .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Is(config.Logging.LogEventLevel) // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // Serilog doesn't disable the built-in logs, so we do it here. + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override( + "Microsoft.EntityFrameworkCore.Database.Command", + config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal + ) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) - // Let's not put webhook tokens and even *full bot tokens* in the logs, thank you - .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning) // The default theme doesn't support light mode - .WriteTo.Console( - theme: AnsiConsoleTheme.Sixteen, - applyThemeToRedirectedOutput: true, - restrictedToMinimumLevel: config.Logging.LogEventLevel - ); + .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen); if (config.Logging.SeqLogUrl != null) { @@ -111,7 +108,6 @@ public static class StartupExtensions .AddScoped() .AddScoped() .AddScoped() - .AddScoped() .AddSingleton() .AddSingleton() .AddSingleton() @@ -121,13 +117,12 @@ public static class StartupExtensions .AddSingleton() .AddSingleton() .AddScoped() - .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(InMemoryDataService.Instance) - .AddTransient() .AddSingleton() + .AddTransient() // Background services // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => @@ -191,15 +186,9 @@ public static class StartupExtensions public static async Task Initialize(this WebApplication app) { - await BuildInfo.ReadBuildInfo(); - await using var scope = app.Services.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService().ForContext(); - logger.Information( - "Starting Catalogger.NET {Version} ({Hash})", - BuildInfo.Version, - BuildInfo.Hash - ); + logger.Information("Starting Catalogger.NET"); CataloggerMetrics.Startup = scope .ServiceProvider.GetRequiredService() @@ -208,11 +197,10 @@ public static class StartupExtensions DatabasePool.ConfigureDapper(); await using var migrator = scope.ServiceProvider.GetRequiredService(); - await migrator.MigrateUp(); + await migrator.Migrate(); var config = scope.ServiceProvider.GetRequiredService(); var slashService = scope.ServiceProvider.GetRequiredService(); - var timeoutService = scope.ServiceProvider.GetRequiredService(); if (config.Discord.TestMode) logger.Warning( @@ -255,9 +243,6 @@ public static class StartupExtensions logger.Information( "Not syncing slash commands, Discord.SyncCommands is false or unset" ); - - // Initialize the timeout service by loading all the timeouts currently in the database. - await timeoutService.InitializeAsync(); } public static void MaybeAddDashboard(this WebApplication app) @@ -277,6 +262,8 @@ public static class StartupExtensions app.UseSerilogRequestLogging(); app.UseRouting(); app.UseHttpMetrics(); + app.UseSwagger(); + app.UseSwaggerUI(); app.UseCors(); app.UseMiddleware(); app.UseMiddleware(); diff --git a/Catalogger.Backend/JsonUtils.cs b/Catalogger.Backend/JsonUtils.cs deleted file mode 100644 index 4fa9a94..0000000 --- a/Catalogger.Backend/JsonUtils.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using NodaTime; -using NodaTime.Serialization.SystemTextJson; -using NodaTime.Text; - -namespace Catalogger.Backend; - -public static class JsonUtils -{ - public static readonly NodaJsonSettings NodaTimeSettings = new NodaJsonSettings - { - InstantConverter = new NodaPatternConverter(InstantPattern.ExtendedIso), - }; - - public static readonly JsonSerializerOptions BaseJsonOptions = new JsonSerializerOptions - { - NumberHandling = JsonNumberHandling.AllowReadingFromString, - }.ConfigureForNodaTime(NodaTimeSettings); - - public static readonly JsonSerializerOptions ApiJsonOptions = new JsonSerializerOptions - { - NumberHandling = - JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, - }.ConfigureForNodaTime(NodaTimeSettings); -} diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs index 6fbf6b3..27710e0 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -15,11 +15,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Catalogger.Backend; using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Extensions; using Catalogger.Backend.Services; -using NodaTime.Serialization.SystemTextJson; using Prometheus; using Remora.Commands.Extensions; using Remora.Discord.API.Abstractions.Gateway.Commands; @@ -27,7 +25,6 @@ using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Gateway.Commands; using Remora.Discord.API.Objects; using Remora.Discord.Commands.Extensions; -using Remora.Discord.Commands.Responders; using Remora.Discord.Extensions.Extensions; using Remora.Discord.Gateway; using Remora.Discord.Interactivity.Extensions; @@ -48,7 +45,6 @@ builder options.JsonSerializerOptions.IncludeFields = true; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString; - options.JsonSerializerOptions.ConfigureForNodaTime(JsonUtils.NodaTimeSettings); }); builder @@ -66,7 +62,8 @@ builder | GatewayIntents.GuildMessages | GatewayIntents.GuildWebhooks | GatewayIntents.MessageContents - | GatewayIntents.GuildExpressions; + // Actually GUILD_EXPRESSIONS + | GatewayIntents.GuildEmojisAndStickers; // Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService. g.Presence = new UpdatePresence( @@ -83,7 +80,6 @@ builder ] ); }) - .Configure(opts => opts.SuppressAutomaticResponses = true) .AddDiscordCommands( enableSlash: true, useDefaultCommandResponder: false, @@ -95,10 +91,8 @@ builder .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() - .WithCommandGroup() - .WithCommandGroup() + .WithCommandGroup() .WithCommandGroup() - .WithCommandGroup() // End command tree .Finish() .AddPagination() @@ -114,7 +108,12 @@ builder.Services.AddMetricServer(o => o.Port = (ushort)config.Logging.MetricsPor if (!config.Logging.EnableMetrics) builder.Services.AddHostedService(); -builder.Services.MaybeAddDashboardServices(config).MaybeAddRedisCaches(config).AddCustomServices(); +builder + .Services.MaybeAddDashboardServices(config) + .MaybeAddRedisCaches(config) + .AddCustomServices() + .AddEndpointsApiExplorer() + .AddSwaggerGen(); var app = builder.Build(); diff --git a/Catalogger.Backend/Services/BackgroundTasksService.cs b/Catalogger.Backend/Services/BackgroundTasksService.cs index c23e96b..11a35da 100644 --- a/Catalogger.Backend/Services/BackgroundTasksService.cs +++ b/Catalogger.Backend/Services/BackgroundTasksService.cs @@ -35,23 +35,16 @@ public class BackgroundTasksService(ILogger logger, IServiceProvider services) : await using var scope = services.CreateAsyncScope(); await using var messageRepository = scope.ServiceProvider.GetRequiredService(); - await using var timeoutRepository = - scope.ServiceProvider.GetRequiredService(); var (msgCount, ignoredCount) = await messageRepository.DeleteExpiredMessagesAsync(); if (msgCount != 0 || ignoredCount != 0) + { _logger.Information( "Deleted {Count} messages and {IgnoredCount} ignored message IDs older than {MaxDays} days old", msgCount, ignoredCount, MessageRepository.MaxMessageAgeDays ); - - var timeoutCount = await timeoutRepository.RemoveExpiredTimeoutsAsync(); - if (timeoutCount != 0) - _logger.Information( - "Deleted {Count} expired timeouts that were never logged", - timeoutCount - ); + } } } diff --git a/Catalogger.Backend/Services/MetricsCollectionService.cs b/Catalogger.Backend/Services/MetricsCollectionService.cs index afba829..7ac9ffe 100644 --- a/Catalogger.Backend/Services/MetricsCollectionService.cs +++ b/Catalogger.Backend/Services/MetricsCollectionService.cs @@ -26,7 +26,6 @@ public class MetricsCollectionService( ILogger logger, GuildCache guildCache, ChannelCache channelCache, - RoleCache roleCache, UserCache userCache, EmojiCache emojiCache, IServiceProvider services @@ -43,10 +42,8 @@ public class MetricsCollectionService( var messageCount = await conn.ExecuteScalarAsync("select count(id) from messages"); - CataloggerMetrics.DatabaseConnections.Set(DatabasePool.OpenConnections); CataloggerMetrics.GuildsCached.Set(guildCache.Size); CataloggerMetrics.ChannelsCached.Set(channelCache.Size); - CataloggerMetrics.RolesCached.Set(roleCache.Size); CataloggerMetrics.UsersCached.Set(userCache.Size); CataloggerMetrics.EmojiCached.Set(emojiCache.Size); CataloggerMetrics.MessagesStored.Set(messageCount); diff --git a/Catalogger.Backend/Services/NewsService.cs b/Catalogger.Backend/Services/NewsService.cs index 3adfebe..e52c783 100644 --- a/Catalogger.Backend/Services/NewsService.cs +++ b/Catalogger.Backend/Services/NewsService.cs @@ -34,9 +34,8 @@ public class NewsService( private readonly ILogger _logger = logger.ForContext(); private List? _messages; - private Instant _lastUpdated = Instant.MinValue; private readonly SemaphoreSlim _lock = new(1); - private bool _isExpired => clock.GetCurrentInstant() > _lastUpdated + ExpiresAfter; + private bool _isExpired => clock.GetCurrentInstant() > clock.GetCurrentInstant() + ExpiresAfter; public async Task> GetNewsAsync() { @@ -75,7 +74,6 @@ public class NewsService( } finally { - _lastUpdated = clock.GetCurrentInstant(); _lock.Release(); } } diff --git a/Catalogger.Backend/Services/PluralkitApiService.cs b/Catalogger.Backend/Services/PluralkitApiService.cs index 283200c..5dd0f0b 100644 --- a/Catalogger.Backend/Services/PluralkitApiService.cs +++ b/Catalogger.Backend/Services/PluralkitApiService.cs @@ -14,9 +14,12 @@ // along with this program. If not, see . using System.Net; +using System.Text.Json; using System.Threading.RateLimiting; using Humanizer; using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using NodaTime.Text; using Polly; namespace Catalogger.Backend.Services; @@ -28,6 +31,16 @@ public class PluralkitApiService(ILogger logger) private readonly HttpClient _client = new(); private readonly ILogger _logger = logger.ForContext(); + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }.ConfigureForNodaTime( + new NodaJsonSettings + { + InstantConverter = new NodaPatternConverter(InstantPattern.ExtendedIso), + } + ); + private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder() .AddRateLimiter( new FixedWindowRateLimiter( @@ -71,7 +84,7 @@ public class PluralkitApiService(ILogger logger) throw new CataloggerError("Non-200 status code from PluralKit API"); } - return await resp.Content.ReadFromJsonAsync(JsonUtils.ApiJsonOptions, ct) + return await resp.Content.ReadFromJsonAsync(_jsonOptions, ct) ?? throw new CataloggerError("JSON response from PluralKit API was null"); } diff --git a/Catalogger.Backend/Services/StatusUpdateService.cs b/Catalogger.Backend/Services/StatusUpdateService.cs index b9f187b..414c2d2 100644 --- a/Catalogger.Backend/Services/StatusUpdateService.cs +++ b/Catalogger.Backend/Services/StatusUpdateService.cs @@ -13,9 +13,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using System.Net.Http.Headers; -using System.Text.Json; -using System.Text.Json.Serialization; using Catalogger.Backend.Bot; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Gateway.Commands; @@ -23,20 +20,19 @@ using Remora.Discord.API.Objects; namespace Catalogger.Backend.Services; -public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient, Config config) +public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient) : BackgroundService { private readonly ILogger _logger = logger.ForContext(); - private readonly HttpClient _client = new(); protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromMinutes(3)); while (await timer.WaitForNextTickAsync(ct)) - await UpdateShardStatuses(ct); + UpdateShardStatuses(ct); } - private async Task UpdateShardStatuses(CancellationToken ct = default) + private void UpdateShardStatuses(CancellationToken ct = default) { _logger.Information( "Updating status for {TotalShards} shards. Guild count is {GuildCount}", @@ -44,12 +40,6 @@ public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedCli CataloggerMetrics.GuildsCached.Value ); - if (config.Discord.TestMode) - { - _logger.Debug("Not updating shard statuses because test mode is enabled."); - return; - } - foreach (var (shardId, client) in shardedClient.Shards) { if (!ShardedGatewayClient.IsConnected(client)) @@ -63,13 +53,11 @@ public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedCli client.SubmitCommand(PresenceFor(shardId)); } - - await ReportStatsAsync(); } private UpdatePresence PresenceFor(int shardId) { - var status = $"/catalogger help | in {CataloggerMetrics.GuildsCached.Value:N0} servers"; + var status = $"/catalogger help | in {CataloggerMetrics.GuildsCached.Value} servers"; if (shardedClient.TotalShards != 1) status += $" | shard {shardId + 1}/{shardedClient.TotalShards}"; @@ -81,43 +69,4 @@ public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedCli Activities: [new Activity(Name: "Beep", Type: ActivityType.Custom, State: status)] ); } - - private async Task ReportStatsAsync() - { - if (config.Discord.BotsGgToken == null) - return; - - _logger.Debug("Posting stats to discord.bots.gg"); - - var req = new HttpRequestMessage( - HttpMethod.Post, - $"https://discord.bots.gg/api/v1/bots/{config.Discord.ApplicationId}/stats" - ); - req.Headers.Add("Authorization", config.Discord.BotsGgToken); - req.Content = new StringContent( - JsonSerializer.Serialize( - new BotsGgStats( - (int)CataloggerMetrics.GuildsCached.Value, - shardedClient.TotalShards - ) - ), - new MediaTypeHeaderValue("application/json", "utf-8") - ); - - var resp = await _client.SendAsync(req); - if (!resp.IsSuccessStatusCode) - { - var content = await resp.Content.ReadAsStringAsync(); - _logger.Error( - "Error updating stats for discord.bots.gg: {StatusCode}, {Content}", - (int)resp.StatusCode, - content - ); - } - } - - private record BotsGgStats( - [property: JsonPropertyName("guildCount")] int GuildCount, - [property: JsonPropertyName("shardCount")] int ShardCount - ); } diff --git a/Catalogger.Backend/Services/TimeoutService.cs b/Catalogger.Backend/Services/TimeoutService.cs deleted file mode 100644 index 03d1831..0000000 --- a/Catalogger.Backend/Services/TimeoutService.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Concurrent; -using Catalogger.Backend.Bot; -using Catalogger.Backend.Cache.InMemoryCache; -using Catalogger.Backend.Database.Models; -using Catalogger.Backend.Database.Repositories; -using Catalogger.Backend.Extensions; -using Remora.Discord.API; -using Remora.Discord.Extensions.Embeds; -using Remora.Rest.Core; - -namespace Catalogger.Backend.Services; - -public class TimeoutService( - IServiceProvider serviceProvider, - ILogger logger, - WebhookExecutorService webhookExecutor, - UserCache userCache -) -{ - private readonly ILogger _logger = logger.ForContext(); - private readonly ConcurrentDictionary _timers = new(); - - public async Task InitializeAsync() - { - _logger.Information("Populating timeout service with existing database timeouts"); - - await using var scope = serviceProvider.CreateAsyncScope(); - await using var timeoutRepository = - scope.ServiceProvider.GetRequiredService(); - - var timeouts = await timeoutRepository.GetAllAsync(); - foreach (var timeout in timeouts) - AddTimer(timeout); - } - - public void AddTimer(DiscordTimeout timeout) - { - _logger.Debug("Adding timeout {TimeoutId} to queue", timeout.Id); - - RemoveTimer(timeout.Id); - _timers[timeout.Id] = new Timer( - _ => - { - var __ = SendTimeoutLogAsync(timeout.Id); - }, - null, - timeout.Until.ToDateTimeOffset() - DateTimeOffset.UtcNow, - Timeout.InfiniteTimeSpan - ); - } - - private async Task SendTimeoutLogAsync(int timeoutId) - { - _logger.Information("Sending timeout log for {TimeoutId}", timeoutId); - - await using var scope = serviceProvider.CreateAsyncScope(); - await using var guildRepository = - scope.ServiceProvider.GetRequiredService(); - await using var timeoutRepository = - scope.ServiceProvider.GetRequiredService(); - - var timeout = await timeoutRepository.RemoveAsync(timeoutId); - if (timeout == null) - { - _logger.Warning("Timeout {TimeoutId} not found, can't log anything", timeoutId); - return; - } - - var userId = DiscordSnowflake.New(timeout.UserId); - var moderatorId = - timeout.ModeratorId != null - ? DiscordSnowflake.New(timeout.ModeratorId.Value) - : (Snowflake?)null; - - var user = await userCache.GetUserAsync(userId); - if (user == null) - { - _logger.Warning("Could not get user {UserId} from cache, can't log timeout", userId); - return; - } - - var embed = new EmbedBuilder() - .WithAuthor(user.Tag(), null, user.AvatarUrl()) - .WithTitle("Member timeout ended") - .WithDescription($"<@{user.ID}>") - .WithColour(DiscordUtils.Green) - .WithFooter($"User ID: {user.ID}") - .WithCurrentTimestamp(); - - if (moderatorId != null) - { - var moderator = await userCache.TryFormatUserAsync(moderatorId.Value); - embed.AddField("Originally timed out by", moderator); - } - else - { - embed.AddField("Originally timed out by", "*(unknown)*"); - } - - try - { - var guildConfig = await guildRepository.GetAsync(DiscordSnowflake.New(timeout.GuildId)); - webhookExecutor.QueueLog( - guildConfig, - LogChannelType.GuildMemberTimeout, - embed.Build().GetOrThrow() - ); - } - catch (Exception e) - { - _logger.Error(e, "Could not log timeout {TimeoutId} expiring", timeout.Id); - } - } - - public void RemoveTimer(int timeoutId) - { - if (!_timers.TryRemove(timeoutId, out var timer)) - return; - _logger.Debug("Removing timeout {TimeoutId} from queue", timeoutId); - timer.Dispose(); - } -} diff --git a/Catalogger.Backend/Services/WebhookExecutorService.cs b/Catalogger.Backend/Services/WebhookExecutorService.cs index 8ca8cb3..dfde9b2 100644 --- a/Catalogger.Backend/Services/WebhookExecutorService.cs +++ b/Catalogger.Backend/Services/WebhookExecutorService.cs @@ -43,7 +43,7 @@ public class WebhookExecutorService( 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 _locks = new(); private readonly ConcurrentDictionary _timers = new(); private IUser? _selfUser; @@ -60,14 +60,8 @@ public class WebhookExecutorService( /// public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed) { - var logChannel = GetLogChannel( - guildConfig, - logChannelType, - channelId: null, - userId: null, - roleId: null, - roleIds: null - ); + var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null); + _logger.Debug("Channel to log {Type} to: {LogChannel}", logChannelType, logChannel); if (logChannel == null) return; @@ -77,16 +71,17 @@ public class WebhookExecutorService( /// /// Queues a log embed for the given channel ID. /// - public void QueueLog(ulong? channelId, IEmbed embed) + public void QueueLog(ulong channelId, IEmbed embed) { - if (channelId is null or 0) + _logger.Debug("Channel to log to: {LogChannel}", channelId); + if (channelId == 0) return; - var queue = _cache.GetOrAdd(channelId.Value, []); + var queue = _cache.GetOrAdd(channelId, []); queue.Enqueue(embed); - _cache[channelId.Value] = queue; + _cache[channelId] = queue; - SetTimer(channelId.Value, queue); + SetTimer(channelId, queue); } /// @@ -189,7 +184,7 @@ public class WebhookExecutorService( private List TakeFromQueue(ulong channelId) { var queue = _cache.GetOrAdd(channelId, []); - var channelLock = _locks.GetOrAdd(channelId, new Lock()); + var channelLock = _locks.GetOrAdd(channelId, channelId); lock (channelLock) { var totalContentLength = 0; @@ -258,248 +253,18 @@ public class WebhookExecutorService( } public ulong? GetLogChannel( - Guild guild, - LogChannelType logChannelType, - Snowflake? channelId = null, - ulong? userId = null, - Snowflake? roleId = null, - IReadOnlyList? roleIds = null - ) - { - var isMessageLog = - logChannelType - is LogChannelType.MessageUpdate - or LogChannelType.MessageDelete - or LogChannelType.MessageDeleteBulk; - - // Check if we're getting the channel for a channel log - var isChannelLog = - channelId != null - && logChannelType - is LogChannelType.ChannelCreate - or LogChannelType.ChannelDelete - or LogChannelType.ChannelUpdate; - - // Check if we're getting the channel for a role log - var isRoleLog = - roleId != null - && logChannelType - is LogChannelType.GuildRoleCreate - or LogChannelType.GuildRoleUpdate - or LogChannelType.GuildRoleDelete; - - // Check if we're getting the channel for a member update log - var isMemberRoleUpdateLog = - roleIds != null && logChannelType is LogChannelType.GuildMemberUpdate; - - if (isMessageLog) - return GetLogChannelForMessageEvent(guild, logChannelType, channelId, userId); - - if (isChannelLog) - return GetLogChannelForChannelEvent(guild, logChannelType, channelId!.Value); - - if (isRoleLog && guild.IgnoredRoles.Contains(roleId!.Value.Value)) - return null; - - // Member update logs are only ignored if *all* updated roles are ignored - if (isMemberRoleUpdateLog && roleIds!.All(r => guild.IgnoredRoles.Contains(r.Value))) - return null; - - // If nothing is ignored, and this isn't a message or channel event, return the default log channel. - return GetDefaultLogChannel(guild, logChannelType); - } - - private ulong? GetLogChannelForMessageEvent( Guild guild, LogChannelType logChannelType, Snowflake? channelId = null, ulong? userId = null ) { - _logger.Verbose( - "Getting log channel for event {Event}. Channel ID: {ChannelId}, user ID: {UserId}", - logChannelType, - channelId, - userId - ); - - // Check if the user is ignored globally - if (userId != null && guild.Messages.IgnoredUsers.Contains(userId.Value)) - { - _logger.Verbose("User {UserId} is ignored globally", userId); - return null; - } - - // If the user isn't ignored and we didn't get a channel ID, return the default log channel if (channelId == null) - { - _logger.Verbose( - "No channel ID given so returning default channel for {Event}", - logChannelType - ); return GetDefaultLogChannel(guild, logChannelType); - } - if (!channelCache.TryGet(channelId.Value, out var channel)) - { - _logger.Verbose( - "Channel with ID {ChannelId} is not cached, returning default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - if (!GetChannelAndParentId(channel, out var actualChannelId, out var categoryId)) - { - _logger.Verbose( - "Could not get root channel and category ID for channel {ChannelId}, returning default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - // Check if the channel or its category is ignored - if ( - guild.Messages.IgnoredChannels.Contains(actualChannelId.Value) - || categoryId != null && guild.Messages.IgnoredChannels.Contains(categoryId.Value.Value) - ) - { - _logger.Verbose( - "Channel {ChannelId} or its parent {CategoryId} is ignored", - actualChannelId, - categoryId - ); return null; - } - if (userId != null) - { - // Check the channel-local and category-local ignored users - var channelIgnoredUsers = - guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault(actualChannelId.Value) - ?? []; - - // Obviously, we can only check for category-level ignored users if we actually got a category ID. - var categoryIgnoredUsers = - ( - categoryId != null - ? guild.Messages.IgnoredUsersPerChannel.GetValueOrDefault( - categoryId.Value.Value - ) - : [] - ) ?? []; - - // Combine the ignored users in the channel and category, then check if the user is in there. - if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value)) - { - _logger.Verbose( - "User {UserId} is ignored in {ChannelId} or its category {CategoryId}", - userId, - channelId, - categoryId - ); - return null; - } - } - - // These three events can be redirected to other channels. Redirects can be on a channel or category level. - // The events are only redirected if they're supposed to be logged in the first place (i.e. GetDefaultLogChannel doesn't return 0) - if (GetDefaultLogChannel(guild, logChannelType) == 0) - { - _logger.Verbose( - "No default log channel for event {EventType}, ignoring event", - logChannelType - ); - return null; - } - - if (guild.Channels.Redirects.TryGetValue(actualChannelId.Value, out var channelRedirect)) - { - _logger.Verbose( - "Messages from channel {ChannelId} should be redirected to {RedirectId}", - actualChannelId, - channelRedirect - ); - return channelRedirect; - } - - var categoryRedirect = - categoryId != null - ? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value) - : 0; - - if (categoryRedirect != 0) - { - _logger.Verbose( - "Messages from categoryId {CategoryId} should be redirected to {RedirectId}", - categoryId, - categoryRedirect - ); - return categoryRedirect; - } - - _logger.Verbose( - "No redirects or ignores for event {EventType}, returning default log channel", - logChannelType - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - private ulong? GetLogChannelForChannelEvent( - Guild guild, - LogChannelType logChannelType, - Snowflake channelId - ) - { - _logger.Verbose( - "Getting log channel for event {Event} in guild {GuildId} and channel {ChannelId}", - logChannelType, - guild.Id, - channelId - ); - - if (!channelCache.TryGet(channelId, out var channel)) - { - _logger.Verbose( - "Channel with ID {ChannelId} is not cached, returning default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - if (!GetChannelAndParentId(channel, out channelId, out var categoryId)) - { - _logger.Verbose( - "Could not get root channel and category ID for channel {ChannelId}, returning default log channel", - channelId - ); - return GetDefaultLogChannel(guild, logChannelType); - } - - // Check if the channel or its category is ignored - if ( - guild.IgnoredChannels.Contains(channelId.Value) - || (categoryId != null && guild.IgnoredChannels.Contains(categoryId.Value.Value)) - ) - { - _logger.Verbose( - "Channel {ChannelId} or its parent {CategoryId} is ignored", - channelId, - categoryId - ); - return null; - } - - _logger.Verbose("Returning default log channel for {EventType}", logChannelType); - return GetDefaultLogChannel(guild, logChannelType); - } - - private bool GetChannelAndParentId( - IChannel channel, - out Snowflake channelId, - out Snowflake? categoryId - ) - { + Snowflake? categoryId; if ( channel.Type is ChannelType.AnnouncementThread @@ -509,17 +274,8 @@ public class WebhookExecutorService( { // parent_id should always have a value for threads channelId = channel.ParentID.Value!.Value; - if (!channelCache.TryGet(channelId, out var parentChannel)) - { - _logger.Verbose( - "Parent channel for thread {ChannelId} is not in cache, returning the default log channel", - channelId - ); - - channelId = Snowflake.CreateTimestampSnowflake(); - categoryId = null; - return false; - } + if (!channelCache.TryGet(channelId.Value, out var parentChannel)) + return GetDefaultLogChannel(guild, logChannelType); categoryId = parentChannel.ParentID.Value; } else @@ -528,11 +284,64 @@ public class WebhookExecutorService( categoryId = channel.ParentID.Value; } - return true; + // 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 logChannelType) => - logChannelType switch + public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) => + channelType switch { LogChannelType.GuildUpdate => guild.Channels.GuildUpdate, LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate, @@ -557,7 +366,7 @@ public class WebhookExecutorService( LogChannelType.MessageUpdate => guild.Channels.MessageUpdate, LogChannelType.MessageDelete => guild.Channels.MessageDelete, LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk, - _ => throw new ArgumentOutOfRangeException(nameof(logChannelType)), + _ => throw new ArgumentOutOfRangeException(nameof(channelType)), }; } diff --git a/Catalogger.Backend/config.example.ini b/Catalogger.Backend/config.example.ini index 3f12c79..9b74c55 100644 --- a/Catalogger.Backend/config.example.ini +++ b/Catalogger.Backend/config.example.ini @@ -7,9 +7,6 @@ LogQueries = false SeqLogUrl = http://localhost:5341 # Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often. EnableMetrics = false -# The URL for the Prometheus server. Used for message rate if metrics are enabled. -# Defaults to http://localhost:9090, should be changed if Prometheus is on another server. -PrometheusUrl = http://localhost:9090 [Database] Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres diff --git a/Catalogger.Backend/packages.lock.json b/Catalogger.Backend/packages.lock.json new file mode 100644 index 0000000..2af0ac1 --- /dev/null +++ b/Catalogger.Backend/packages.lock.json @@ -0,0 +1,751 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Dapper": { + "type": "Direct", + "requested": "[2.1.35, )", + "resolved": "2.1.35", + "contentHash": "YKRwjVfrG7GYOovlGyQoMvr1/IJdn+7QzNXJxyMh0YfFF5yvDmTYaJOVYWsckreNjGsGSEtrMTpnzxTUq/tZQw==" + }, + "Humanizer.Core": { + "type": "Direct", + "requested": "[2.14.1, )", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "LazyCache": { + "type": "Direct", + "requested": "[2.4.0, )", + "resolved": "2.4.0", + "contentHash": "THig17vqe5PEs3wvTqFrNzorz2nD4Qz9F9C3YlAydU673CogAO8z1u8NNJD6x52I7oDCQ/N/HwJIZMBH8Y/Qiw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "2.1.0", + "Microsoft.Extensions.Caching.Memory": "2.1.0" + } + }, + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { + "type": "Direct", + "requested": "[8.0.8, )", + "resolved": "8.0.8", + "contentHash": "KL3lI8GmCnnROwDrbWbboVpHiXSNTyoLgYPdHus3hEjAwhSAm1JU5S+rmZk7w3Qt0rQfHVIFxKwCf6yapeZy+w==", + "dependencies": { + "Microsoft.AspNetCore.JsonPatch": "8.0.8", + "Newtonsoft.Json": "13.0.3", + "Newtonsoft.Json.Bson": "1.0.2" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[8.0.8, )", + "resolved": "8.0.8", + "contentHash": "wNHhohqP8rmsQ4UhKbd6jZMD6l+2Q/+DvRBT0Cgqeuglr13aF6sSJWicZKCIhZAUXzuhkdwtHVc95MlPlFk0dA==", + "dependencies": { + "Microsoft.OpenApi": "1.4.3" + } + }, + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[13.0.3, )", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NodaTime": { + "type": "Direct", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "nDcUbG0jiEXmV8cOz7V8GnUKlmPJjqZm/R+E2JNnUSdlMoaQ19xSU8GXFLReGs/Nt8xdBfA8XfO77xVboWO1Vg==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "4.7.1" + } + }, + "NodaTime.Serialization.SystemTextJson": { + "type": "Direct", + "requested": "[1.2.0, )", + "resolved": "1.2.0", + "contentHash": "HNMQdHw6xCrNaHEEvJlBek+uUNI4uySEQhU3t8FibZT9ASMz40y5qkLIwhrHsnXhxUzOPP4tmAGy8PfBwc3zMg==", + "dependencies": { + "NodaTime": "[3.0.0, 4.0.0)" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "zRG5V8cyeZLpzJlKzFKjEwkRMYIYnHWJvEor2lWXeccS2E1G2nIWYYhnukB51iz5XsWSVEtqg3AxTWM0QJ6vfg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.NodaTime": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "oC7Ml5TDuQlcGECB5ML0XsPxFrYu3OdpG7c9cuqhB+xunLvqbZ0zXQoPJjvXK9KDNPDB/II61HNdsNas9f2J3A==", + "dependencies": { + "NodaTime": "3.1.9", + "Npgsql": "8.0.5" + } + }, + "Polly.Core": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.RateLimiting": { + "type": "Direct", + "requested": "[8.4.2, )", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "prometheus-net": { + "type": "Direct", + "requested": "[8.2.1, )", + "resolved": "8.2.1", + "contentHash": "3wVgdEPOCBF752s2xps5T+VH+c9mJK8S8GKEDg49084P6JZMumTZI5Te6aJ9MQpX0sx7om6JOnBpIi7ZBmmiDQ==", + "dependencies": { + "Microsoft.Extensions.Http": "3.1.0", + "Microsoft.Extensions.ObjectPool": "7.0.0" + } + }, + "prometheus-net.AspNetCore": { + "type": "Direct", + "requested": "[8.2.1, )", + "resolved": "8.2.1", + "contentHash": "/4TfTvbwIDqpaKTiWvEsjUywiHYF9zZvGZF5sK15avoDsUO/WPQbKsF8TiMaesuphdFQPK2z52P0zk6j26V0rQ==", + "dependencies": { + "prometheus-net": "8.2.1" + } + }, + "Remora.Discord": { + "type": "Direct", + "requested": "[2024.3.0-github11168366508, )", + "resolved": "2024.3.0-github11168366508", + "contentHash": "tlqwVPeILmUmjEIsDgRQQChwCPnwAvpJTXSiYMruPDO+XVomfMjMUfS7EVIMUosHEC4bs4PS8m60lbTO2Lducw==", + "dependencies": { + "Remora.Discord.Caching": "39.0.0-github11168366508", + "Remora.Discord.Commands": "28.1.0-github11168366508", + "Remora.Discord.Extensions": "5.3.6-github11168366508", + "Remora.Discord.Hosting": "6.0.10-github11168366508", + "Remora.Discord.Interactivity": "5.0.0-github11168366508", + "Remora.Discord.Pagination": "4.0.1-github11168366508" + } + }, + "Remora.Sdk": { + "type": "Direct", + "requested": "[3.1.2, )", + "resolved": "3.1.2", + "contentHash": "IjHGwOH9XZJu4sMPA25M/gMLJktq4CdtSvekn8sAF85bE/3uhxU9pqmuzc4N39ktY7aTkLBRDa6/oQJnmiI6CQ==" + }, + "Serilog": { + "type": "Direct", + "requested": "[4.0.2, )", + "resolved": "4.0.2", + "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" + }, + "Serilog.AspNetCore": { + "type": "Direct", + "requested": "[8.0.2, )", + "resolved": "8.0.2", + "contentHash": "LNUd1bHsik2E7jSoCQFdeMGAWXjH7eUQ6c2pqm5vl+jGqvxdabYXxlrfaqApjtX5+BfAjW9jTA2EKmPwxknpIA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0", + "Serilog": "3.1.1", + "Serilog.Extensions.Hosting": "8.0.0", + "Serilog.Formatting.Compact": "2.0.0", + "Serilog.Settings.Configuration": "8.0.2", + "Serilog.Sinks.Console": "5.0.0", + "Serilog.Sinks.Debug": "2.0.0", + "Serilog.Sinks.File": "5.0.0" + } + }, + "Serilog.Extensions.Hosting": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "db0OcbWeSCvYQkHWu6n0v40N4kKaTAXNjlM3BKvcbwvNzYphQFcBR+36eQ/7hMMwOkJvAyLC2a9/jNdUL5NjtQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Serilog": "3.1.1", + "Serilog.Extensions.Logging": "8.0.0" + } + }, + "Serilog.Sinks.Console": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "z5ig56/qzjkX6Fj4U/9m1g8HQaQiYPMZS4Uevtjg1I+WWzoGSf5t/E+6JbMP/jbZYhU63bA5NJN5y0x+qqx2Bw==", + "dependencies": { + "Serilog": "4.0.0", + "Serilog.Sinks.File": "5.0.0" + } + }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.8.16, )", + "resolved": "2.8.16", + "contentHash": "WaoulkOqOC9jHepca3JZKFTqndCWab5uYS7qCzmiQDlrTkFaDN7eLSlEfHycBxipRnQY9ppZM7QSsWAwUEGblw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "Swashbuckle.AspNetCore": { + "type": "Direct", + "requested": "[6.8.1, )", + "resolved": "6.8.1", + "contentHash": "JN6ccH37QKtNOwBrvSxc+jBYIB+cw6RlZie2IKoJhjjf6HzBH+2kPJCpxmJ5EHIqmxvq6aQG+0A8XklGx9rAxA==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "6.0.5", + "Swashbuckle.AspNetCore.Swagger": "6.8.1", + "Swashbuckle.AspNetCore.SwaggerGen": "6.8.1", + "Swashbuckle.AspNetCore.SwaggerUI": "6.8.1" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.2.2", + "contentHash": "+zIp8d3sbtYaRbM6hqDs4Ui/z34j7DcUmleruZlYLE4CVxXq+MO8XJyIs42vzeTYFX+k0Iq1dEbBUnQ4z/Gnrw==" + }, + "FuzzySharp": { + "type": "Transitive", + "resolved": "2.0.2", + "contentHash": "sBKqWxw3g//peYxDZ8JipRlyPbIyBtgzqBVA5GqwHVeqtIrw75maGXAllztf+1aJhchD+drcQIgf2mFho8ZV8A==" + }, + "JsonDocumentPath": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "4mgdlioVvfq6ZjftvsoKANWgpr/AU+UySiW68EjcbPbTfvcrZOlgS+6JkouRAN4TwI8dN2DUAVME7bklThk3KQ==" + }, + "Microsoft.AspNetCore.JsonPatch": { + "type": "Transitive", + "resolved": "8.0.8", + "contentHash": "IGhuO/SsjHIIvFP4O/5pn/WcPJor+A+BERBhIkMYrlYcRXnZmbBBNSyqoNI9wFq0oxtsrnYMnzXAIi+0MKVdSA==", + "dependencies": { + "Microsoft.CSharp": "4.7.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.CSharp": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "pTj+D3uJWyN3My70i2Hqo+OXixq3Os2D1nJ2x92FFo6sk8fYS1m1WLNTs0Dc1uPaViH0YvEEwvzddQ7y4rhXmA==" + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "Transitive", + "resolved": "6.0.5", + "contentHash": "Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "V8S3bsm50ig6JSyrbcJJ8bW2b9QLGouz+G1miK3UTaOWmMtFwNNNzUf4AleyDWUmTrWMLNnFSLEQtxmxgNQnNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "5Ou6varcxLBzQ+Agfm0k0pnH7vrEITYlXMDuE6s7ZHlZHz6/G8XJ3iISZDr5rfwfge6RnXJ1+Wc479mMn52vjA==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.4" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3PZp/YSkIXrF7QK7PfC1bkyRYwqOHpWFad8Qx+4wkuumAeXo1NHaxpS9LboNA9OvNSAu+QOVlXbMyoY+pHSqcw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JHYCQG7HmugNYUhOl368g+NMxYE/N/AiclCYRNlgCY9eVyiBkOHMwK4x60RYMxv9EL3+rmj1mqHvdCiPpC+D4Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "System.Diagnostics.DiagnosticSource": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AG7HWwVRdCHlaA++1oKDxLsXIBxmDpMPb3VoyOoAghEWnkUvEAdYQUwnV4jJbAaa/nMYNiEh5ByoLauZBEiovg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Http": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cWz4caHwvx0emoYe7NkHPxII/KkTI8R/LC9qdqJqnKv2poTJ4e2qqPGQqvRoQ5kaSA4FU5IV3qFAuLuOhoqULQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Diagnostics": "8.0.0", + "Microsoft.Extensions.Logging": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Http.Polly": { + "type": "Transitive", + "resolved": "8.0.6", + "contentHash": "vehhL2uDlr2ovIFMuYcQwXgOCu7QECXnjcRD37luN40Fjqm0C4PDiN0t0dHoyfJp6OgJ+sOYDev5jVMGz4lJnQ==", + "dependencies": { + "Microsoft.Extensions.Http": "8.0.0", + "Polly": "7.2.4", + "Polly.Extensions.Http": "3.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "tvRkov9tAJ3xP51LCv3FJ2zINmv1P8Hi8lhhtcKGqM+ImiTCC84uOPEI4z8Cdq2C3o9e+Aa0Gw0rmrsJD77W+w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "udvKco0sAVgYGTBnHUb0tY9JQzJ/nPDiv/8PIyz69wl1AibeCDZOLVVI+6156dPfHmJH7ws5oUJRiW4ZmAvuuA==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.OpenApi": { + "type": "Transitive", + "resolved": "1.6.14", + "contentHash": "tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==" + }, + "Newtonsoft.Json.Bson": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "QYFyxhaABwmq3p/21VrZNYvCg3DaEoN/wUuw5nmfAf0X3HLjgupwhkEWdgfb9nvGAUIv3osmZoD3kKl4jxEmYQ==", + "dependencies": { + "Newtonsoft.Json": "12.0.1" + } + }, + "NGettext": { + "type": "Transitive", + "resolved": "0.6.7", + "contentHash": "gT6bf5PVayvTuEIuM2XSNqthrtn9W+LlCX4RD//Nb4hrT3agohHvPdjpROgNGgyXDkjwE74F+EwDwqUgJCJG8A==" + }, + "OneOf": { + "type": "Transitive", + "resolved": "3.0.271", + "contentHash": "pqpqeK8xQGggExhr4tesVgJkjdn+9HQAO0QgrYV2hFjE3y90okzk1kQMntMiUOGfV7FrCUfKPaVvPBD4IANqKg==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, + "Polly": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "z2EeUutuy49jBQyZ5s2FUuTCGx3GCzJ0cJ2HbjWwks94TsC6bKTtAHKBkMZOa/DyYRl5yIX7MshvMTWl1J6RNg==", + "dependencies": { + "Polly.Core": "8.4.0" + } + }, + "Polly.Contrib.WaitAndRetry": { + "type": "Transitive", + "resolved": "1.1.1", + "contentHash": "1MUQLiSo4KDkQe6nzQRhIU05lm9jlexX5BVsbuw0SL82ynZ+GzAHQxJVDPVBboxV37Po3SG077aX8DuSy8TkaA==" + }, + "Polly.Extensions.Http": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "drrG+hB3pYFY7w1c3BD+lSGYvH2oIclH8GRSehgfyP5kjnFnHKQuuBhuHLv+PWyFuaTDyk/vfRpnxOzd11+J8g==", + "dependencies": { + "Polly": "7.1.0" + } + }, + "Remora.Commands": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "uvZ34ywhK9WxBBqHZiLz7GXJDPZrt0N+IhRs5+V53TTCvLlgA0S8zBCPCANnVpcbVJ8Vl9l3EkcL+PY0VT0TYw==", + "dependencies": { + "Microsoft.Extensions.Options": "8.0.0", + "Remora.Results": "7.4.1" + } + }, + "Remora.Discord.API": { + "type": "Transitive", + "resolved": "78.0.0-github11168366508", + "contentHash": "yDH7x0XLbe4GPhHeK5Ju4tGXCPpSAo0Jd20jikVZOlFHLJkynt0NVWYTT69ZJyniibopwpeANPyAnX8KhZmBbA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.0", + "Microsoft.Extensions.Options": "8.0.2", + "Remora.Discord.API.Abstractions": "82.0.0-github11168366508", + "Remora.Rest": "3.4.0", + "System.Text.Json": "8.0.3" + } + }, + "Remora.Discord.API.Abstractions": { + "type": "Transitive", + "resolved": "82.0.0-github11168366508", + "contentHash": "vUsvcaM8bSqha9uBhye0mRvARaRHYQgQcIre+CcEloGO4n2JzalLdCFlYIUF3yzcBMGWQnnXymMSzvxjipPglw==", + "dependencies": { + "OneOf": "3.0.271", + "Remora.Rest.Core": "2.2.1", + "Remora.Results": "7.4.1" + } + }, + "Remora.Discord.Caching": { + "type": "Transitive", + "resolved": "39.0.0-github11168366508", + "contentHash": "LY6fROu/g+lcfV60OAM+7KC29nsKtJNUuhiGPI1Mb1w6uR5LoTWGaM29/nQeY8DzixD60np7lF5ZwZUlgoTp0g==", + "dependencies": { + "Remora.Discord.Caching.Abstractions": "1.1.4-github11168366508", + "Remora.Discord.Gateway": "12.0.2-github11168366508" + } + }, + "Remora.Discord.Caching.Abstractions": { + "type": "Transitive", + "resolved": "1.1.4-github11168366508", + "contentHash": "ZDh/C/d0lJ2rYY/8UyRDf57XYg2ZVnTjwuqVXNYrGI/kkQCMI3R4WCbPOppBrycji6iX5pp+fx1j1pSdZsc3eA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Remora.Results": "7.4.1" + } + }, + "Remora.Discord.Commands": { + "type": "Transitive", + "resolved": "28.1.0-github11168366508", + "contentHash": "SzYCnL4KEsnqvBaDLrXeAfkr45A3cHygJSO/VUSfQpTC6XoHDSMY181H7M2czgY+GiwSzrxYkeu/p89MFkzvxw==", + "dependencies": { + "FuzzySharp": "2.0.2", + "Humanizer.Core": "2.14.1", + "NGettext": "0.6.7", + "Remora.Commands": "10.0.5", + "Remora.Discord.Gateway": "12.0.2-github11168366508", + "Remora.Extensions.Options.Immutable": "1.0.8", + "System.ComponentModel.Annotations": "5.0.0" + } + }, + "Remora.Discord.Extensions": { + "type": "Transitive", + "resolved": "5.3.6-github11168366508", + "contentHash": "xidy4VW5xS8m+crKKjZeN2p6H+TQOgl9Je79ykX1vckMrUOMGtSreKoCEzpVRMPyXotNr9K2xbj1dqNtr4afXw==", + "dependencies": { + "Remora.Discord.API": "78.0.0-github11168366508", + "Remora.Discord.Commands": "28.1.0-github11168366508", + "Remora.Discord.Gateway": "12.0.2-github11168366508", + "Remora.Discord.Interactivity": "5.0.0-github11168366508" + } + }, + "Remora.Discord.Gateway": { + "type": "Transitive", + "resolved": "12.0.2-github11168366508", + "contentHash": "yleE7MHFc8JC6QDhCf6O9Xn2mQA06mmZtwph4tiBnehBTf6GY0ST6op7szEHEE4BI6LuvSo7TuKaHqFzAbxLHQ==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.2.2", + "Remora.Discord.Rest": "51.0.0-github11168366508", + "Remora.Extensions.Options.Immutable": "1.0.8", + "System.Threading.Channels": "8.0.0" + } + }, + "Remora.Discord.Hosting": { + "type": "Transitive", + "resolved": "6.0.10-github11168366508", + "contentHash": "BCTbNq/sYvUeiuFSNt8Y0aFi0+g4Fnz1vcHEwzFPxczGsW1QaHNOJst8GDpV9fEfcBrs5EHgE+Y4vo0ed8B9zQ==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "8.0.0", + "Remora.Discord.Gateway": "12.0.2-github11168366508", + "Remora.Extensions.Options.Immutable": "1.0.8" + } + }, + "Remora.Discord.Interactivity": { + "type": "Transitive", + "resolved": "5.0.0-github11168366508", + "contentHash": "vJOy/8//5+UcTHx8TV4iilQrYJEVfqfmuPNISIShLlgbEzbp/UjmN7QBiOJtpgUAPifeaQbmBXLPlYR0nKEDxg==", + "dependencies": { + "Remora.Discord.Commands": "28.1.0-github11168366508", + "Remora.Discord.Gateway": "12.0.2-github11168366508" + } + }, + "Remora.Discord.Pagination": { + "type": "Transitive", + "resolved": "4.0.1-github11168366508", + "contentHash": "+JKA+GYTlAkX1MxElI+ICGGmZnteiODiVHN09+QeHsjHaWxSBkb7g3pk8OqWrLhyQlyGvI/37kHV+UjRT6Ua5A==", + "dependencies": { + "Remora.Discord.Interactivity": "5.0.0-github11168366508" + } + }, + "Remora.Discord.Rest": { + "type": "Transitive", + "resolved": "51.0.0-github11168366508", + "contentHash": "4NImnAdU27K2Wkbjvw1Dyyib+dZwpKvl39vwnYNnpcYRgQ9mSiKWXq6y2rw/bXXn/l7V/EO6qZsgN1+Q5Yo65A==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.0", + "Microsoft.Extensions.Http.Polly": "8.0.6", + "Polly": "8.4.0", + "Polly.Contrib.WaitAndRetry": "1.1.1", + "Remora.Discord.API": "78.0.0-github11168366508", + "Remora.Discord.Caching.Abstractions": "1.1.4-github11168366508" + } + }, + "Remora.Extensions.Options.Immutable": { + "type": "Transitive", + "resolved": "1.0.8", + "contentHash": "CCw7IlZnE7hCGsO7sb9w05qdYY7bTufdYe6hiXKTOE3IDwdl2xtV7vitMif1KXVAjSZi9QySk8UPA5OfJTC3bA==", + "dependencies": { + "Microsoft.Extensions.Options": "7.0.1" + } + }, + "Remora.Rest": { + "type": "Transitive", + "resolved": "3.4.0", + "contentHash": "uncX4dsj6sq52ZUAnUrUs/usl3YEO4KZ+939r1K6Ojlq2IAZuuJ/4WocicARAiUZp8xa4xeOk1xbAP0+54D3gg==", + "dependencies": { + "JsonDocumentPath": "1.0.3", + "Microsoft.Extensions.Http": "8.0.0", + "OneOf": "3.0.263", + "Remora.Rest.Core": "2.2.1", + "Remora.Results": "7.4.1", + "System.Text.Json": "8.0.3" + } + }, + "Remora.Rest.Core": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "XWhTyHiClwJHiZf0+Ci0+R8ZdeJOyFWvPYh05JNYwAE9327T57d7VIqInbZ8/NfRdgYZ3TSHEjUwITVhetQZZQ==" + }, + "Remora.Results": { + "type": "Transitive", + "resolved": "7.4.1", + "contentHash": "XDO1jZBNpp3d0gApH0uG8BcOkjL4QxMJAEkmx3SlP202GDHev0BthuC4yOcENT5yApZvVT4IV5pJAwLYtSYIFg==" + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.0", + "Serilog": "3.1.1" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "ob6z3ikzFM3D1xalhFuBIK1IOWf+XrQq+H4KeH4VqBcPpNcmUgZlRQ2h3Q7wvthpdZBBoY86qZOI2LCXNaLlNA==", + "dependencies": { + "Serilog": "3.1.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "hn8HCAmupon7N0to20EwGeNJ+L3iRzjGzAHIl8+8CCFlEkVedHvS6NMYMb0VPNMsDgDwOj4cPBPV6Fc2hb0/7w==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.1", + "Serilog": "3.1.1" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "2.0.0", + "contentHash": "Y6g3OBJ4JzTyyw16fDqtFcQ41qQAydnEvEqmXjhwhgjsnG/FaJ8GUqF5ldsC/bVkK8KYmqrPhDO+tm4dF6xx4A==", + "dependencies": { + "Serilog": "2.10.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "uwV5hdhWPwUH1szhO8PJpFiahqXmzPzJT/sOijH/kFgUx+cyoDTMM8MHD0adw9+Iem6itoibbUXHYslzXsLEAg==", + "dependencies": { + "Serilog": "2.10.0" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "6.8.1", + "contentHash": "eOkdM4bsWBU5Ty3kWbyq5O9L+05kZT0vOdGh4a92vIb/LLQGQTPLRHXuJdnUBNIPNC8XfKWfSbtRfqzI6nnbqw==", + "dependencies": { + "Microsoft.OpenApi": "1.6.14" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "Transitive", + "resolved": "6.8.1", + "contentHash": "TjBPxsN0HeJzxEXZYeDXBNNMSyhg+TYXtkbwX+Cn8GH/y5ZeoB/chw0p71kRo5tR2sNshbKwL24T6f9pTF9PHg==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "6.8.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "6.8.1", + "contentHash": "lpEszYJ7vZaTTE5Dp8MrsbSHrgDfjhDMjzW1qOA1Xs1Dnj3ZRBJAcPZUTsa5Bva+nLaw91JJ8OI8FkSg8hhIyA==" + }, + "System.ComponentModel.Annotations": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dMkqfy2el8A8/I76n2Hi1oBFEbG1SfxD2l5nhwXV3XjlnOmwxJlQbYpJH4W51odnU9sARCSAgv7S3CyAFMkpYg==" + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "5.0.1", + "contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg==" + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "4.7.1", + "contentHash": "zOHkQmzPCn5zm/BH+cxC1XbUS3P4Yoi3xzW7eRgVpDR2tPGSzyMZ17Ig1iRkfJuY0nhxkQQde8pgePNiA7z7TQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "8.0.4", + "contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "CMaFr7v+57RW7uZfZkPExsPB6ljwzhjACWW1gfU35Y56rk72B/Wu+sTqxVmGSk4SFUlPc3cjeKND0zktziyjBA==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + } + } + } +} \ No newline at end of file diff --git a/Catalogger.Frontend/.gitignore b/Catalogger.Frontend/.gitignore index bbe170d..449610c 100644 --- a/Catalogger.Frontend/.gitignore +++ b/Catalogger.Frontend/.gitignore @@ -1,4 +1,3 @@ -.vscode node_modules # Output @@ -20,6 +19,3 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* - -# yarn 4 -.yarn \ No newline at end of file diff --git a/Catalogger.Frontend/.yarnrc.yml b/Catalogger.Frontend/.yarnrc.yml deleted file mode 100644 index 3186f3f..0000000 --- a/Catalogger.Frontend/.yarnrc.yml +++ /dev/null @@ -1 +0,0 @@ -nodeLinker: node-modules diff --git a/Catalogger.Frontend/package.json b/Catalogger.Frontend/package.json index d1142b3..1df9362 100644 --- a/Catalogger.Frontend/package.json +++ b/Catalogger.Frontend/package.json @@ -17,13 +17,11 @@ "@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltestrap/sveltestrap": "^6.2.7", "@types/eslint": "^9.6.0", - "@types/file-saver": "^2.0.7", "@types/luxon": "^3.4.2", "bootstrap": "^5.3.3", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.36.0", - "file-saver": "^2.0.5", "globals": "^15.0.0", "luxon": "^3.5.0", "marked": "^14.1.3", @@ -39,6 +37,5 @@ "vite": "^5.0.3", "vite-plugin-markdown": "^2.2.0" }, - "type": "module", - "packageManager": "yarn@4.5.1" -} + "type": "module" +} \ No newline at end of file diff --git a/Catalogger.Frontend/src/lib/api.ts b/Catalogger.Frontend/src/lib/api.ts index 05c6d67..9db5318 100644 --- a/Catalogger.Frontend/src/lib/api.ts +++ b/Catalogger.Frontend/src/lib/api.ts @@ -77,12 +77,7 @@ export type FullGuild = { icon_url: string; categories: GuildCategory[]; channels_without_category: GuildChannel[]; - roles: GuildRole[]; - ignored_channels: string[]; - ignored_roles: string[]; - messages: MessageConfig; - channels: ChannelConfig; - key_roles: string[]; + config: GuildConfig; }; export type GuildCategory = { @@ -98,13 +93,6 @@ export type GuildChannel = { can_redirect_from: boolean; }; -export type GuildRole = { - id: string; - name: string; - position: string; - colour: string; -}; - export type CurrentUser = { user: User; guilds: PartialGuild[]; @@ -117,15 +105,14 @@ export type ApiError = { message: string; }; -export type MessageConfig = { +export type GuildConfig = GuildChannelConfig & { ignored_channels: string[]; ignored_users: string[]; - ignored_roles: string[]; ignored_users_per_channel: Record; + redirects: Record; }; -export type ChannelConfig = { - redirects: Record; +export type GuildChannelConfig = { guild_update: string; guild_emojis_update: string; guild_role_create: string; diff --git a/Catalogger.Frontend/src/lib/components/RemovableListItem.svelte b/Catalogger.Frontend/src/lib/components/RemovableListItem.svelte deleted file mode 100644 index 3c63c68..0000000 --- a/Catalogger.Frontend/src/lib/components/RemovableListItem.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/Catalogger.Frontend/src/lib/components/RoleListItem.svelte b/Catalogger.Frontend/src/lib/components/RoleListItem.svelte deleted file mode 100644 index eed5c13..0000000 --- a/Catalogger.Frontend/src/lib/components/RoleListItem.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - {#if typeof role === "string"} - (unknown role {role}) - {:else} - - {role.name} - {/if} - diff --git a/Catalogger.Frontend/src/routes/+layout.svelte b/Catalogger.Frontend/src/routes/+layout.svelte index a33b94e..aae3f1a 100644 --- a/Catalogger.Frontend/src/routes/+layout.svelte +++ b/Catalogger.Frontend/src/routes/+layout.svelte @@ -31,9 +31,7 @@ · Privacy · - Source code + Source code diff --git a/Catalogger.Frontend/src/routes/+page.svelte b/Catalogger.Frontend/src/routes/+page.svelte index 8d3fa9f..f0bed2a 100644 --- a/Catalogger.Frontend/src/routes/+page.svelte +++ b/Catalogger.Frontend/src/routes/+page.svelte @@ -4,7 +4,6 @@ import type { PageData } from "./$types"; import { Button, ButtonGroup } from "@sveltestrap/sveltestrap"; import Message from "./Message.svelte"; - import Screenshot from "./Screenshot.svelte"; export let data: PageData; @@ -69,60 +68,3 @@ {:else}

No news right now.

{/each} - -
- -
- - Catalogger will ignore the deleted messages caused by proxying, while adding - extra data to the proxied messages themselves. - - - You can easily configure Catalogger with its slash commands, or with the - dashboard (you're there right now!) - - - When banning a user, Catalogger will warn you if they have a linked - PluralKit system, and notify you if another account linked to the same - system joins. - - - When members join your server, Catalogger will list the invite they used, - their PluralKit system, and whether their system has been banned before. You - can also add users to a watchlist, and Catalogger will send a warning when - they join. - - - Catalogger can log edited messages too, and ignore the pk;edit - messages that made the edits. Of course, this also shows the system - and member that sent the message. - - - Catalogger can log your server's most important roles separately, and will - show who gave or removed the roles in addition to the target. - -
diff --git a/Catalogger.Frontend/src/routes/+page.ts b/Catalogger.Frontend/src/routes/+page.ts index 189f71e..c8cacf0 100644 --- a/Catalogger.Frontend/src/routes/+page.ts +++ b/Catalogger.Frontend/src/routes/+page.ts @@ -1 +1 @@ -export const prerender = true; +export const prerender = true; \ No newline at end of file diff --git a/Catalogger.Frontend/src/routes/Message.svelte b/Catalogger.Frontend/src/routes/Message.svelte index 0122f57..e07170f 100644 --- a/Catalogger.Frontend/src/routes/Message.svelte +++ b/Catalogger.Frontend/src/routes/Message.svelte @@ -21,7 +21,7 @@ : null; - + {@html content} diff --git a/Catalogger.Frontend/src/routes/Screenshot.svelte b/Catalogger.Frontend/src/routes/Screenshot.svelte deleted file mode 100644 index 623dac1..0000000 --- a/Catalogger.Frontend/src/routes/Screenshot.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -
- - - - - - - - {title} - - - -
diff --git a/Catalogger.Frontend/src/routes/about/contact/+page.svelte b/Catalogger.Frontend/src/routes/about/contact/+page.svelte index e38536f..a2a9650 100644 --- a/Catalogger.Frontend/src/routes/about/contact/+page.svelte +++ b/Catalogger.Frontend/src/routes/about/contact/+page.svelte @@ -1,9 +1,9 @@ - Catalogger - Contact + Catalogger - Contact -{@html html} +{@html html} \ No newline at end of file diff --git a/Catalogger.Frontend/src/routes/about/tos/+page.svelte b/Catalogger.Frontend/src/routes/about/tos/+page.svelte index 678e6fd..bce6743 100644 --- a/Catalogger.Frontend/src/routes/about/tos/+page.svelte +++ b/Catalogger.Frontend/src/routes/about/tos/+page.svelte @@ -1,9 +1,9 @@ - Catalogger - Terms of Service + Catalogger - Terms of Service -{@html html} +{@html html} \ No newline at end of file diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte index 7c1832d..353b812 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte @@ -2,8 +2,33 @@ import { Button, Nav, NavItem, NavLink } from "@sveltestrap/sveltestrap"; import type { LayoutData } from "./$types"; import { page } from "$app/stores"; + import apiFetch, { type ApiError, type GuildConfig } from "$lib/api"; + import { addToast } from "$lib/toast"; export let data: LayoutData; + + // This only saves log channels. All other pages are lists and are saved immediately upon adding/removing an entry. + const save = async () => { + try { + const resp = await apiFetch( + "PATCH", + `/api/guilds/${data.guild.id}`, + data.guild.config, + ); + data.guild.config = resp; + + addToast({ + header: "Saved log channels.", + body: "Successfully edited log channels and ignored channels.", + }); + } catch (e) { + addToast({ + header: "Error saving changes to log channels", + body: + (e as ApiError).message || "Unknown error. Please try again later.", + }); + } + }; @@ -26,29 +51,24 @@ Redirects - Ignored messages + Ignored channels - Ignored channels/roles + Ignored users + Key roles - - Import/export settings - + + {#if $page.url.pathname === `/dash/${data.guild.id}` || $page.url.pathname === `/dash/${data.guild.id}/ignored-channels`} + + {/if} diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte index 793c966..695b0e9 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte @@ -1,42 +1,15 @@ -
-

Log channels

- -
+

Log channels

diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/RoleSelect.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/RoleSelect.svelte deleted file mode 100644 index 3dfc5c9..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/RoleSelect.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/delete/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/delete/+page.svelte index a1a55a0..84b4862 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/delete/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/delete/+page.svelte @@ -1,10 +1,9 @@

Delete this server's data

To make Catalogger leave your server and delete all data from your server, - fill its name ({data.guild.name}) in below and press - "Delete". + fill its name in below and press "Delete".
- You will get a backup of your server's settings which you can restore later if - you change your mind. + + This is irreversible. If you change your mind later, your data cannot be + restored. + +
+ If you just want to make Catalogger leave your server but not delete data, simply + kick it via Discord.

-

- - Message data is not backed up. If you change your mind, we cannot restore it - for you. - -

+ +

This is irreversible!

+ + We cannot help you recover data deleted in this way. +

diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-channels/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-channels/+page.svelte new file mode 100644 index 0000000..fe0ea57 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-channels/+page.svelte @@ -0,0 +1,113 @@ + + +

Ignored channels

+ +

+ Messages from ignored channels will not be logged. Note that this does not + ignore channel update events, any changes to the channel will still be logged. +

+ +
+ + +
+ +
+ +
+ +
+

Currently ignored channels

+
+ + + {#each ignored as id} + + {channelName(id)} + + + {/each} + diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-entities/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-entities/+page.svelte deleted file mode 100644 index 33cc720..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-entities/+page.svelte +++ /dev/null @@ -1,205 +0,0 @@ - - -

Ignored channels and roles

- -

- Events related to these channels and roles (such as them being edited, them - being deleted, and them being added to users) will not be logged. -

- -

- If you want to ignore messages from channels and roles, go to - ignored messages instead. -

- -

Channels

- -
- -
- -
- -
- - - {#each ignoredChannels as id} - removeChannelIgnore(id)} - buttonText="Stop ignoring" - > - {channelName(id)} - - {:else} - No channels are being ignored right now. - {/each} - - -

Roles

- -
- -
- -
- -
- - - {#each ignoredRoles as id} - removeRoleIgnore(id)} - buttonText="Stop ignoring" - /> - {:else} - No roles are being ignored right now. - {/each} - diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.svelte deleted file mode 100644 index 7632941..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.svelte +++ /dev/null @@ -1,330 +0,0 @@ - - -

Ignored messages

- -

Here you can select which channels and roles to ignore messages from.

- -

How it works

- -

Messages will be ignored if:

- -
    -
  • They are posted in a channel that is listed here
  • -
  • - They are posted in a channel in a category that is listed here -
  • -
  • They are posted in a thread that's in a channel that is listed here
  • -
  • - They are posted by a person with at least one role that is listed here -
  • -
  • They are posted by a user that is listed here
  • -
- -

- Additionally, messages from ignored users are also ignored. -

- -

Channels

- -
- -
- -
- -
- - - {#each ignoredChannels as id} - removeChannelIgnore(id)} - buttonText="Stop ignoring" - > - {channelName(id)} - - {:else} - - No channels are having their messages ignored right now. - - {/each} - - -

Roles

- -
- -
- -
- -
- - - {#each ignoredRoles as id} - removeRoleIgnore(id)} - buttonText="Stop ignoring" - /> - {:else} - - No roles are having their messages ignored right now. - - {/each} - - -

Users

- -
- - {#if userToIgnore && !userIdRegex.test(userToIgnore)} -

- If you're not ignoring a member of your server, you need to give a - user ID, not their username. -

- {/if} -
- -
- -
- - - {#each data.users as user (user.id)} - removeUserIgnore(user.id)} - buttonText="Stop ignoring" - > - {user.tag} (ID: {user.id}) - - {:else} - No users are being ignored right now. - {/each} - diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.ts b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.ts deleted file mode 100644 index 8f793f5..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.ts +++ /dev/null @@ -1,10 +0,0 @@ -import apiFetch from "$lib/api"; - -export const load = async ({ params }) => { - const users = await apiFetch>( - "GET", - `/api/guilds/${params.guildId}/ignored-users`, - ); - - return { users }; -}; diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-users/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-users/+page.svelte new file mode 100644 index 0000000..215452e --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-users/+page.svelte @@ -0,0 +1,7 @@ + + +

Ignored users

+ +

This page is still under construction!

diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte deleted file mode 100644 index 4c32d95..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - -

Import and export settings

- -

- You can create a backup of your server's configuration here. If you removed - the bot from your server before, you can import the backup you got then here - too. -

- - -

This will overwrite existing settings

- If you have already made some changes to this server's settings, they will be lost - when importing a backup. -
- -

- -

- - - - - diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte index a903960..6f940a6 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte @@ -1,96 +1,7 @@

Key roles

-

- Key roles are logged separately from other roles, and also log who added - or removed the role. Useful for moderator roles. -

- -
- - -
- -
- -
- -
-

Current key roles

- - - {#each keyRoles as role (role.id)} - removeRole(role.id)} /> - {/each} - -
+

This page is still under construction!

diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte index 238adce..bfff7cd 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte @@ -1,16 +1,20 @@