diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b315638..d2b1b96 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,14 +3,14 @@ "isRoot": true, "tools": { "csharpier": { - "version": "0.29.2", + "version": "0.30.6", "commands": [ "dotnet-csharpier" ], "rollForward": false }, "husky": { - "version": "0.7.1", + "version": "0.7.2", "commands": [ "husky" ], diff --git a/.editorconfig b/.editorconfig index d20e217..1e5c57b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,4 +14,7 @@ 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 \ No newline at end of file +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 diff --git a/.gitignore b/.gitignore index 7d908b7..383eadd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ 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 10eef95..4ea96ec 100644 --- a/.idea/.idea.catalogger/.idea/sqldialects.xml +++ b/.idea/.idea.catalogger/.idea/sqldialects.xml @@ -2,6 +2,7 @@ + \ No newline at end of file diff --git a/Catalogger.Backend/Api/ApiCache.cs b/Catalogger.Backend/Api/ApiCache.cs index b790643..c4161ad 100644 --- a/Catalogger.Backend/Api/ApiCache.cs +++ b/Catalogger.Backend/Api/ApiCache.cs @@ -14,43 +14,11 @@ // 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, IDiscordRestChannelAPI channelApi, Config config) +public class ApiCache(RedisService redisService) { - 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 abde930..c63e04e 100644 --- a/Catalogger.Backend/Api/DiscordRequestService.cs +++ b/Catalogger.Backend/Api/DiscordRequestService.cs @@ -30,8 +30,10 @@ 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, @@ -82,8 +84,9 @@ 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 new file mode 100644 index 0000000..f808b0c --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.Backup.cs @@ -0,0 +1,102 @@ +// 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 new file mode 100644 index 0000000..4114d5a --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs @@ -0,0 +1,166 @@ +// 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.KeyRoles.cs b/Catalogger.Backend/Api/GuildsController.KeyRoles.cs new file mode 100644 index 0000000..f1e786e --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.KeyRoles.cs @@ -0,0 +1,63 @@ +// 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 9ce84de..4131343 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.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); return NoContent(); } @@ -80,7 +80,7 @@ public partial class GuildsController ); guildConfig.Channels.Redirects.Remove(channelId, out _); - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); return NoContent(); } diff --git a/Catalogger.Backend/Api/GuildsController.Remove.cs b/Catalogger.Backend/Api/GuildsController.Remove.cs index 2d2ff5e..1002efa 100644 --- a/Catalogger.Backend/Api/GuildsController.Remove.cs +++ b/Catalogger.Backend/Api/GuildsController.Remove.cs @@ -14,12 +14,15 @@ // 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; @@ -40,6 +43,8 @@ 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); @@ -50,15 +55,25 @@ 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 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. + """ ) .WithColour(DiscordUtils.Red) .WithCurrentTimestamp() .Build() .GetOrThrow(); - await webhookExecutor.SendLogAsync(logChannelId.Value, [embed], []); + 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]); } else { @@ -115,17 +130,9 @@ 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 NoContent(); + return Ok(export); } public record LeaveGuildRequest(string Name); diff --git a/Catalogger.Backend/Api/GuildsController.Ignores.cs b/Catalogger.Backend/Api/GuildsController.Users.cs similarity index 50% rename from Catalogger.Backend/Api/GuildsController.Ignores.cs rename to Catalogger.Backend/Api/GuildsController.Users.cs index ea9e647..e67cb55 100644 --- a/Catalogger.Backend/Api/GuildsController.Ignores.cs +++ b/Catalogger.Backend/Api/GuildsController.Users.cs @@ -13,6 +13,9 @@ // 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; @@ -21,44 +24,71 @@ namespace Catalogger.Backend.Api; public partial class GuildsController { - [HttpPut("ignored-channels/{channelId}")] - public async Task AddIgnoredChannelAsync(string id, ulong channelId) + [HttpGet("ignored-users")] + public async Task GetIgnoredUsersAsync(string id, CancellationToken ct = default) { + 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); - if (guildConfig.Channels.IgnoredChannels.Contains(channelId)) - return NoContent(); + var output = new List(); + foreach (var userId in guildConfig.Messages.IgnoredUsers) + { + if (cts.Token.IsCancellationRequested) + break; - 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 + var member = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId)); + output.Add( + new IgnoredUser( + Id: userId, + Tag: member != null ? member.User.Value.Tag() : "unknown user" + ) ); - if (channel == null) - return NoContent(); + } - guildConfig.Channels.IgnoredChannels.Add(channelId); - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); - - return NoContent(); + return Ok(output.OrderBy(i => i.Id)); } - [HttpDelete("ignored-channels/{channelId}")] - public async Task RemoveIgnoredChannelAsync(string id, ulong channelId) + private record IgnoredUser(ulong Id, string Tag); + + [HttpPut("ignored-users/{userId}")] + public async Task AddIgnoredUserAsync(string id, ulong userId) { var (guildId, _) = await ParseGuildAsync(id); var guildConfig = await guildRepository.GetAsync(guildId); - guildConfig.Channels.IgnoredChannels.Remove(channelId); - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + 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); return NoContent(); } @@ -73,35 +103,4 @@ 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.cs b/Catalogger.Backend/Api/GuildsController.cs index 560456f..73d81ef 100644 --- a/Catalogger.Backend/Api/GuildsController.cs +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -19,6 +19,7 @@ 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; @@ -33,12 +34,13 @@ public partial class GuildsController( ILogger logger, DatabaseConnection dbConn, GuildRepository guildRepository, - GuildCache guildCache, - EmojiCache emojiCache, + InviteRepository inviteRepository, + WatchlistRepository watchlistRepository, ChannelCache channelCache, RoleCache roleCache, IMemberCache memberCache, IInviteCache inviteCache, + UserCache userCache, DiscordRequestService discordRequestService, IDiscordRestUserAPI userApi, WebhookExecutorService webhookExecutor @@ -91,6 +93,16 @@ 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, @@ -98,7 +110,12 @@ public partial class GuildsController( guild.IconUrl, categories, channelsWithoutCategories, - guildConfig.Channels + roles, + guildConfig.IgnoredChannels, + guildConfig.IgnoredRoles, + guildConfig.Messages, + guildConfig.Channels, + guildConfig.KeyRoles ) ); } @@ -122,13 +139,20 @@ public partial class GuildsController( string IconUrl, IEnumerable Categories, IEnumerable ChannelsWithoutCategory, - Database.Models.Guild.ChannelConfig Config + IEnumerable Roles, + List IgnoredChannels, + List IgnoredRoles, + Database.Models.Guild.MessageConfig Messages, + Database.Models.Guild.ChannelConfig Channels, + List KeyRoles ); 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)] @@ -141,28 +165,6 @@ 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 @@ -316,12 +318,11 @@ public partial class GuildsController( ) guildConfig.Channels.MessageDeleteBulk = req.MessageDeleteBulk ?? 0; - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); 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 17d3c27..9d7a7b1 100644 --- a/Catalogger.Backend/Api/MetaController.cs +++ b/Catalogger.Backend/Api/MetaController.cs @@ -65,6 +65,10 @@ 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 47447dc..5d2c888 100644 --- a/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs +++ b/Catalogger.Backend/Api/Middleware/AuthenticationMiddleware.cs @@ -16,12 +16,10 @@ 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, IClock clock) - : IMiddleware +public class AuthenticationMiddleware(ApiTokenRepository tokenRepository) : 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 fcf54b9..7f2ec0d 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommands.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommands.cs @@ -43,6 +43,7 @@ public class ChannelCommands( Config config, GuildRepository guildRepository, GuildCache guildCache, + GuildFetchService guildFetchService, ChannelCache channelCache, IMemberCache memberCache, IFeedbackService feedbackService, @@ -68,8 +69,11 @@ public class ChannelCommands( public async Task CheckPermissionsAsync() { var (userId, guildId) = contextInjection.GetUserAndGuild(); + if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + { + return CataloggerError.Result($"Guild {guildId} not in cache"); + } var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}"); @@ -78,8 +82,18 @@ public class ChannelCommands( DiscordSnowflake.New(config.Discord.ApplicationId) ); var currentUser = await memberCache.TryGetAsync(guildId, userId); + if (botUser == null || currentUser == null) - throw new CataloggerError("Bot member or invoking member not found in cache"); + { + // 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"); + } // We don't want to check categories or threads var guildChannels = channelCache @@ -204,7 +218,7 @@ public class ChannelCommands( { var (userId, guildId) = contextInjection.GetUserAndGuild(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -222,6 +236,93 @@ 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, @@ -357,208 +458,9 @@ 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 1bda457..1104c82 100644 --- a/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs +++ b/Catalogger.Backend/Bot/Commands/ChannelCommandsComponents.cs @@ -45,20 +45,117 @@ 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) - throw new CataloggerError("No context"); + return CataloggerError.Result("No context"); if (!ctx.TryGetUserID(out var userId)) - throw new CataloggerError("No user ID in context"); + return CataloggerError.Result("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - throw new CataloggerError("No message ID in context"); + return CataloggerError.Result("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - throw new CataloggerError("No guild ID in context"); + return CataloggerError.Result("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildChannels = channelCache.GuildChannels(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); @@ -82,9 +179,9 @@ public class ChannelCommandsComponents( ); case "reset": if (lease.Data.CurrentPage == null) - throw new CataloggerError("CurrentPage was null in reset button callback"); + return CataloggerError.Result("CurrentPage was null in reset button callback"); if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - throw new CataloggerError( + return CataloggerError.Result( $"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'" ); @@ -164,7 +261,7 @@ public class ChannelCommandsComponents( throw new ArgumentOutOfRangeException(); } - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); goto case "return"; case "return": var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig); @@ -176,71 +273,7 @@ public class ChannelCommandsComponents( 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) - ); + return Result.Success; } [SelectMenu("config-channels")] @@ -248,15 +281,15 @@ public class ChannelCommandsComponents( public async Task OnMenuSelectionAsync(IReadOnlyList channels) { if (contextInjection.Context is not IInteractionCommandContext ctx) - throw new CataloggerError("No context"); + return CataloggerError.Result("No context"); if (!ctx.TryGetUserID(out var userId)) - throw new CataloggerError("No user ID in context"); + return CataloggerError.Result("No user ID in context"); if (!ctx.Interaction.Message.TryGet(out var msg)) - throw new CataloggerError("No message ID in context"); + return CataloggerError.Result("No message ID in context"); if (!ctx.TryGetGuildID(out var guildId)) - throw new CataloggerError("No guild ID in context"); + return CataloggerError.Result("No guild ID in context"); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildConfig = await guildRepository.GetAsync(guildId); var channelId = channels[0].ID.ToUlong(); @@ -272,7 +305,7 @@ public class ChannelCommandsComponents( } if (!Enum.TryParse(lease.Data.CurrentPage, out var channelType)) - throw new CataloggerError( + return CataloggerError.Result( $"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'" ); @@ -351,7 +384,7 @@ public class ChannelCommandsComponents( throw new ArgumentOutOfRangeException(); } - await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); List embeds = [ diff --git a/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs b/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs deleted file mode 100644 index 63dce5e..0000000 --- a/Catalogger.Backend/Bot/Commands/IgnoreChannelCommands.cs +++ /dev/null @@ -1,210 +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("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 new file mode 100644 index 0000000..7e8987c --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs @@ -0,0 +1,304 @@ +// 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 new file mode 100644 index 0000000..b61fabc --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Channels.cs @@ -0,0 +1,213 @@ +// 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 new file mode 100644 index 0000000..2cc46b7 --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs @@ -0,0 +1,122 @@ +// 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 new file mode 100644 index 0000000..15ae280 --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs @@ -0,0 +1,124 @@ +// 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 77757f6..6ec6991 100644 --- a/Catalogger.Backend/Bot/Commands/InviteCommands.cs +++ b/Catalogger.Backend/Bot/Commands/InviteCommands.cs @@ -43,6 +43,7 @@ public class InviteCommands( InviteRepository inviteRepository, GuildCache guildCache, IInviteCache inviteCache, + UserCache userCache, IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, FeedbackService feedbackService, @@ -58,7 +59,7 @@ public class InviteCommands( var (userId, guildId) = contextInjection.GetUserAndGuild(); var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); if (!guildCache.TryGet(guildId, out var guild)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId); @@ -113,21 +114,22 @@ 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("What to name the new invite")] string? name = null, + [Description("How long the invite should be valid for")] InviteDuration? duration = null ) { var (userId, guildId) = contextInjection.GetUserAndGuild(); var inviteResult = await channelApi.CreateChannelInviteAsync( channel.ID, - maxAge: TimeSpan.Zero, + maxAge: duration?.ToTimespan() ?? TimeSpan.Zero, isUnique: true, - reason: $"Create invite command by {userId}" + reason: $"Create invite command by {await userCache.TryFormatUserAsync(userId, addMention: false)}" ); if (inviteResult.Error != null) { @@ -144,17 +146,20 @@ 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}" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}{durationText}" ); 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}" + + $"\nLink: https://discord.gg/{inviteResult.Entity.Code}{durationText}" ); } @@ -253,3 +258,51 @@ 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 67d3ab8..dd2ff90 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)) - throw new CataloggerError("Guild not in cache"); + return CataloggerError.Result("Guild not in cache"); var guildRoles = roleCache.GuildRoles(guildId).ToList(); var guildConfig = await guildRepository.GetAsync(guildId); - if (guildConfig.KeyRoles.Length == 0) + if (guildConfig.KeyRoles.Count == 0) return await feedbackService.ReplyAsync( "There are no key roles to list. Add some with `/key-roles add`.", isEphemeral: true @@ -76,13 +76,16 @@ public class KeyRoleCommands( [Command("add")] [Description("Add a new key role.")] public async Task AddKeyRoleAsync( - [Description("The role to add.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId + [Option("role")] + [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) - throw new CataloggerError("Role is not cached"); + return CataloggerError.Result("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.Any(id => role.ID.Value == id)) @@ -91,20 +94,24 @@ public class KeyRoleCommands( isEphemeral: true ); - await guildRepository.AddKeyRoleAsync(guildId, role.ID); + guildConfig.KeyRoles.Add(role.ID.Value); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); return await feedbackService.ReplyAsync($"Added {role.Name} to this server's key roles!"); } [Command("remove")] [Description("Remove a key role.")] public async Task RemoveKeyRoleAsync( - [Description("The role to remove.")] [DiscordTypeHint(TypeHint.Role)] Snowflake roleId + [Option("role")] + [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) - throw new CataloggerError("Role is not cached"); + return CataloggerError.Result("Role is not cached"); var guildConfig = await guildRepository.GetAsync(guildId); if (guildConfig.KeyRoles.All(id => role.ID != id)) @@ -113,7 +120,8 @@ public class KeyRoleCommands( isEphemeral: true ); - await guildRepository.RemoveKeyRoleAsync(guildId, role.ID); + guildConfig.KeyRoles.Remove(role.ID.Value); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); 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 8f516f1..8b543af 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( - $"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" + $"{BuildInfo.Version}, {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" ) .WithCurrentTimestamp(); embed.AddField( @@ -209,8 +209,7 @@ 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", - false + + $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis" ); IEmbed[] embeds = [embed.Build().GetOrThrow()]; @@ -219,7 +218,7 @@ public class MetaCommands( await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); } - // TODO: add more checks around response format, configurable prometheus endpoint + // TODO: add more checks around response format private async Task MessagesRate() { if (!config.Logging.EnableMetrics) @@ -228,7 +227,8 @@ public class MetaCommands( try { var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); - var resp = await _client.GetAsync($"http://localhost:9090/api/v1/query?query={query}"); + var prometheusUrl = config.Logging.PrometheusUrl ?? "http://localhost:9090"; + var resp = await _client.GetAsync($"{prometheusUrl}/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 c776675..3864c54 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.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); 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.UpdateChannelConfigAsync(guildId, guildConfig.Channels); + await guildRepository.UpdateConfigAsync(guildId, guildConfig); 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)) - throw new CataloggerError("Guild was not cached"); + return CataloggerError.Result("Guild not in cache"); 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 new file mode 100644 index 0000000..c92a886 --- /dev/null +++ b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs @@ -0,0 +1,136 @@ +// 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 8e0a867..b6512d1 100644 --- a/Catalogger.Backend/Bot/DiscordUtils.cs +++ b/Catalogger.Backend/Bot/DiscordUtils.cs @@ -44,4 +44,28 @@ 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 93666fe..0fc9d7f 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelCreateResponder.cs @@ -35,6 +35,8 @@ 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); @@ -97,8 +99,11 @@ public class ChannelCreateResponder( var guildConfig = await guildRepository.GetAsync(ch.GuildID); webhookExecutor.QueueLog( - guildConfig, - LogChannelType.ChannelCreate, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.ChannelCreate, + channelId: ch.ID + ), 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 aaee939..06b4727 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelDeleteResponder.cs @@ -35,6 +35,8 @@ 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); @@ -68,8 +70,11 @@ public class ChannelDeleteResponder( embed.AddField("Description", topic); webhookExecutor.QueueLog( - guildConfig, - LogChannelType.ChannelDelete, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.ChannelDelete, + channelId: channel.ID + ), 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 0ad0c08..5f0ef62 100644 --- a/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Channels/ChannelUpdateResponder.cs @@ -40,6 +40,8 @@ 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)) @@ -179,9 +181,13 @@ 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( - guildConfig, - LogChannelType.ChannelUpdate, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.ChannelUpdate, + channelId: evt.ID + ), builder.Build().GetOrThrow() ); diff --git a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs index b038cfe..1fd3b9e 100644 --- a/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs +++ b/Catalogger.Backend/Bot/Responders/CustomInteractionResponder.cs @@ -13,21 +13,27 @@ // 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. +/// Wrapper for Remora.Discord's default interaction responder, that ignores all events if test mode is enabled, +/// and handles results returned by commands. /// public class CustomInteractionResponder( Config config, @@ -45,34 +51,78 @@ 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 gatewayEvent, - CancellationToken ct = default - ) + public async Task RespondAsync(IInteractionCreate evt, CancellationToken ct = default) { if (config.Discord.TestMode) { _logger.Information( "Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled", - gatewayEvent.ID, - gatewayEvent.Channel.Map(c => c.ID).OrDefault() + evt.ID, + evt.Channel.Map(c => c.ID).OrDefault() ); return Result.Success; } - return await _inner.RespondAsync(gatewayEvent, ct); + 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 + ); } } diff --git a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs index 1d88db1..fba79eb 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/AuditLogResponder.cs @@ -14,6 +14,7 @@ // 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; @@ -28,6 +29,8 @@ 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 0100b1f..2feb745 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanAddResponder.cs @@ -37,6 +37,7 @@ 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 @@ -76,7 +77,12 @@ public class GuildBanAddResponder( evt.GuildID ); - await guildRepository.BanSystemAsync(evt.GuildID, pkSystem.Id, pkSystem.Uuid); + await guildRepository.BanSystemAsync( + evt.GuildID, + evt.User.ID, + 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 ee1d1bc..cc50908 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildBanRemoveResponder.cs @@ -37,6 +37,7 @@ 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 @@ -67,20 +68,52 @@ public class GuildBanRemoveResponder( var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct); if (pkSystem != null) { - 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. - """ + await guildRepository.UnbanSystemAsync( + evt.GuildID, + evt.User.ID, + pkSystem.Id, + pkSystem.Uuid ); + + 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 ab12125..55bac7a 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildCreateResponder.cs @@ -35,6 +35,7 @@ public class GuildCreateResponder( RoleCache roleCache, IMemberCache memberCache, IInviteCache inviteCache, + IWebhookCache webhookCache, WebhookExecutorService webhookExecutor, GuildFetchService guildFetchService ) : IResponder, IResponder @@ -43,6 +44,8 @@ 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)) @@ -100,6 +103,8 @@ 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); @@ -107,14 +112,18 @@ public class GuildCreateResponder( } // Clear the cache for this guild - guildCache.Remove(evt.ID, out _); + var wasCached = guildCache.Remove(evt.ID, out var guild); emojiCache.Remove(evt.ID); channelCache.RemoveGuild(evt.ID); roleCache.RemoveGuild(evt.ID); await memberCache.RemoveAllMembersAsync(evt.ID); await inviteCache.RemoveAsync(evt.ID); - if (!guildCache.TryGet(evt.ID, out var guild)) + // Also clear the webhook cache + var guildConfig = await guildRepository.GetAsync(evt.ID); + await webhookCache.RemoveWebhooksAsync(guildConfig.Channels.AllChannels); + + if (!wasCached || guild == null) { _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 0ccc859..c70bd41 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildEmojisUpdateResponder.cs @@ -37,6 +37,8 @@ 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 8a7c786..8a43b46 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs @@ -14,6 +14,7 @@ // 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; @@ -27,6 +28,8 @@ 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 ee43e98..b2992c4 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildUpdateResponder.cs @@ -37,6 +37,8 @@ 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 329d764..d50f81d 100644 --- a/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Invites/InviteCreateResponder.cs @@ -37,6 +37,7 @@ 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 bb9d855..d34b3c8 100644 --- a/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Invites/InviteDeleteResponder.cs @@ -38,6 +38,7 @@ 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 5ea725a..6d8901c 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs @@ -48,6 +48,8 @@ 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]); @@ -126,7 +128,7 @@ public class GuildMemberAddResponder( goto afterInvite; } - var inviteName = inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); + var inviteName = await inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); var inviteDescription = $""" **Code:** {usedInvite.Code} @@ -156,7 +158,7 @@ public class GuildMemberAddResponder( ); } - var watchlist = await watchlistRepository.GetWatchlistEntryAsync(member.GuildID, user.ID); + var watchlist = await watchlistRepository.GetEntryAsync(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 94cf8cc..a2e61d1 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs @@ -39,6 +39,8 @@ 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 4a6e6b8..649c945 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs @@ -15,9 +15,11 @@ 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; @@ -30,6 +32,8 @@ namespace Catalogger.Backend.Bot.Responders.Members; public class GuildMemberUpdateResponder( ILogger logger, GuildRepository guildRepository, + TimeoutRepository timeoutRepository, + TimeoutService timeoutService, UserCache userCache, RoleCache roleCache, IMemberCache memberCache, @@ -44,6 +48,8 @@ public class GuildMemberUpdateResponder( CancellationToken ct = default ) { + using var _ = LogUtils.Enrich(newMember); + try { var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID); @@ -245,11 +251,15 @@ 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); @@ -261,6 +271,27 @@ 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, @@ -286,19 +317,20 @@ public class GuildMemberUpdateResponder( .WithFooter($"User ID: {member.User.ID}") .WithCurrentTimestamp(); - var addedRoles = member.Roles.Except(oldRoles).Select(s => s.Value).ToList(); - var removedRoles = oldRoles.Except(member.Roles).Select(s => s.Value).ToList(); + var addedRoles = member.Roles.Except(oldRoles).ToList(); + var removedRoles = oldRoles.Except(member.Roles).ToList(); if (addedRoles.Count != 0) { roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>"))); // Add all added key roles to the log - if (!addedRoles.Except(guildConfig.KeyRoles).Any()) + if (!addedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", addedRoles + .Select(s => s.Value) .Where(guildConfig.KeyRoles.Contains) .Select(id => { @@ -319,11 +351,12 @@ public class GuildMemberUpdateResponder( ); // Add all removed key roles to the log - if (!removedRoles.Except(guildConfig.KeyRoles).Any()) + if (!removedRoles.Select(s => s.Value).Except(guildConfig.KeyRoles).Any()) { var value = string.Join( "\n", removedRoles + .Select(s => s.Value) .Where(guildConfig.KeyRoles.Contains) .Select(id => { @@ -332,7 +365,7 @@ public class GuildMemberUpdateResponder( }) ); - keyRoleUpdate.AddField("Added", value); + keyRoleUpdate.AddField("Removed", value); } } @@ -340,8 +373,12 @@ public class GuildMemberUpdateResponder( if (roleUpdate.Fields.Count != 0) { webhookExecutor.QueueLog( - guildConfig, - LogChannelType.GuildMemberUpdate, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.GuildMemberUpdate, + // Check for all added and removed roles + roleIds: addedRoles.Concat(removedRoles).ToList() + ), roleUpdate.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs index cc45edc..e54d12b 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageCreateResponder.cs @@ -38,6 +38,8 @@ public class MessageCreateResponder( public async Task RespondAsync(IMessageCreate msg, CancellationToken ct = default) { + using var __ = LogUtils.Enrich(msg); + userCache.UpdateUser(msg.Author); CataloggerMetrics.MessagesReceived.Inc(); @@ -53,7 +55,13 @@ 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)) + if ( + guild.IsMessageIgnored( + msg.ChannelID, + msg.Author.ID, + msg.Member.OrDefault()?.Roles.OrDefault() + ) + ) { await messageRepository.IgnoreMessageAsync(msg.ID.Value); return Result.Success; @@ -69,7 +77,7 @@ public class MessageCreateResponder( return Result.Success; } - await messageRepository.SaveMessageAsync(msg, ct); + await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct); return Result.Success; } } @@ -88,18 +96,11 @@ 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(); @@ -108,12 +109,7 @@ 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); @@ -148,16 +144,33 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) await using var messageRepository = scope.ServiceProvider.GetRequiredService(); - await Task.WhenAll( - messageRepository.SetProxiedMessageDataAsync( + if (await messageRepository.IsMessageIgnoredAsync(originalId)) + { + _logger.Debug( + "Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored", msgId, - originalId, - authorId, - systemId: match.Groups[1].Value, - memberId: match.Groups[2].Value - ), - messageRepository.IgnoreMessageAsync(originalId) + originalId + ); + + await messageRepository.IgnoreMessageAsync(originalId); + await messageRepository.IgnoreMessageAsync(msgId); + return; + } + + _logger.Debug( + "Setting proxy data for {MessageId} and ignoring {OriginalId}", + msgId, + 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) @@ -189,15 +202,32 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services) return; } - await Task.WhenAll( - messageRepository.SetProxiedMessageDataAsync( - msgId, - pkMessage.Original, - pkMessage.Sender, - pkMessage.System?.Id, - pkMessage.Member?.Id - ), - messageRepository.IgnoreMessageAsync(pkMessage.Original) + _logger.Debug( + "Setting proxy data for {MessageId} and ignoring {OriginalId}", + msgId, + 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 6676839..157d30d 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteBulkResponder.cs @@ -41,8 +41,10 @@ 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)) + if (guild.IsMessageIgnored(evt.ChannelID, null, 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 f866b94..55a5a68 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageDeleteResponder.cs @@ -18,7 +18,6 @@ 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; @@ -37,7 +36,6 @@ public class MessageDeleteResponder( WebhookExecutorService webhookExecutor, ChannelCache channelCache, UserCache userCache, - IClock clock, PluralkitApiService pluralkitApi ) : IResponder { @@ -48,6 +46,8 @@ 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,27 +64,22 @@ 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) { - if (logChannel == null) - return Result.Success; + _logger.Debug( + "Deleted message {MessageId} should be logged but is not in the database", + evt.ID + ); + webhookExecutor.QueueLog( - logChannel.Value, + webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, evt.ChannelID), 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}"), - Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() + Footer: new EmbedFooter(Text: $"ID: {evt.ID} | Original sent at"), + Timestamp: evt.ID.Timestamp ) ); @@ -107,21 +102,26 @@ public class MessageDeleteResponder( } } - logChannel = webhookExecutor.GetLogChannel( + var logChannel = webhookExecutor.GetLogChannel( guild, LogChannelType.MessageDelete, evt.ChannelID, msg.UserId ); - if (logChannel == null) - return Result.Success; + if (logChannel is null or 0) + { + _logger.Debug( + "Message {MessageId} should not be logged; either ignored or message delete logs are disabled", + evt.ID + ); + } 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}") + .WithFooter($"ID: {msg.Id} | Original sent at") .WithTimestamp(evt.ID); if (user != null) @@ -173,7 +173,7 @@ public class MessageDeleteResponder( builder.AddField("Attachments", attachmentInfo, false); } - webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow()); + webhookExecutor.QueueLog(logChannel, 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 1dd9290..0bf2c28 100644 --- a/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Messages/MessageUpdateResponder.cs @@ -20,7 +20,6 @@ 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; @@ -40,11 +39,9 @@ public class MessageUpdateResponder( { private readonly ILogger _logger = logger.ForContext(); - public async Task RespondAsync(IMessageUpdate evt, CancellationToken ct = default) + public async Task RespondAsync(IMessageUpdate msg, CancellationToken ct = default) { - // 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); + using var _ = LogUtils.Enrich(msg); if (!msg.GuildID.IsDefined()) { @@ -58,10 +55,7 @@ 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 { @@ -135,7 +129,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**", false); + embedBuilder.AddField("\u200b", "**PluralKit information**"); embedBuilder.AddField("System ID", oldMessage.System, true); embedBuilder.AddField("Member ID", oldMessage.Member, true); } @@ -175,7 +169,7 @@ public class MessageUpdateResponder( ) { if ( - !await messageRepository.SaveMessageAsync(msg, ct) + !await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct) && msg.ApplicationID.Is(DiscordUtils.PkUserId) ) { @@ -197,44 +191,6 @@ 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 192ce72..8c43140 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 gatewayEvent, CancellationToken ct = default) + public Task RespondAsync(IReady evt, CancellationToken ct = default) { - var shardId = gatewayEvent.Shard.TryGet(out var shard) - ? (shard.ShardID, shard.ShardCount) - : (0, 1); + using var _ = LogUtils.Enrich(evt); + + var shardId = evt.Shard.TryGet(out var shard) ? (shard.ShardID, shard.ShardCount) : (0, 1); _logger.Information( "Ready as {User} on shard {ShardId}/{ShardCount}", - gatewayEvent.User.Tag(), + evt.User.Tag(), shardId.Item1, shardId.Item2 ); if (shardId.Item1 == 0) - webhookExecutorService.SetSelfUser(gatewayEvent.User); + webhookExecutorService.SetSelfUser(evt.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 4df76ea..8d079d7 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleCreateResponder.cs @@ -35,6 +35,8 @@ 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); @@ -54,8 +56,11 @@ public class RoleCreateResponder( } webhookExecutor.QueueLog( - guildConfig, - LogChannelType.GuildRoleCreate, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.GuildRoleCreate, + roleId: evt.Role.ID + ), embed.Build().GetOrThrow() ); diff --git a/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs b/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs index 8566434..5f9b648 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleDeleteResponder.cs @@ -35,6 +35,8 @@ 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)) @@ -70,8 +72,11 @@ public class RoleDeleteResponder( } webhookExecutor.QueueLog( - guildConfig, - LogChannelType.GuildRoleDelete, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.GuildRoleDelete, + roleId: role.ID + ), embed.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs index 828ef22..656bb02 100644 --- a/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Roles/RoleUpdateResponder.cs @@ -37,6 +37,8 @@ public class RoleUpdateResponder( public async Task RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default) { + using var _ = LogUtils.Enrich(evt); + try { var newRole = evt.Role; @@ -96,8 +98,11 @@ public class RoleUpdateResponder( var guildConfig = await guildRepository.GetAsync(evt.GuildID); webhookExecutor.QueueLog( - guildConfig, - LogChannelType.GuildRoleUpdate, + webhookExecutor.GetLogChannel( + guildConfig, + LogChannelType.GuildRoleUpdate, + roleId: evt.Role.ID + ), embed.Build().GetOrThrow() ); } diff --git a/Catalogger.Backend/Bot/ShardedGatewayClient.cs b/Catalogger.Backend/Bot/ShardedGatewayClient.cs index 2de70cd..5621389 100644 --- a/Catalogger.Backend/Bot/ShardedGatewayClient.cs +++ b/Catalogger.Backend/Bot/ShardedGatewayClient.cs @@ -115,7 +115,9 @@ public class ShardedGatewayClient( _logger.Information("Started shard {ShardId}/{ShardCount}", shardIndex, TotalShards); } - return await await Task.WhenAny(tasks); + var taskResult = await await Task.WhenAny(tasks); + Disconnect(); + return taskResult; } public int ShardIdFor(ulong guildId) => (int)((guildId >> 22) % (ulong)TotalShards); @@ -136,6 +138,17 @@ 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 new file mode 100644 index 0000000..95ca05f --- /dev/null +++ b/Catalogger.Backend/BuildInfo.cs @@ -0,0 +1,46 @@ +// 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 4267260..69a256a 100644 --- a/Catalogger.Backend/Cache/IWebhookCache.cs +++ b/Catalogger.Backend/Cache/IWebhookCache.cs @@ -24,6 +24,7 @@ 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 b4f1a2f..3a6208b 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/InMemoryWebhookCache.cs @@ -33,4 +33,11 @@ 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 dfa9694..0e08c1f 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs @@ -234,6 +234,7 @@ 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 9c07f62..0e2962c 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisWebhookCache.cs @@ -26,5 +26,8 @@ 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 436899d..b377418 100644 --- a/Catalogger.Backend/Catalogger.Backend.csproj +++ b/Catalogger.Backend/Catalogger.Backend.csproj @@ -1,44 +1,44 @@ - net8.0 + net9.0 enable enable - true + + + + + + - - + + - + - - - - + + + + - - - + + + - - - - - diff --git a/Catalogger.Backend/CataloggerError.cs b/Catalogger.Backend/CataloggerError.cs index 31abf6f..40322a4 100644 --- a/Catalogger.Backend/CataloggerError.cs +++ b/Catalogger.Backend/CataloggerError.cs @@ -13,6 +13,13 @@ // 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) { } +public class CataloggerError(string message) : Exception(message), IResultError +{ + public static RemoraResult Result(string message) => + RemoraResult.FromError(new CataloggerError(message)); +} diff --git a/Catalogger.Backend/CataloggerMetrics.cs b/Catalogger.Backend/CataloggerMetrics.cs index b8726b5..ce55805 100644 --- a/Catalogger.Backend/CataloggerMetrics.cs +++ b/Catalogger.Backend/CataloggerMetrics.cs @@ -29,6 +29,11 @@ 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" @@ -39,6 +44,11 @@ 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 2a60d6a..831c439 100644 --- a/Catalogger.Backend/Config.cs +++ b/Catalogger.Backend/Config.cs @@ -33,6 +33,7 @@ public class Config public bool EnableMetrics { get; init; } = true; public string? SeqLogUrl { get; init; } + public string? PrometheusUrl { get; init; } } public class DatabaseConfig @@ -60,6 +61,9 @@ 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 61d2a7e..b2488f9 100644 --- a/Catalogger.Backend/Database/DatabaseConnection.cs +++ b/Catalogger.Backend/Database/DatabaseConnection.cs @@ -17,16 +17,13 @@ using System.Data; using System.Data.Common; using System.Diagnostics.CodeAnalysis; using Npgsql; +using Serilog; namespace Catalogger.Backend.Database; -public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) - : DbConnection, - IDisposable +public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposable { - public Guid ConnectionId => id; - private readonly ILogger _logger = logger.ForContext(); - private readonly DateTimeOffset _openTime = DateTimeOffset.UtcNow; + public NpgsqlConnection Inner => inner; private bool _hasClosed; @@ -42,8 +39,6 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) } DatabasePool.DecrementConnections(); - var openFor = DateTimeOffset.UtcNow - _openTime; - _logger.Verbose("Closing connection {ConnId}, open for {OpenFor}", ConnectionId, openFor); _hasClosed = true; await inner.CloseAsync(); } @@ -51,19 +46,22 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) protected override async ValueTask BeginDbTransactionAsync( IsolationLevel isolationLevel, CancellationToken cancellationToken - ) - { - _logger.Verbose("Beginning transaction on connection {ConnId}", ConnectionId); - return await inner.BeginTransactionAsync(isolationLevel, cancellationToken); - } + ) => await inner.BeginTransactionAsync(isolationLevel, cancellationToken); public new void Dispose() { - Close(); - inner.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(); + inner.Dispose(); + } + public override async ValueTask DisposeAsync() { await CloseAsync(); @@ -72,13 +70,13 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) } protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => - inner.BeginTransaction(isolationLevel); + throw new SyncException(nameof(BeginDbTransaction)); public override void ChangeDatabase(string databaseName) => inner.ChangeDatabase(databaseName); - public override void Close() => inner.Close(); + public override void Close() => throw new SyncException(nameof(Close)); - public override void Open() => inner.Open(); + public override void Open() => throw new SyncException(nameof(Open)); [AllowNull] public override string ConnectionString @@ -93,4 +91,6 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) 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 76a0dbc..183723d 100644 --- a/Catalogger.Backend/Database/DatabaseMigrator.cs +++ b/Catalogger.Backend/Database/DatabaseMigrator.cs @@ -19,6 +19,9 @@ using NodaTime; namespace Catalogger.Backend.Database; +/// +/// Manages database migrations. +/// public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection conn) : IDisposable, IAsyncDisposable @@ -26,7 +29,10 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c private const string RootPath = "Catalogger.Backend.Database"; private static readonly int MigrationsPathLength = $"{RootPath}.Migrations.".Length; - public async Task Migrate() + /// + /// Migrates the database to the latest version. + /// + public async Task MigrateUp() { var migrations = GetMigrationNames().ToArray(); logger.Debug("Getting current database migration"); @@ -65,17 +71,62 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c await tx.CommitAsync(); } - private async Task ExecuteMigration(DbTransaction tx, string migrationName) + /// + /// 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) { - var query = await GetResource($"{RootPath}.Migrations.{migrationName}.up.sql"); + 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" + ); // Run the migration await conn.ExecuteAsync(query, transaction: tx); - // 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() } - ); + // 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 } + ); } /// Returns the current migration. If no migrations have been applied, returns null @@ -90,7 +141,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c if (hasMigrationTable) { return await conn.QuerySingleOrDefaultAsync( - "SELECT * FROM migrations ORDER BY applied_at DESC LIMIT 1" + "SELECT * FROM migrations ORDER BY applied_at DESC, migration_name DESC LIMIT 1" ); } @@ -112,7 +163,7 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c return await reader.ReadToEndAsync(); } - public static IEnumerable GetMigrationNames() => + private 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 82072f8..29a845e 100644 --- a/Catalogger.Backend/Database/DatabasePool.cs +++ b/Catalogger.Backend/Database/DatabasePool.cs @@ -24,18 +24,13 @@ 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, ILogger logger, ILoggerFactory? loggerFactory) + public DatabasePool(Config config, ILoggerFactory? loggerFactory) { - _rootLogger = logger; - _logger = logger.ForContext(); - var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { Timeout = config.Database.Timeout ?? 5, @@ -51,24 +46,14 @@ public class DatabasePool public async Task AcquireAsync(CancellationToken ct = default) { - return new DatabaseConnection( - LogOpen(), - _rootLogger, - await _dataSource.OpenConnectionAsync(ct) - ); + IncrementConnections(); + return new DatabaseConnection(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 connId; + return new DatabaseConnection(_dataSource.OpenConnection()); } public async Task ExecuteAsync( @@ -112,10 +97,12 @@ 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: @@ -131,36 +118,34 @@ 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 UlongArrayHandler : SqlMapper.TypeHandler + private class UlongListHandler : SqlMapper.TypeHandler> { - public override void SetValue(IDbDataParameter parameter, ulong[]? value) => - parameter.Value = value != null ? Array.ConvertAll(value, i => (long)i) : null; + public override void SetValue(IDbDataParameter parameter, List? value) => + parameter.Value = value?.Select(i => (long)i).ToArray(); - public override ulong[] Parse(object value) => - Array.ConvertAll((long[])value, i => (ulong)i); + public override List? Parse(object value) => + ((long[])value).Select(i => (ulong)i).ToList(); } - public class JsonTypeHandler : SqlMapper.TypeHandler + private class JsonTypeHandler : SqlMapper.TypeHandler { + public override void SetValue(IDbDataParameter parameter, T? value) => + parameter.Value = JsonSerializer.Serialize(value); + public override T Parse(object value) { - string json = (string)value; + var 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 new file mode 100644 index 0000000..c411602 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/002_store_pk_systems.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..d095e36 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/002_store_pk_systems.up.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..d5f53c0 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/003_store_timeouts.down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..41b4dd5 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/003_store_timeouts.up.sql @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..a9f1de1 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/004_split_message_config.down.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6cd35f0 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/004_split_message_config.up.sql @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..30f833d --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.down.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..8d39d94 --- /dev/null +++ b/Catalogger.Backend/Database/Migrations/005_add_ignored_channels_roles.up.sql @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..dfbe95c --- /dev/null +++ b/Catalogger.Backend/Database/Models/ConfigExport.cs @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..21ed5c5 --- /dev/null +++ b/Catalogger.Backend/Database/Models/DiscordTimeout.cs @@ -0,0 +1,12 @@ +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 9ac8042..0558da2 100644 --- a/Catalogger.Backend/Database/Models/Guild.cs +++ b/Catalogger.Backend/Database/Models/Guild.cs @@ -13,7 +13,6 @@ // 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; @@ -22,23 +21,31 @@ 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 ulong[] KeyRoles { 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 bool IsSystemBanned(PluralkitApiService.PkSystem system) => BannedSystems.Contains(system.Id) || BannedSystems.Contains(system.Uuid.ToString()); - public bool IsMessageIgnored(Snowflake channelId, Snowflake? userId) + public bool IsMessageIgnored( + Snowflake channelId, + Snowflake? userId, + IReadOnlyList? roleIds + ) { if ( Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 } - || Channels.IgnoredChannels.Contains(channelId.ToUlong()) - || (userId != null && Channels.IgnoredUsers.Contains(userId.Value.ToUlong())) + || 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))) ) return true; @@ -46,7 +53,7 @@ public class Guild return false; if ( - Channels.IgnoredUsersPerChannel.TryGetValue( + Messages.IgnoredUsersPerChannel.TryGetValue( channelId.ToUlong(), out var thisChannelIgnoredUsers ) @@ -56,11 +63,16 @@ public class Guild return false; } - public class ChannelConfig + 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 Dictionary Redirects { get; init; } = []; public ulong GuildUpdate { get; set; } @@ -86,5 +98,35 @@ 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 2bbb658..81e94b1 100644 --- a/Catalogger.Backend/Database/Models/Message.cs +++ b/Catalogger.Backend/Database/Models/Message.cs @@ -13,13 +13,10 @@ // 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; } @@ -38,5 +35,3 @@ 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 1ed24af..e8e474c 100644 --- a/Catalogger.Backend/Database/Redis/RedisService.cs +++ b/Catalogger.Backend/Database/Redis/RedisService.cs @@ -24,8 +24,10 @@ 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); @@ -44,6 +46,9 @@ 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 de62b1d..71502e2 100644 --- a/Catalogger.Backend/Database/Repositories/GuildRepository.cs +++ b/Catalogger.Backend/Database/Repositories/GuildRepository.cs @@ -15,6 +15,7 @@ using Catalogger.Backend.Database.Models; using Dapper; +using Remora.Discord.API; using Remora.Rest.Core; namespace Catalogger.Backend.Database.Repositories; @@ -31,7 +32,7 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) public async Task GetAsync(ulong id) { - _logger.Debug("Getting guild config for {GuildId}", id); + _logger.Verbose("Getting guild config for {GuildId}", id); var guild = await conn.QueryFirstOrDefaultAsync( "select * from guilds where id = @Id", @@ -51,20 +52,37 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) public async Task AddGuildAsync(ulong id) => await conn.ExecuteAsync( """ - insert into guilds (id, key_roles, banned_systems, key_roles, channels) - values (@Id, array[]::bigint[], array[]::text[], array[]::bigint[], @Channels) + insert into guilds (id, key_roles, banned_systems, channels) + values (@Id, array[]::bigint[], array[]::text[], @Channels::jsonb) on conflict do nothing """, new { Id = id, Channels = new Guild.ChannelConfig() } ); - public async Task BanSystemAsync(Snowflake guildId, string hid, Guid uuid) => + public async Task BanSystemAsync(Snowflake guildId, Snowflake userId, 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()] } ); - public async Task UnbanSystemAsync(Snowflake guildId, string hid, Guid uuid) => + 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) + { await conn.ExecuteAsync( "update guilds set banned_systems = array_remove(array_remove(banned_systems, @Hid), @Uuid) where id = @Id", new @@ -75,22 +93,70 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn) } ); - public async Task AddKeyRoleAsync(Snowflake guildId, Snowflake roleId) => await conn.ExecuteAsync( - "update guilds set key_roles = array_append(key_roles, @RoleId) where id = @GuildId", - new { GuildId = guildId.Value, RoleId = roleId.Value } + """ + 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, + } ); - public async Task RemoveKeyRoleAsync(Snowflake guildId, Snowflake roleId) => + public async Task ImportConfigAsync( + ulong id, + Guild.ChannelConfig channels, + Guild.MessageConfig messages, + string[] bannedSystems, + List keyRoles + ) => await conn.ExecuteAsync( - "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 } + "update guilds set channels = @channels::jsonb, messages = @messages::jsonb, banned_systems = @bannedSystems, key_roles = @keyRoles where id = @id", + new + { + id, + channels, + messages, + bannedSystems, + keyRoles, + } ); public void Dispose() diff --git a/Catalogger.Backend/Database/Repositories/InviteRepository.cs b/Catalogger.Backend/Database/Repositories/InviteRepository.cs index 9ea7382..914588f 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, guild_id) do update set name = @Name + (@Code, @GuildId, @Name) on conflict (code) do update set name = @Name """, new { @@ -65,6 +65,34 @@ 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 17925c3..fc45e4f 100644 --- a/Catalogger.Backend/Database/Repositories/MessageRepository.cs +++ b/Catalogger.Backend/Database/Repositories/MessageRepository.cs @@ -18,6 +18,7 @@ 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; @@ -63,7 +64,11 @@ public class MessageRepository( /// /// Adds a new message. If the message is already in the database, updates the existing message instead. /// - public async Task SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) + public async Task SaveMessageAsync( + IMessage msg, + Optional guildId, + CancellationToken ct = default + ) { var content = await Task.Run( () => @@ -107,7 +112,9 @@ public class MessageRepository( Id = msg.ID.Value, UserId = msg.Author.ID.Value, ChannelId = msg.ChannelID.Value, - GuildId = msg.GuildID.Map(s => s.Value).OrDefault(), + GuildId = guildId.IsDefined(out var guildIdValue) + ? guildIdValue.Value + : (ulong?)null, Content = content, Username = username, Metadata = metadata, diff --git a/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs b/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs new file mode 100644 index 0000000..f50a177 --- /dev/null +++ b/Catalogger.Backend/Database/Repositories/TimeoutRepository.cs @@ -0,0 +1,84 @@ +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 8177f78..653fd85 100644 --- a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs +++ b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs @@ -33,12 +33,76 @@ public class WatchlistRepository(ILogger logger, DatabaseConnection conn) ) ).ToList(); - public async Task GetWatchlistEntryAsync(Snowflake guildId, Snowflake userId) => + public async Task GetEntryAsync(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 new file mode 100644 index 0000000..330e5f7 --- /dev/null +++ b/Catalogger.Backend/Extensions/LogUtils.cs @@ -0,0 +1,152 @@ +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 176d821..f9a226c 100644 --- a/Catalogger.Backend/Extensions/StartupExtensions.cs +++ b/Catalogger.Backend/Extensions/StartupExtensions.cs @@ -51,19 +51,22 @@ public static class StartupExtensions { var logCfg = new LoggerConfiguration() .Enrich.FromLogContext() - .MinimumLevel.Is(config.Logging.LogEventLevel) + .MinimumLevel.Verbose() + // Most Microsoft.* package logs are needlessly verbose, so we restrict them to INFO level and up + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) // 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); + .WriteTo.Console( + theme: AnsiConsoleTheme.Sixteen, + applyThemeToRedirectedOutput: true, + restrictedToMinimumLevel: config.Logging.LogEventLevel + ); if (config.Logging.SeqLogUrl != null) { @@ -108,6 +111,7 @@ public static class StartupExtensions .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddSingleton() .AddSingleton() .AddSingleton() @@ -117,12 +121,13 @@ public static class StartupExtensions .AddSingleton() .AddSingleton() .AddScoped() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(InMemoryDataService.Instance) - .AddSingleton() .AddTransient() + .AddSingleton() // Background services // GuildFetchService is added as a separate singleton as it's also injected into other services. .AddHostedService(serviceProvider => @@ -186,9 +191,15 @@ 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"); + logger.Information( + "Starting Catalogger.NET {Version} ({Hash})", + BuildInfo.Version, + BuildInfo.Hash + ); CataloggerMetrics.Startup = scope .ServiceProvider.GetRequiredService() @@ -197,10 +208,11 @@ public static class StartupExtensions DatabasePool.ConfigureDapper(); await using var migrator = scope.ServiceProvider.GetRequiredService(); - await migrator.Migrate(); + await migrator.MigrateUp(); var config = scope.ServiceProvider.GetRequiredService(); var slashService = scope.ServiceProvider.GetRequiredService(); + var timeoutService = scope.ServiceProvider.GetRequiredService(); if (config.Discord.TestMode) logger.Warning( @@ -243,6 +255,9 @@ 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) @@ -262,8 +277,6 @@ 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 new file mode 100644 index 0000000..4fa9a94 --- /dev/null +++ b/Catalogger.Backend/JsonUtils.cs @@ -0,0 +1,27 @@ +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 27710e0..6fbf6b3 100644 --- a/Catalogger.Backend/Program.cs +++ b/Catalogger.Backend/Program.cs @@ -15,9 +15,11 @@ 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; @@ -25,6 +27,7 @@ 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; @@ -45,6 +48,7 @@ builder options.JsonSerializerOptions.IncludeFields = true; options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString; + options.JsonSerializerOptions.ConfigureForNodaTime(JsonUtils.NodaTimeSettings); }); builder @@ -62,8 +66,7 @@ builder | GatewayIntents.GuildMessages | GatewayIntents.GuildWebhooks | GatewayIntents.MessageContents - // Actually GUILD_EXPRESSIONS - | GatewayIntents.GuildEmojisAndStickers; + | GatewayIntents.GuildExpressions; // Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService. g.Presence = new UpdatePresence( @@ -80,6 +83,7 @@ builder ] ); }) + .Configure(opts => opts.SuppressAutomaticResponses = true) .AddDiscordCommands( enableSlash: true, useDefaultCommandResponder: false, @@ -91,8 +95,10 @@ builder .WithCommandGroup() .WithCommandGroup() .WithCommandGroup() - .WithCommandGroup() + .WithCommandGroup() + .WithCommandGroup() .WithCommandGroup() + .WithCommandGroup() // End command tree .Finish() .AddPagination() @@ -108,12 +114,7 @@ builder.Services.AddMetricServer(o => o.Port = (ushort)config.Logging.MetricsPor if (!config.Logging.EnableMetrics) builder.Services.AddHostedService(); -builder - .Services.MaybeAddDashboardServices(config) - .MaybeAddRedisCaches(config) - .AddCustomServices() - .AddEndpointsApiExplorer() - .AddSwaggerGen(); +builder.Services.MaybeAddDashboardServices(config).MaybeAddRedisCaches(config).AddCustomServices(); var app = builder.Build(); diff --git a/Catalogger.Backend/Services/BackgroundTasksService.cs b/Catalogger.Backend/Services/BackgroundTasksService.cs index 11a35da..c23e96b 100644 --- a/Catalogger.Backend/Services/BackgroundTasksService.cs +++ b/Catalogger.Backend/Services/BackgroundTasksService.cs @@ -35,16 +35,23 @@ 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 7ac9ffe..afba829 100644 --- a/Catalogger.Backend/Services/MetricsCollectionService.cs +++ b/Catalogger.Backend/Services/MetricsCollectionService.cs @@ -26,6 +26,7 @@ public class MetricsCollectionService( ILogger logger, GuildCache guildCache, ChannelCache channelCache, + RoleCache roleCache, UserCache userCache, EmojiCache emojiCache, IServiceProvider services @@ -42,8 +43,10 @@ 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 e52c783..3adfebe 100644 --- a/Catalogger.Backend/Services/NewsService.cs +++ b/Catalogger.Backend/Services/NewsService.cs @@ -34,8 +34,9 @@ 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() > clock.GetCurrentInstant() + ExpiresAfter; + private bool _isExpired => clock.GetCurrentInstant() > _lastUpdated + ExpiresAfter; public async Task> GetNewsAsync() { @@ -74,6 +75,7 @@ public class NewsService( } finally { + _lastUpdated = clock.GetCurrentInstant(); _lock.Release(); } } diff --git a/Catalogger.Backend/Services/PluralkitApiService.cs b/Catalogger.Backend/Services/PluralkitApiService.cs index 5dd0f0b..283200c 100644 --- a/Catalogger.Backend/Services/PluralkitApiService.cs +++ b/Catalogger.Backend/Services/PluralkitApiService.cs @@ -14,12 +14,9 @@ // 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; @@ -31,16 +28,6 @@ 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( @@ -84,7 +71,7 @@ public class PluralkitApiService(ILogger logger) throw new CataloggerError("Non-200 status code from PluralKit API"); } - return await resp.Content.ReadFromJsonAsync(_jsonOptions, ct) + return await resp.Content.ReadFromJsonAsync(JsonUtils.ApiJsonOptions, 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 414c2d2..b9f187b 100644 --- a/Catalogger.Backend/Services/StatusUpdateService.cs +++ b/Catalogger.Backend/Services/StatusUpdateService.cs @@ -13,6 +13,9 @@ // 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; @@ -20,19 +23,20 @@ using Remora.Discord.API.Objects; namespace Catalogger.Backend.Services; -public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient) +public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient, Config config) : 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)) - UpdateShardStatuses(ct); + await UpdateShardStatuses(ct); } - private void UpdateShardStatuses(CancellationToken ct = default) + private async Task UpdateShardStatuses(CancellationToken ct = default) { _logger.Information( "Updating status for {TotalShards} shards. Guild count is {GuildCount}", @@ -40,6 +44,12 @@ 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)) @@ -53,11 +63,13 @@ 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} servers"; + var status = $"/catalogger help | in {CataloggerMetrics.GuildsCached.Value:N0} servers"; if (shardedClient.TotalShards != 1) status += $" | shard {shardId + 1}/{shardedClient.TotalShards}"; @@ -69,4 +81,43 @@ 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 new file mode 100644 index 0000000..03d1831 --- /dev/null +++ b/Catalogger.Backend/Services/TimeoutService.cs @@ -0,0 +1,122 @@ +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 dfde9b2..8ca8cb3 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,8 +60,14 @@ public class WebhookExecutorService( /// public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed) { - var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null); - _logger.Debug("Channel to log {Type} to: {LogChannel}", logChannelType, logChannel); + var logChannel = GetLogChannel( + guildConfig, + logChannelType, + channelId: null, + userId: null, + roleId: null, + roleIds: null + ); if (logChannel == null) return; @@ -71,17 +77,16 @@ 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) { - _logger.Debug("Channel to log to: {LogChannel}", channelId); - if (channelId == 0) + if (channelId is null or 0) return; - var queue = _cache.GetOrAdd(channelId, []); + var queue = _cache.GetOrAdd(channelId.Value, []); queue.Enqueue(embed); - _cache[channelId] = queue; + _cache[channelId.Value] = queue; - SetTimer(channelId, queue); + SetTimer(channelId.Value, queue); } /// @@ -184,7 +189,7 @@ public class WebhookExecutorService( private List TakeFromQueue(ulong channelId) { var queue = _cache.GetOrAdd(channelId, []); - var channelLock = _locks.GetOrAdd(channelId, channelId); + var channelLock = _locks.GetOrAdd(channelId, new Lock()); lock (channelLock) { var totalContentLength = 0; @@ -253,18 +258,248 @@ 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 ) { - if (channelId == null) - return GetDefaultLogChannel(guild, logChannelType); - if (!channelCache.TryGet(channelId.Value, out var channel)) - return null; + _logger.Verbose( + "Getting log channel for event {Event}. Channel ID: {ChannelId}, user ID: {UserId}", + logChannelType, + channelId, + userId + ); - Snowflake? categoryId; + // 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 + ) + { if ( channel.Type is ChannelType.AnnouncementThread @@ -274,8 +509,17 @@ public class WebhookExecutorService( { // parent_id should always have a value for threads channelId = channel.ParentID.Value!.Value; - if (!channelCache.TryGet(channelId.Value, out var parentChannel)) - return GetDefaultLogChannel(guild, logChannelType); + 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; + } categoryId = parentChannel.ParentID.Value; } else @@ -284,64 +528,11 @@ public class WebhookExecutorService( categoryId = channel.ParentID.Value; } - // Check if the channel, or its category, or the user is ignored - if ( - guild.Channels.IgnoredChannels.Contains(channelId.Value.Value) - || categoryId != null && guild.Channels.IgnoredChannels.Contains(categoryId.Value.Value) - ) - return null; - if (userId != null) - { - if (guild.Channels.IgnoredUsers.Contains(userId.Value)) - return null; - - // Check the channel-local and category-local ignored users - var channelIgnoredUsers = - guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value) - ?? []; - var categoryIgnoredUsers = - ( - categoryId != null - ? guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault( - categoryId.Value.Value - ) - : [] - ) ?? []; - if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value)) - return null; - } - - // These three events can be redirected to other channels. Redirects can be on a channel or category level. - // Obviously, the events are only redirected if they're supposed to be logged in the first place. - if ( - logChannelType - is LogChannelType.MessageUpdate - or LogChannelType.MessageDelete - or LogChannelType.MessageDeleteBulk - ) - { - if (GetDefaultLogChannel(guild, logChannelType) == 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); + return true; } - public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) => - channelType switch + public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) => + logChannelType switch { LogChannelType.GuildUpdate => guild.Channels.GuildUpdate, LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate, @@ -366,7 +557,7 @@ public class WebhookExecutorService( LogChannelType.MessageUpdate => guild.Channels.MessageUpdate, LogChannelType.MessageDelete => guild.Channels.MessageDelete, LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk, - _ => throw new ArgumentOutOfRangeException(nameof(channelType)), + _ => throw new ArgumentOutOfRangeException(nameof(logChannelType)), }; } diff --git a/Catalogger.Backend/config.example.ini b/Catalogger.Backend/config.example.ini index 9b74c55..3f12c79 100644 --- a/Catalogger.Backend/config.example.ini +++ b/Catalogger.Backend/config.example.ini @@ -7,6 +7,9 @@ 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 deleted file mode 100644 index 2af0ac1..0000000 --- a/Catalogger.Backend/packages.lock.json +++ /dev/null @@ -1,751 +0,0 @@ -{ - "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 449610c..bbe170d 100644 --- a/Catalogger.Frontend/.gitignore +++ b/Catalogger.Frontend/.gitignore @@ -1,3 +1,4 @@ +.vscode node_modules # Output @@ -19,3 +20,6 @@ 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 new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/Catalogger.Frontend/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/Catalogger.Frontend/package.json b/Catalogger.Frontend/package.json index 1df9362..d1142b3 100644 --- a/Catalogger.Frontend/package.json +++ b/Catalogger.Frontend/package.json @@ -17,11 +17,13 @@ "@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", @@ -37,5 +39,6 @@ "vite": "^5.0.3", "vite-plugin-markdown": "^2.2.0" }, - "type": "module" -} \ No newline at end of file + "type": "module", + "packageManager": "yarn@4.5.1" +} diff --git a/Catalogger.Frontend/src/lib/api.ts b/Catalogger.Frontend/src/lib/api.ts index 9db5318..05c6d67 100644 --- a/Catalogger.Frontend/src/lib/api.ts +++ b/Catalogger.Frontend/src/lib/api.ts @@ -77,7 +77,12 @@ export type FullGuild = { icon_url: string; categories: GuildCategory[]; channels_without_category: GuildChannel[]; - config: GuildConfig; + roles: GuildRole[]; + ignored_channels: string[]; + ignored_roles: string[]; + messages: MessageConfig; + channels: ChannelConfig; + key_roles: string[]; }; export type GuildCategory = { @@ -93,6 +98,13 @@ 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[]; @@ -105,14 +117,15 @@ export type ApiError = { message: string; }; -export type GuildConfig = GuildChannelConfig & { +export type MessageConfig = { ignored_channels: string[]; ignored_users: string[]; + ignored_roles: string[]; ignored_users_per_channel: Record; - redirects: Record; }; -export type GuildChannelConfig = { +export type ChannelConfig = { + redirects: Record; 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 new file mode 100644 index 0000000..3c63c68 --- /dev/null +++ b/Catalogger.Frontend/src/lib/components/RemovableListItem.svelte @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Catalogger.Frontend/src/lib/components/RoleListItem.svelte b/Catalogger.Frontend/src/lib/components/RoleListItem.svelte new file mode 100644 index 0000000..eed5c13 --- /dev/null +++ b/Catalogger.Frontend/src/lib/components/RoleListItem.svelte @@ -0,0 +1,18 @@ + + + + {#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 aae3f1a..a33b94e 100644 --- a/Catalogger.Frontend/src/routes/+layout.svelte +++ b/Catalogger.Frontend/src/routes/+layout.svelte @@ -31,7 +31,9 @@ · Privacy · - Source code + Source code diff --git a/Catalogger.Frontend/src/routes/+page.svelte b/Catalogger.Frontend/src/routes/+page.svelte index f0bed2a..8d3fa9f 100644 --- a/Catalogger.Frontend/src/routes/+page.svelte +++ b/Catalogger.Frontend/src/routes/+page.svelte @@ -4,6 +4,7 @@ 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; @@ -68,3 +69,60 @@ {: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 c8cacf0..189f71e 100644 --- a/Catalogger.Frontend/src/routes/+page.ts +++ b/Catalogger.Frontend/src/routes/+page.ts @@ -1 +1 @@ -export const prerender = true; \ No newline at end of file +export const prerender = true; diff --git a/Catalogger.Frontend/src/routes/Message.svelte b/Catalogger.Frontend/src/routes/Message.svelte index e07170f..0122f57 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 new file mode 100644 index 0000000..623dac1 --- /dev/null +++ b/Catalogger.Frontend/src/routes/Screenshot.svelte @@ -0,0 +1,32 @@ + + +
+ + + + + + + + {title} + + + +
diff --git a/Catalogger.Frontend/src/routes/about/contact/+page.svelte b/Catalogger.Frontend/src/routes/about/contact/+page.svelte index a2a9650..e38536f 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} \ No newline at end of file +{@html html} diff --git a/Catalogger.Frontend/src/routes/about/tos/+page.svelte b/Catalogger.Frontend/src/routes/about/tos/+page.svelte index bce6743..678e6fd 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} \ No newline at end of file +{@html html} diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte index 353b812..7c1832d 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/+layout.svelte @@ -2,33 +2,8 @@ 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.", - }); - } - }; @@ -51,24 +26,29 @@ Redirects - Ignored channels + Ignored messages - Ignored users + Ignored channels/roles - 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 695b0e9..793c966 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/+page.svelte @@ -1,15 +1,42 @@ -

Log channels

+
+

Log channels

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

Delete this server's data

To make Catalogger leave your server and delete all data from your server, - fill its name in below and press "Delete". + fill its name ({data.guild.name}) in below and press + "Delete".
- - 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. + You will get a backup of your server's settings which you can restore later if + you change your mind.

- -

This is irreversible!

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

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

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

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 new file mode 100644 index 0000000..33cc720 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-entities/+page.svelte @@ -0,0 +1,205 @@ + + +

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 new file mode 100644 index 0000000..7632941 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.svelte @@ -0,0 +1,330 @@ + + +

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 new file mode 100644 index 0000000..8f793f5 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-messages/+page.ts @@ -0,0 +1,10 @@ +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 deleted file mode 100644 index 215452e..0000000 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/ignored-users/+page.svelte +++ /dev/null @@ -1,7 +0,0 @@ - - -

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 new file mode 100644 index 0000000..4c32d95 --- /dev/null +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/import/+page.svelte @@ -0,0 +1,89 @@ + + +

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 6f940a6..a903960 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/key-roles/+page.svelte @@ -1,7 +1,96 @@

Key roles

-

This page is still under construction!

+

+ 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} + +
diff --git a/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte b/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte index bfff7cd..238adce 100644 --- a/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/[guildId]/redirects/+page.svelte @@ -1,20 +1,16 @@