Compare commits

..

57 commits
dapper ... main

Author SHA1 Message Date
sam
9f3dfc74d6
fix: make prometheus base url configurable 2025-04-11 14:58:21 +02:00
sam
a4a6fb5d31
refactor: clean up log channel resolution 2025-04-11 14:41:09 +02:00
sam
24f6aee57d
feat: handle CataloggerError results in interaction responder 2025-03-20 15:24:46 +01:00
sam
8a4e3ff184
refactor: return CataloggerError as Results instead of throwing in commands 2025-03-20 15:24:23 +01:00
sam
84c3b42874
fix(backend): ignore proxied messages if the original is ignored 2025-03-14 20:43:02 +01:00
sam
cb43ac1a50
chore(backend): update dependencies 2025-03-14 20:11:00 +01:00
sam
db3e6fa7b0
fix: try to fix database connections not being closed sometimes 2025-02-11 15:28:12 +01:00
sam
1a63540f89
fix: don't register subcommand groups separately 2025-01-05 13:57:57 -05:00
sam
0d7e809ef6
fix: fall back to default log channel if the queried channel isn't in cache 2024-12-13 14:41:04 +01:00
sam
27e1903c4b
fix: also increment database connection count 2024-12-10 02:34:45 +01:00
sam
5157105c35
fix: better logging
- verbose logging for log channel resolving logic
- don't LOG TOKENS TO THE CONSOLE OR SEQ
- actually log verbose logs to seq
- report open db connections to prometheus
2024-11-27 16:17:11 +01:00
sam
7749c9d9e2
feat: more log context 2024-11-27 15:48:30 +01:00
sam
4047df8610
fix: always get the latest migration even when two are applied at the same time
without also ordering by migration_name, two migrations being applied at
the same time *can* (but won't always) result in the *earlier* migration
being returned as the latest one. as our migrations are not idempotent,
this causes the bot to crash on startup (and even if they *were*
idempotent, the code that inserts the new migration name into the
migrations table isn't)
2024-11-27 15:22:29 +01:00
sam
27e77eeaed
feat: slightly more debug logs, enrich message events with extra data 2024-11-26 21:25:10 +01:00
sam
c06376dfda
im stupid and NewsService._isExpired could literally never fire 2024-11-19 20:30:05 +01:00
sam
04d6bc958e
just remove the package lock file 2024-11-19 15:46:24 +01:00
sam
b3c541f743
fix: don't mark cross compiled builds as dirty 2024-11-19 15:43:15 +01:00
sam
48a11be7b7
update to .net 9 2024-11-19 00:11:48 +01:00
sam
e8feedb979
fix: correct name of down migration #5 2024-11-18 21:30:04 +01:00
sam
1f4aba0868
feat(dashboard): ignore entity page 2024-11-18 21:27:34 +01:00
sam
223f808151
feat: ignore entity commands, actually ignore events when the entity is ignored 2024-11-18 21:26:47 +01:00
sam
4eb5c16451
refactor: change ulong[] to List<ulong> for better ergonomics 2024-11-18 21:02:42 +01:00
sam
e12bd6194b
fix: actually store ignored channels/roles 2024-11-18 20:47:58 +01:00
sam
19d9f33454
feat(dashboard): ignore messages page, remove ignore channel page 2024-11-18 20:26:03 +01:00
sam
0cac964aa6
feat: split ignores into 'ignore messages' and 'ignore entities' 2024-11-18 00:47:27 +01:00
sam
d48ab7e16e
feat: freeze config backup model
the database model will probably change in the future, but backups should
keep the same model even when that happens.
2024-11-14 02:45:20 +01:00
sam
cbb07f9cc3
fix: add thousands separator to status 2024-11-14 02:38:11 +01:00
sam
681aaa8254
chore: update from yarn 1 to yarn 4 (whoops) 2024-11-13 15:24:12 +01:00
sam
254a50da4d
feat: clear timeouts that never get logged 2024-11-13 15:14:22 +01:00
sam
0564206bf7
fix: disconnect all shards when shard manager restarts, don't fetch old timeouts 2024-11-12 17:25:50 +01:00
sam
492283b9c1
feat: clean webhook cache upon leaving guild 2024-11-08 19:39:36 +01:00
sam
2deac26fc8
chore: clean up unused code 2024-11-08 19:27:39 +01:00
sam
db5d7bb4f8
feat: import/export settings, send backup of settings when leaving guild 2024-11-08 17:12:00 +01:00
sam
e6d68338db
feat: store timeouts in database and log them ending
we have to do this because discord doesn't notify us when a timeout
ends naturally, only when a moderator removes it early.
2024-11-05 22:22:12 +01:00
sam
f0fcfd7bd3
feat: replace buttons in /configure-channels with select menu 2024-11-05 16:20:24 +01:00
sam
e7eaa9f13a
remove ".dirty" suffix from builds in working directories with uncommitted changes
this is always true when building for linux-x64 on macOS, it seems?
2024-11-05 15:48:23 +01:00
sam
5f24a6aa88
feat: store system UUIDs of banned users per guild 2024-11-05 15:32:53 +01:00
sam
5ac607fd0a
feat: show bot version on startup and in /catalogger ping 2024-10-31 01:26:50 +01:00
sam
a22057b9fa
feat(dashboard): ignored users page 2024-10-31 01:17:44 +01:00
sam
8ed9b4b143
feat: post stats to discord.bots.gg 2024-10-30 14:35:56 +01:00
sam
4b74005110
fix: ignored channels should also apply to ChannelUpdate 2024-10-30 13:52:33 +01:00
sam
c28f987240
fix: add missing option names/descriptions 2024-10-29 22:18:30 +01:00
sam
a34b5479c0
feat: add expiry to create invite command 2024-10-29 22:01:29 +01:00
sam
00af303555
feat(dashboard): remove WIP ignored users page
we're not entirely sure how to implement this yet, so putting it on the backburner for now.
2024-10-29 21:32:50 +01:00
sam
8f154ce5ae
slightly less verbose logging 2024-10-29 21:32:33 +01:00
sam
dce148b844
colour output to journalctl 2024-10-29 20:24:00 +01:00
sam
ae4d9018ea
fix: don't change status in test mode, fix broken guilds query 2024-10-29 20:19:49 +01:00
sam
87b3281c8d
feat(dashboard): screenshots and text showcasing some unique features 2024-10-29 17:23:43 +01:00
sam
225c162603
fix: fix invite renaming 2024-10-29 15:51:41 +01:00
sam
735c71b6f7
fix typo 2024-10-29 15:34:17 +01:00
sam
8ae4ba722a
fix: don't use Task.WhenAll() in message responders, it breaks ignoring them for some reason 2024-10-29 15:24:32 +01:00
sam
be8bc9b199
feat(dashboard): favicon 2024-10-29 14:50:48 +01:00
sam
65d286389d
feat(dashboard): add key roles 2024-10-29 14:19:18 +01:00
sam
b52df95b65
feat: ignore user commands 2024-10-29 00:06:39 +01:00
sam
a50a8567dd
feat: import messages from go version 2024-10-28 23:42:57 +01:00
sam
b56a71e105
feat: watchlist commands 2024-10-28 16:25:42 +01:00
sam
56af787e57
fix: don't specify duplicate key_roles in guild repository, let PK API service read strings as numbers 2024-10-28 14:57:05 +01:00
148 changed files with 8268 additions and 4577 deletions

View file

@ -3,14 +3,14 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"csharpier": { "csharpier": {
"version": "0.29.2", "version": "0.30.6",
"commands": [ "commands": [
"dotnet-csharpier" "dotnet-csharpier"
], ],
"rollForward": false "rollForward": false
}, },
"husky": { "husky": {
"version": "0.7.1", "version": "0.7.2",
"commands": [ "commands": [
"husky" "husky"
], ],

View file

@ -15,3 +15,6 @@ resharper_entity_framework_model_validation_unlimited_string_length_highlighting
resharper_not_accessed_positional_property_local_highlighting = none resharper_not_accessed_positional_property_local_highlighting = none
# ReSharper yells at us for the name "GuildCache", for some reason # ReSharper yells at us for the name "GuildCache", for some reason
resharper_inconsistent_naming_highlighting = none 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

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ riderModule.iml
/_ReSharper.Caches/ /_ReSharper.Caches/
config.ini config.ini
*.DotSettings.user *.DotSettings.user
.version

View file

@ -2,6 +2,7 @@
<project version="4"> <project version="4">
<component name="SqlDialectMappings"> <component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/Catalogger.Backend/Database/Migrations/001_init.up.sql" dialect="PostgreSQL" /> <file url="file://$PROJECT_DIR$/Catalogger.Backend/Database/Migrations/001_init.up.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/Catalogger.Backend/Database/Migrations/004_split_message_config.down.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" /> <file url="PROJECT" dialect="PostgreSQL" />
</component> </component>
</project> </project>

View file

@ -14,43 +14,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Database.Redis; 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; namespace Catalogger.Backend.Api;
public class ApiCache(RedisService redisService, IDiscordRestChannelAPI channelApi, Config config) public class ApiCache(RedisService redisService)
{ {
private List<IMessage>? _news;
private readonly SemaphoreSlim _newsSemaphore = new(1);
public async Task<List<IMessage>> 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 UserKey(string id) => $"api-user:{id}";
private static string GuildsKey(string userId) => $"api-user-guilds:{userId}"; private static string GuildsKey(string userId) => $"api-user-guilds:{userId}";

View file

@ -30,8 +30,10 @@ public class DiscordRequestService
private readonly IClock _clock; private readonly IClock _clock;
private readonly ApiTokenRepository _tokenRepository; private readonly ApiTokenRepository _tokenRepository;
private static readonly JsonSerializerOptions JsonOptions = private static readonly JsonSerializerOptions JsonOptions = new()
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public DiscordRequestService( public DiscordRequestService(
ILogger logger, 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 DiscordUserUri = new("https://discord.com/api/v10/users/@me");
private static readonly Uri DiscordGuildsUri = private static readonly Uri DiscordGuildsUri = new(
new("https://discord.com/api/v10/users/@me/guilds"); "https://discord.com/api/v10/users/@me/guilds"
);
private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token"); private static readonly Uri DiscordTokenUri = new("https://discord.com/api/oauth2/token");
public async Task<User> GetMeAsync(string token) => await GetAsync<User>(DiscordUserUri, token); public async Task<User> GetMeAsync(string token) => await GetAsync<User>(DiscordUserUri, token);

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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<ConfigExport> 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))
);
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IActionResult> 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<IActionResult> 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();
}
}

View file

@ -61,7 +61,7 @@ public partial class GuildsController
); );
guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value; guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value;
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
return NoContent(); return NoContent();
} }
@ -80,7 +80,7 @@ public partial class GuildsController
); );
guildConfig.Channels.Redirects.Remove(channelId, out _); guildConfig.Channels.Redirects.Remove(channelId, out _);
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
return NoContent(); return NoContent();
} }

View file

@ -14,12 +14,15 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net; using System.Net;
using System.Text;
using System.Text.Json;
using Catalogger.Backend.Api.Middleware; using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Bot; using Catalogger.Backend.Bot;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Dapper; using Dapper;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
namespace Catalogger.Backend.Api; namespace Catalogger.Backend.Api;
@ -40,6 +43,8 @@ public partial class GuildsController
} }
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
var export = await ToExport(guildConfig);
var logChannelId = var logChannelId =
webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildUpdate) webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildUpdate)
?? webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildMemberRemove); ?? webhookExecutor.GetLogChannel(guildConfig, LogChannelType.GuildMemberRemove);
@ -50,15 +55,25 @@ public partial class GuildsController
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithTitle("Catalogger is leaving this server") .WithTitle("Catalogger is leaving this server")
.WithDescription( .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) .WithColour(DiscordUtils.Red)
.WithCurrentTimestamp() .WithCurrentTimestamp()
.Build() .Build()
.GetOrThrow(); .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 else
{ {
@ -115,17 +130,9 @@ public partial class GuildsController
guildId 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); _logger.Information("Left guild {GuildId} and removed all data for it", guildId);
return NoContent(); return Ok(export);
} }
public record LeaveGuildRequest(string Name); public record LeaveGuildRequest(string Name);

View file

@ -13,6 +13,9 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Catalogger.Backend.Api.Middleware;
using Catalogger.Backend.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -21,44 +24,71 @@ namespace Catalogger.Backend.Api;
public partial class GuildsController public partial class GuildsController
{ {
[HttpPut("ignored-channels/{channelId}")] [HttpGet("ignored-users")]
public async Task<IActionResult> AddIgnoredChannelAsync(string id, ulong channelId) public async Task<IActionResult> 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 (guildId, _) = await ParseGuildAsync(id);
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.Channels.IgnoredChannels.Contains(channelId)) var output = new List<IgnoredUser>();
return NoContent(); foreach (var userId in guildConfig.Messages.IgnoredUsers)
{
if (cts.Token.IsCancellationRequested)
break;
var channel = channelCache var member = await memberCache.TryGetAsync(guildId, DiscordSnowflake.New(userId));
.GuildChannels(guildId) output.Add(
.FirstOrDefault(c => new IgnoredUser(
c.ID.Value == channelId Id: userId,
&& c.Type Tag: member != null ? member.User.Value.Tag() : "unknown user"
is ChannelType.GuildText )
or ChannelType.GuildCategory
or ChannelType.GuildAnnouncement
or ChannelType.GuildForum
or ChannelType.GuildMedia
or ChannelType.GuildVoice
); );
if (channel == null) }
return NoContent();
guildConfig.Channels.IgnoredChannels.Add(channelId); return Ok(output.OrderBy(i => i.Id));
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels);
return NoContent();
} }
[HttpDelete("ignored-channels/{channelId}")] private record IgnoredUser(ulong Id, string Tag);
public async Task<IActionResult> RemoveIgnoredChannelAsync(string id, ulong channelId)
[HttpPut("ignored-users/{userId}")]
public async Task<IActionResult> AddIgnoredUserAsync(string id, ulong userId)
{ {
var (guildId, _) = await ParseGuildAsync(id); var (guildId, _) = await ParseGuildAsync(id);
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
guildConfig.Channels.IgnoredChannels.Remove(channelId); IUser? user;
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); 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<IActionResult> 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(); return NoContent();
} }
@ -73,35 +103,4 @@ public partial class GuildsController
} }
private record UserQueryResponse(string Name, string Id); private record UserQueryResponse(string Name, string Id);
[HttpPut("ignored-users/{userId}")]
public async Task<IActionResult> 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<IActionResult> 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();
}
} }

View file

@ -19,6 +19,7 @@ using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database; using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Remora.Discord.API; using Remora.Discord.API;
@ -33,12 +34,13 @@ public partial class GuildsController(
ILogger logger, ILogger logger,
DatabaseConnection dbConn, DatabaseConnection dbConn,
GuildRepository guildRepository, GuildRepository guildRepository,
GuildCache guildCache, InviteRepository inviteRepository,
EmojiCache emojiCache, WatchlistRepository watchlistRepository,
ChannelCache channelCache, ChannelCache channelCache,
RoleCache roleCache, RoleCache roleCache,
IMemberCache memberCache, IMemberCache memberCache,
IInviteCache inviteCache, IInviteCache inviteCache,
UserCache userCache,
DiscordRequestService discordRequestService, DiscordRequestService discordRequestService,
IDiscordRestUserAPI userApi, IDiscordRestUserAPI userApi,
WebhookExecutorService webhookExecutor WebhookExecutorService webhookExecutor
@ -91,6 +93,16 @@ public partial class GuildsController(
.Select(ToChannel) .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( return Ok(
new GuildResponse( new GuildResponse(
guild.Id, guild.Id,
@ -98,7 +110,12 @@ public partial class GuildsController(
guild.IconUrl, guild.IconUrl,
categories, categories,
channelsWithoutCategories, channelsWithoutCategories,
guildConfig.Channels roles,
guildConfig.IgnoredChannels,
guildConfig.IgnoredRoles,
guildConfig.Messages,
guildConfig.Channels,
guildConfig.KeyRoles
) )
); );
} }
@ -122,13 +139,20 @@ public partial class GuildsController(
string IconUrl, string IconUrl,
IEnumerable<GuildCategory> Categories, IEnumerable<GuildCategory> Categories,
IEnumerable<GuildChannel> ChannelsWithoutCategory, IEnumerable<GuildChannel> ChannelsWithoutCategory,
Database.Models.Guild.ChannelConfig Config IEnumerable<GuildRole> Roles,
List<ulong> IgnoredChannels,
List<ulong> IgnoredRoles,
Database.Models.Guild.MessageConfig Messages,
Database.Models.Guild.ChannelConfig Channels,
List<ulong> KeyRoles
); );
private record GuildCategory(string Id, string Name, IEnumerable<GuildChannel> Channels); private record GuildCategory(string Id, string Name, IEnumerable<GuildChannel> Channels);
private record GuildChannel(string Id, string Name, bool CanLogTo, bool CanRedirectFrom); private record GuildChannel(string Id, string Name, bool CanLogTo, bool CanRedirectFrom);
private record GuildRole(string Id, string Name, int Position, string Colour);
[Authorize] [Authorize]
[HttpPatch] [HttpPatch]
[ProducesResponseType<Database.Models.Guild.ChannelConfig>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<Database.Models.Guild.ChannelConfig>(statusCode: StatusCodes.Status200OK)]
@ -141,28 +165,6 @@ public partial class GuildsController(
.ToList(); .ToList();
var guildConfig = await guildRepository.GetAsync(guildId); 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 // i love repeating myself wheeeeee
if ( if (
req.GuildUpdate == null req.GuildUpdate == null
@ -316,12 +318,11 @@ public partial class GuildsController(
) )
guildConfig.Channels.MessageDeleteBulk = req.MessageDeleteBulk ?? 0; guildConfig.Channels.MessageDeleteBulk = req.MessageDeleteBulk ?? 0;
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
return Ok(guildConfig.Channels); return Ok(guildConfig.Channels);
} }
public record ChannelRequest( public record ChannelRequest(
ulong[]? IgnoredChannels = null,
ulong? GuildUpdate = null, ulong? GuildUpdate = null,
ulong? GuildEmojisUpdate = null, ulong? GuildEmojisUpdate = null,
ulong? GuildRoleCreate = null, ulong? GuildRoleCreate = null,

View file

@ -65,6 +65,10 @@ public class MetaController(
); );
} }
[HttpGet("coffee")]
public IActionResult BrewCoffee() =>
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
private record MetaResponse( private record MetaResponse(
int Guilds, int Guilds,
string InviteUrl, string InviteUrl,

View file

@ -16,12 +16,10 @@
using System.Net; using System.Net;
using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Database.Repositories;
using NodaTime;
namespace Catalogger.Backend.Api.Middleware; namespace Catalogger.Backend.Api.Middleware;
public class AuthenticationMiddleware(ApiTokenRepository tokenRepository, IClock clock) public class AuthenticationMiddleware(ApiTokenRepository tokenRepository) : IMiddleware
: IMiddleware
{ {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {

View file

@ -43,6 +43,7 @@ public class ChannelCommands(
Config config, Config config,
GuildRepository guildRepository, GuildRepository guildRepository,
GuildCache guildCache, GuildCache guildCache,
GuildFetchService guildFetchService,
ChannelCache channelCache, ChannelCache channelCache,
IMemberCache memberCache, IMemberCache memberCache,
IFeedbackService feedbackService, IFeedbackService feedbackService,
@ -68,8 +69,11 @@ public class ChannelCommands(
public async Task<IResult> CheckPermissionsAsync() public async Task<IResult> CheckPermissionsAsync()
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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}"); var embed = new EmbedBuilder().WithTitle($"Permission check for {guild.Name}");
@ -78,8 +82,18 @@ public class ChannelCommands(
DiscordSnowflake.New(config.Discord.ApplicationId) DiscordSnowflake.New(config.Discord.ApplicationId)
); );
var currentUser = await memberCache.TryGetAsync(guildId, userId); var currentUser = await memberCache.TryGetAsync(guildId, userId);
if (botUser == null || currentUser == null) 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 // We don't want to check categories or threads
var guildChannels = channelCache var guildChannels = channelCache
@ -204,7 +218,7 @@ public class ChannelCommands(
{ {
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
@ -222,6 +236,93 @@ public class ChannelCommands(
return Result.Success; 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<IEmbed>, List<IMessageComponent>) BuildRootMenu( public static (List<IEmbed>, List<IMessageComponent>) BuildRootMenu(
List<IChannel> guildChannels, List<IChannel> guildChannels,
IGuild guild, IGuild guild,
@ -357,208 +458,9 @@ public class ChannelCommands(
List<IMessageComponent> components = List<IMessageComponent> components =
[ [
new ActionRowComponent([LogTypeSelect]),
new ActionRowComponent( 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( new ButtonComponent(
ButtonComponentStyle.Secondary, ButtonComponentStyle.Secondary,
Label: "Close", Label: "Close",

View file

@ -45,20 +45,117 @@ public class ChannelCommandsComponents(
{ {
private readonly ILogger _logger = logger.ForContext<ChannelCommandsComponents>(); private readonly ILogger _logger = logger.ForContext<ChannelCommandsComponents>();
[SelectMenu("select-log-type")]
[SuppressInteractionResponse(true)]
public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<string> 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<LogChannelType>(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<IEmbed> 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<IMessageComponent> 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")] [Button("config-channels")]
[SuppressInteractionResponse(true)] [SuppressInteractionResponse(true)]
public async Task<Result> OnButtonPressedAsync(string state) public async Task<Result> OnButtonPressedAsync(string state)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) if (contextInjection.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); return CataloggerError.Result("No context");
if (!ctx.TryGetUserID(out var userId)) 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)) 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)) 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)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
@ -82,9 +179,9 @@ public class ChannelCommandsComponents(
); );
case "reset": case "reset":
if (lease.Data.CurrentPage == null) 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<LogChannelType>(lease.Data.CurrentPage, out var channelType)) if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType))
throw new CataloggerError( return CataloggerError.Result(
$"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'" $"Invalid config-channels CurrentPage: '{lease.Data.CurrentPage}'"
); );
@ -164,7 +261,7 @@ public class ChannelCommandsComponents(
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
goto case "return"; goto case "return";
case "return": case "return":
var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig); var (e, c) = ChannelCommands.BuildRootMenu(guildChannels, guild, guildConfig);
@ -176,71 +273,7 @@ public class ChannelCommandsComponents(
return Result.Success; return Result.Success;
} }
if (!Enum.TryParse<LogChannelType>(state, out var logChannelType)) return Result.Success;
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<IEmbed> 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<IMessageComponent> components =
[
new ActionRowComponent(
new[]
{
new ChannelSelectComponent(
CustomID: CustomIDHelpers.CreateSelectMenuID("config-channels"),
ChannelTypes: new[] { ChannelType.GuildText }
),
}
),
new ActionRowComponent(
new[]
{
new ButtonComponent(
ButtonComponentStyle.Danger,
Label: "Stop logging",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"reset"
),
IsDisabled: channelMention == null
),
new ButtonComponent(
ButtonComponentStyle.Secondary,
Label: "Return to menu",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
"return"
)
),
}
),
];
lease.Data = new ChannelCommandData(userId, CurrentPage: state);
return await interactionApi.UpdateMessageAsync(
ctx.Interaction,
new InteractionMessageCallbackData(Embeds: embeds, Components: components)
);
} }
[SelectMenu("config-channels")] [SelectMenu("config-channels")]
@ -248,15 +281,15 @@ public class ChannelCommandsComponents(
public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels) public async Task<Result> OnMenuSelectionAsync(IReadOnlyList<IPartialChannel> channels)
{ {
if (contextInjection.Context is not IInteractionCommandContext ctx) if (contextInjection.Context is not IInteractionCommandContext ctx)
throw new CataloggerError("No context"); return CataloggerError.Result("No context");
if (!ctx.TryGetUserID(out var userId)) 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)) 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)) 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)) 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 guildConfig = await guildRepository.GetAsync(guildId);
var channelId = channels[0].ID.ToUlong(); var channelId = channels[0].ID.ToUlong();
@ -272,7 +305,7 @@ public class ChannelCommandsComponents(
} }
if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType)) if (!Enum.TryParse<LogChannelType>(lease.Data.CurrentPage, out var channelType))
throw new CataloggerError( return CataloggerError.Result(
$"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'" $"Invalid config-channels CurrentPage '{lease.Data.CurrentPage}'"
); );
@ -351,7 +384,7 @@ public class ChannelCommandsComponents(
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
List<IEmbed> embeds = List<IEmbed> embeds =
[ [

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IgnoreChannelCommands>();
[Command("add")]
[Description("Add a channel to the list of ignored channels.")]
public async Task<IResult> 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<IResult> 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<IResult> 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,
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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,
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IResult> 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<IResult> 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<IResult> 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,
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IResult> 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<IResult> 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<IResult> 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(),
]
);
}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IResult> 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<IResult> 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<IResult> 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<string>();
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<IUser?> TryGetUserAsync(Snowflake guildId, Snowflake userId) =>
(await memberCache.TryGetAsync(guildId, userId))?.User.Value
?? await userCache.GetUserAsync(userId);
}
}

View file

@ -43,6 +43,7 @@ public class InviteCommands(
InviteRepository inviteRepository, InviteRepository inviteRepository,
GuildCache guildCache, GuildCache guildCache,
IInviteCache inviteCache, IInviteCache inviteCache,
UserCache userCache,
IDiscordRestChannelAPI channelApi, IDiscordRestChannelAPI channelApi,
IDiscordRestGuildAPI guildApi, IDiscordRestGuildAPI guildApi,
FeedbackService feedbackService, FeedbackService feedbackService,
@ -58,7 +59,7 @@ public class InviteCommands(
var (userId, guildId) = contextInjection.GetUserAndGuild(); var (userId, guildId) = contextInjection.GetUserAndGuild();
var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow(); var guildInvites = await guildApi.GetGuildInvitesAsync(guildId).GetOrThrow();
if (!guildCache.TryGet(guildId, out var guild)) 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); var dbInvites = await inviteRepository.GetGuildInvitesAsync(guildId);
@ -113,21 +114,22 @@ public class InviteCommands(
); );
[Command("create")] [Command("create")]
[Description("Create a new invite.`")] [Description("Create a new invite.")]
public async Task<IResult> CreateInviteAsync( public async Task<IResult> CreateInviteAsync(
[Description("The channel to create the invite in")] [Description("The channel to create the invite in")]
[ChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)] [ChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)]
IChannel channel, 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 (userId, guildId) = contextInjection.GetUserAndGuild();
var inviteResult = await channelApi.CreateChannelInviteAsync( var inviteResult = await channelApi.CreateChannelInviteAsync(
channel.ID, channel.ID,
maxAge: TimeSpan.Zero, maxAge: duration?.ToTimespan() ?? TimeSpan.Zero,
isUnique: true, isUnique: true,
reason: $"Create invite command by {userId}" reason: $"Create invite command by {await userCache.TryFormatUserAsync(userId, addMention: false)}"
); );
if (inviteResult.Error != null) if (inviteResult.Error != null)
{ {
@ -144,17 +146,20 @@ public class InviteCommands(
); );
} }
var durationText =
duration != null ? $"\nThis invite {duration.ToHumanString()}." : string.Empty;
if (name == null) if (name == null)
return await feedbackService.ReplyAsync( return await feedbackService.ReplyAsync(
$"Created a new invite in <#{channel.ID}>!" $"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); await inviteRepository.SetInviteNameAsync(guildId, inviteResult.Entity.Code, name);
return await feedbackService.ReplyAsync( return await feedbackService.ReplyAsync(
$"Created a new invite in <#{channel.ID}> with the name **{name}**!" $"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(); .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",
};
}

View file

@ -45,11 +45,11 @@ public class KeyRoleCommands(
{ {
var (_, guildId) = contextInjection.GetUserAndGuild(); var (_, guildId) = contextInjection.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildRoles = roleCache.GuildRoles(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.KeyRoles.Length == 0) if (guildConfig.KeyRoles.Count == 0)
return await feedbackService.ReplyAsync( return await feedbackService.ReplyAsync(
"There are no key roles to list. Add some with `/key-roles add`.", "There are no key roles to list. Add some with `/key-roles add`.",
isEphemeral: true isEphemeral: true
@ -76,13 +76,16 @@ public class KeyRoleCommands(
[Command("add")] [Command("add")]
[Description("Add a new key role.")] [Description("Add a new key role.")]
public async Task<IResult> AddKeyRoleAsync( public async Task<IResult> 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 (_, guildId) = contextInjection.GetUserAndGuild();
var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId);
if (role == null) if (role == null)
throw new CataloggerError("Role is not cached"); return CataloggerError.Result("Role is not cached");
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.KeyRoles.Any(id => role.ID.Value == id)) if (guildConfig.KeyRoles.Any(id => role.ID.Value == id))
@ -91,20 +94,24 @@ public class KeyRoleCommands(
isEphemeral: true 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!"); return await feedbackService.ReplyAsync($"Added {role.Name} to this server's key roles!");
} }
[Command("remove")] [Command("remove")]
[Description("Remove a key role.")] [Description("Remove a key role.")]
public async Task<IResult> RemoveKeyRoleAsync( public async Task<IResult> 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 (_, guildId) = contextInjection.GetUserAndGuild();
var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId); var role = roleCache.GuildRoles(guildId).FirstOrDefault(r => r.ID == roleId);
if (role == null) if (role == null)
throw new CataloggerError("Role is not cached"); return CataloggerError.Result("Role is not cached");
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
if (guildConfig.KeyRoles.All(id => role.ID != id)) if (guildConfig.KeyRoles.All(id => role.ID != id))
@ -113,7 +120,8 @@ public class KeyRoleCommands(
isEphemeral: true isEphemeral: true
); );
await guildRepository.RemoveKeyRoleAsync(guildId, role.ID); guildConfig.KeyRoles.Remove(role.ID.Value);
await guildRepository.UpdateConfigAsync(guildId, guildConfig);
return await feedbackService.ReplyAsync( return await feedbackService.ReplyAsync(
$"Removed {role.Name} from this server's key roles!" $"Removed {role.Name} from this server's key roles!"
); );

View file

@ -176,7 +176,7 @@ public class MetaCommands(
var embed = new EmbedBuilder() var embed = new EmbedBuilder()
.WithColour(DiscordUtils.Purple) .WithColour(DiscordUtils.Purple)
.WithFooter( .WithFooter(
$"{RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}" $"{BuildInfo.Version}, {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.RuntimeIdentifier}"
) )
.WithCurrentTimestamp(); .WithCurrentTimestamp();
embed.AddField( embed.AddField(
@ -209,8 +209,7 @@ public class MetaCommands(
"Numbers", "Numbers",
$"{CataloggerMetrics.MessagesStored.Value:N0} messages " $"{CataloggerMetrics.MessagesStored.Value:N0} messages "
+ $"from {guildCache.Size:N0} servers\n" + $"from {guildCache.Size:N0} servers\n"
+ $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis", + $"Cached {channelCache.Size:N0} channels, {roleCache.Size:N0} roles, {emojiCache.Size:N0} emojis"
false
); );
IEmbed[] embeds = [embed.Build().GetOrThrow()]; IEmbed[] embeds = [embed.Build().GetOrThrow()];
@ -219,7 +218,7 @@ public class MetaCommands(
await channelApi.EditMessageAsync(msg.ChannelID, msg.ID, content: "", embeds: embeds); 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<double?> MessagesRate() private async Task<double?> MessagesRate()
{ {
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
@ -228,7 +227,8 @@ public class MetaCommands(
try try
{ {
var query = HttpUtility.UrlEncode("increase(catalogger_received_messages[5m])"); 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(); resp.EnsureSuccessStatusCode();
var data = await resp.Content.ReadFromJsonAsync<PrometheusResponse>(); var data = await resp.Content.ReadFromJsonAsync<PrometheusResponse>();

View file

@ -61,7 +61,7 @@ public class RedirectCommands(
var (_, guildId) = contextInjectionService.GetUserAndGuild(); var (_, guildId) = contextInjectionService.GetUserAndGuild();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);
guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value; guildConfig.Channels.Redirects[source.ID.Value] = target.ID.Value;
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
var output = var output =
$"Success! Edited and deleted messages from {FormatChannel(source)} will now be redirected to <#{target.ID}>."; $"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 guildConfig = await guildRepository.GetAsync(guildId);
var wasSet = guildConfig.Channels.Redirects.Remove(source.ID.Value); var wasSet = guildConfig.Channels.Redirects.Remove(source.ID.Value);
await guildRepository.UpdateChannelConfigAsync(guildId, guildConfig.Channels); await guildRepository.UpdateConfigAsync(guildId, guildConfig);
var output = wasSet var output = wasSet
? $"Removed the redirect for {FormatChannel(source)}! Message logs from" ? $"Removed the redirect for {FormatChannel(source)}! Message logs from"
@ -141,7 +141,7 @@ public class RedirectCommands(
{ {
var (userId, guildId) = contextInjectionService.GetUserAndGuild(); var (userId, guildId) = contextInjectionService.GetUserAndGuild();
if (!guildCache.TryGet(guildId, out var guild)) 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 guildChannels = channelCache.GuildChannels(guildId).ToList();
var guildConfig = await guildRepository.GetAsync(guildId); var guildConfig = await guildRepository.GetAsync(guildId);

View file

@ -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 <https://www.gnu.org/licenses/>.
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<IResult> 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<IResult> 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<IResult> 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<IEmbedField>();
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<EmbedField> 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:** <t:{entry.AddedAt.ToUnixTimeSeconds()}>
**Reason:**
>>> {entry.Reason}
"""
);
}
private async Task<IUser?> TryGetUserAsync(Snowflake guildId, Snowflake userId) =>
(await memberCache.TryGetAsync(guildId, userId))?.User.Value
?? await userCache.GetUserAsync(userId);
}

View file

@ -44,4 +44,28 @@ public static class DiscordUtils
description, description,
new Embed(Title: title, Colour: Purple) new Embed(Title: title, Colour: Purple)
); );
public static List<Embed> PaginateStrings(
IEnumerable<string> strings,
Optional<string> 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<IEnumerable<T>> Split<T>(this T[] arr, int size)
{
for (var i = 0; i < arr.Length / size + 1; i++)
{
yield return arr.Skip(i * size).Take(size);
}
}
} }

View file

@ -35,6 +35,8 @@ public class ChannelCreateResponder(
{ {
public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelCreate ch, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(ch);
if (!ch.GuildID.IsDefined()) if (!ch.GuildID.IsDefined())
return Result.Success; return Result.Success;
channelCache.Set(ch); channelCache.Set(ch);
@ -97,8 +99,11 @@ public class ChannelCreateResponder(
var guildConfig = await guildRepository.GetAsync(ch.GuildID); var guildConfig = await guildRepository.GetAsync(ch.GuildID);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.ChannelCreate, guildConfig,
LogChannelType.ChannelCreate,
channelId: ch.ID
),
builder.Build().GetOrThrow() builder.Build().GetOrThrow()
); );
return Result.Success; return Result.Success;

View file

@ -35,6 +35,8 @@ public class ChannelDeleteResponder(
public async Task<Result> RespondAsync(IChannelDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelDelete evt, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(evt);
if (!evt.GuildID.IsDefined()) if (!evt.GuildID.IsDefined())
{ {
_logger.Debug("Deleted channel {ChannelId} is not in a guild", evt.ID); _logger.Debug("Deleted channel {ChannelId} is not in a guild", evt.ID);
@ -68,8 +70,11 @@ public class ChannelDeleteResponder(
embed.AddField("Description", topic); embed.AddField("Description", topic);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.ChannelDelete, guildConfig,
LogChannelType.ChannelDelete,
channelId: channel.ID
),
embed.Build().GetOrThrow() embed.Build().GetOrThrow()
); );
return Result.Success; return Result.Success;

View file

@ -40,6 +40,8 @@ public class ChannelUpdateResponder(
public async Task<Result> RespondAsync(IChannelUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IChannelUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!channelCache.TryGet(evt.ID, out var oldChannel)) 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 that happens, there will be no embed fields, so just check for that
if (builder.Fields.Count == 0) if (builder.Fields.Count == 0)
return Result.Success; return Result.Success;
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.ChannelUpdate, guildConfig,
LogChannelType.ChannelUpdate,
channelId: evt.ID
),
builder.Build().GetOrThrow() builder.Build().GetOrThrow()
); );

View file

@ -13,21 +13,27 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Remora.Commands.Services; using Remora.Commands.Services;
using Remora.Commands.Tokenization; using Remora.Commands.Tokenization;
using Remora.Commands.Trees; using Remora.Commands.Trees;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Responders; using Remora.Discord.Commands.Responders;
using Remora.Discord.Commands.Services; using Remora.Discord.Commands.Services;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Rest.Core;
using Remora.Results; using Remora.Results;
using Serilog.Context;
namespace Catalogger.Backend.Bot.Responders; namespace Catalogger.Backend.Bot.Responders;
/// <summary> /// <summary>
/// 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 <see cref="CataloggerError" /> results returned by commands.
/// </summary> /// </summary>
public class CustomInteractionResponder( public class CustomInteractionResponder(
Config config, Config config,
@ -45,34 +51,78 @@ public class CustomInteractionResponder(
{ {
private readonly ILogger _logger = logger.ForContext<CustomInteractionResponder>(); private readonly ILogger _logger = logger.ForContext<CustomInteractionResponder>();
private readonly InteractionResponder _inner = private readonly InteractionResponder _inner = new(
new( commandService,
commandService, options,
options, interactionAPI,
interactionAPI, eventCollector,
eventCollector, services,
services, contextInjection,
contextInjection, tokenizerOptions,
tokenizerOptions, treeSearchOptions,
treeSearchOptions, treeNameResolver
treeNameResolver );
);
public async Task<Result> RespondAsync( public async Task<Result> RespondAsync(IInteractionCreate evt, CancellationToken ct = default)
IInteractionCreate gatewayEvent,
CancellationToken ct = default
)
{ {
if (config.Discord.TestMode) if (config.Discord.TestMode)
{ {
_logger.Information( _logger.Information(
"Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled", "Not responding to interaction create event {InteractionId} in {ChannelId} as test mode is enabled",
gatewayEvent.ID, evt.ID,
gatewayEvent.Channel.Map(c => c.ID).OrDefault() evt.Channel.Map(c => c.ID).OrDefault()
); );
return Result.Success; 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<OneOf.OneOf<
IInteractionMessageCallbackData,
IInteractionAutocompleteCallbackData,
IInteractionModalCallbackData
>>(
new InteractionMessageCallbackData(
Embeds: new Optional<IReadOnlyList<IEmbed>>(
[
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
);
} }
} }

View file

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
@ -28,6 +29,8 @@ public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger)
public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (evt.TargetID == null || evt.UserID == null) if (evt.TargetID == null || evt.UserID == null)
return Task.FromResult(Result.Success); return Task.FromResult(Result.Success);

View file

@ -37,6 +37,7 @@ public class GuildBanAddResponder(
public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildBanAdd evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildConfig = await guildRepository.GetAsync(evt.GuildID); var guildConfig = await guildRepository.GetAsync(evt.GuildID);
// Delay 2 seconds for the audit log // Delay 2 seconds for the audit log
@ -76,7 +77,12 @@ public class GuildBanAddResponder(
evt.GuildID 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( embed.AddField(

View file

@ -37,6 +37,7 @@ public class GuildBanRemoveResponder(
public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildBanRemove evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildConfig = await guildRepository.GetAsync(evt.GuildID); var guildConfig = await guildRepository.GetAsync(evt.GuildID);
// Delay 2 seconds for the audit log // Delay 2 seconds for the audit log
@ -67,20 +68,52 @@ public class GuildBanRemoveResponder(
var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct); var pkSystem = await pluralkitApi.GetPluralKitSystemAsync(evt.User.ID.Value, ct);
if (pkSystem != null) if (pkSystem != null)
{ {
await guildRepository.UnbanSystemAsync(evt.GuildID, pkSystem.Id, pkSystem.Uuid); await guildRepository.UnbanSystemAsync(
evt.GuildID,
embed.AddField( evt.User.ID,
"PluralKit system", pkSystem.Id,
$""" pkSystem.Uuid
**ID:** {pkSystem.Id}
**UUID:** `{pkSystem.Uuid}`
**Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"}
This system has been unbanned.
Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts.
"""
); );
var systemUsers = await guildRepository.GetSystemAccountsAsync(
evt.GuildID,
pkSystem.Uuid
);
if (systemUsers.Length == 0)
{
embed.AddField(
"PluralKit system",
$"""
**ID:** {pkSystem.Id}
**UUID:** `{pkSystem.Uuid}`
**Name:** {pkSystem.Name ?? "*(none)*"}
**Tag:** {pkSystem.Tag ?? "*(none)*"}
This system has been unbanned.
Note that other accounts linked to the system might still be banned, check `pk;system {pkSystem.Id}` for the linked accounts.
"""
);
}
else
{
var users = new List<string>();
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( webhookExecutor.QueueLog(

View file

@ -35,6 +35,7 @@ public class GuildCreateResponder(
RoleCache roleCache, RoleCache roleCache,
IMemberCache memberCache, IMemberCache memberCache,
IInviteCache inviteCache, IInviteCache inviteCache,
IWebhookCache webhookCache,
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
GuildFetchService guildFetchService GuildFetchService guildFetchService
) : IResponder<IGuildCreate>, IResponder<IGuildDelete> ) : IResponder<IGuildCreate>, IResponder<IGuildDelete>
@ -43,6 +44,8 @@ public class GuildCreateResponder(
public async Task<Result> RespondAsync(IGuildCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
ulong guildId; ulong guildId;
string? guildName = null; string? guildName = null;
if (evt.Guild.TryPickT0(out var guild, out var unavailableGuild)) if (evt.Guild.TryPickT0(out var guild, out var unavailableGuild))
@ -100,6 +103,8 @@ public class GuildCreateResponder(
public async Task<Result> RespondAsync(IGuildDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (evt.IsUnavailable.OrDefault(false)) if (evt.IsUnavailable.OrDefault(false))
{ {
_logger.Debug("Guild {GuildId} became unavailable", evt.ID); _logger.Debug("Guild {GuildId} became unavailable", evt.ID);
@ -107,14 +112,18 @@ public class GuildCreateResponder(
} }
// Clear the cache for this guild // Clear the cache for this guild
guildCache.Remove(evt.ID, out _); var wasCached = guildCache.Remove(evt.ID, out var guild);
emojiCache.Remove(evt.ID); emojiCache.Remove(evt.ID);
channelCache.RemoveGuild(evt.ID); channelCache.RemoveGuild(evt.ID);
roleCache.RemoveGuild(evt.ID); roleCache.RemoveGuild(evt.ID);
await memberCache.RemoveAllMembersAsync(evt.ID); await memberCache.RemoveAllMembersAsync(evt.ID);
await inviteCache.RemoveAsync(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); _logger.Information("Left uncached guild {GuildId}", evt.ID);
return Result.Success; return Result.Success;

View file

@ -37,6 +37,8 @@ public class GuildEmojisUpdateResponder(
public async Task<Result> RespondAsync(IGuildEmojisUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildEmojisUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!emojiCache.TryGet(evt.GuildID, out var oldEmoji)) if (!emojiCache.TryGet(evt.GuildID, out var oldEmoji))

View file

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
@ -27,6 +28,8 @@ public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache
public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMembersChunk evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
_logger.Debug( _logger.Debug(
"Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}", "Received chunk {ChunkIndex} / {ChunkCount} for guild {GuildId}",
evt.ChunkIndex + 1, evt.ChunkIndex + 1,

View file

@ -37,6 +37,8 @@ public class GuildUpdateResponder(
public async Task<Result> RespondAsync(IGuildUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
if (!guildCache.TryGet(evt.ID, out var oldGuild)) if (!guildCache.TryGet(evt.ID, out var oldGuild))

View file

@ -37,6 +37,7 @@ public class InviteCreateResponder(
public async Task<Result> RespondAsync(IInviteCreate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IInviteCreate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildId = evt.GuildID.Value; var guildId = evt.GuildID.Value;
var invitesResult = await guildApi.GetGuildInvitesAsync(guildId, ct); var invitesResult = await guildApi.GetGuildInvitesAsync(guildId, ct);

View file

@ -38,6 +38,7 @@ public class InviteDeleteResponder(
public async Task<Result> RespondAsync(IInviteDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IInviteDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guildId = evt.GuildID.Value; var guildId = evt.GuildID.Value;
var dbDeleteCount = await inviteRepository.DeleteInviteAsync(guildId, evt.Code); var dbDeleteCount = await inviteRepository.DeleteInviteAsync(guildId, evt.Code);

View file

@ -48,6 +48,8 @@ public class GuildMemberAddResponder(
public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMemberAdd member, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(member);
await memberCache.SetAsync(member.GuildID, member); await memberCache.SetAsync(member.GuildID, member);
await memberCache.SetMemberNamesAsync(member.GuildID, [member]); await memberCache.SetMemberNamesAsync(member.GuildID, [member]);
@ -126,7 +128,7 @@ public class GuildMemberAddResponder(
goto afterInvite; goto afterInvite;
} }
var inviteName = inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code); var inviteName = await inviteRepository.GetInviteNameAsync(member.GuildID, usedInvite.Code);
var inviteDescription = $""" var inviteDescription = $"""
**Code:** {usedInvite.Code} **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) if (watchlist != null)
{ {
var moderator = await userCache.GetUserAsync( var moderator = await userCache.GetUserAsync(

View file

@ -39,6 +39,8 @@ public class GuildMemberRemoveResponder(
public async Task<Result> RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildMemberRemove evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
var embed = new EmbedBuilder() var embed = new EmbedBuilder()

View file

@ -15,9 +15,11 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database.Models;
using Catalogger.Backend.Database.Repositories; using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using NodaTime.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
@ -30,6 +32,8 @@ namespace Catalogger.Backend.Bot.Responders.Members;
public class GuildMemberUpdateResponder( public class GuildMemberUpdateResponder(
ILogger logger, ILogger logger,
GuildRepository guildRepository, GuildRepository guildRepository,
TimeoutRepository timeoutRepository,
TimeoutService timeoutService,
UserCache userCache, UserCache userCache,
RoleCache roleCache, RoleCache roleCache,
IMemberCache memberCache, IMemberCache memberCache,
@ -44,6 +48,8 @@ public class GuildMemberUpdateResponder(
CancellationToken ct = default CancellationToken ct = default
) )
{ {
using var _ = LogUtils.Enrich(newMember);
try try
{ {
var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID); var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID);
@ -245,11 +251,15 @@ public class GuildMemberUpdateResponder(
var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId); var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
embed.AddField("Responsible moderator", moderator); embed.AddField("Responsible moderator", moderator);
embed.AddField("Reason", actionData.Reason ?? "No reason given"); embed.AddField("Reason", actionData.Reason ?? "No reason given");
await UpdateTimeoutDatabaseAsync(member, actionData.ModeratorId);
} }
else else
{ {
embed.AddField("Responsible moderator", "*(unknown)*"); embed.AddField("Responsible moderator", "*(unknown)*");
embed.AddField("Reason", "*(unknown)*"); embed.AddField("Reason", "*(unknown)*");
await UpdateTimeoutDatabaseAsync(member, null);
} }
var guildConfig = await guildRepository.GetAsync(member.GuildID); var guildConfig = await guildRepository.GetAsync(member.GuildID);
@ -261,6 +271,27 @@ public class GuildMemberUpdateResponder(
return Result.Success; 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<Result> HandleRoleUpdateAsync( private async Task<Result> HandleRoleUpdateAsync(
IGuildMemberUpdate member, IGuildMemberUpdate member,
IReadOnlyList<Snowflake> oldRoles, IReadOnlyList<Snowflake> oldRoles,
@ -286,19 +317,20 @@ public class GuildMemberUpdateResponder(
.WithFooter($"User ID: {member.User.ID}") .WithFooter($"User ID: {member.User.ID}")
.WithCurrentTimestamp(); .WithCurrentTimestamp();
var addedRoles = member.Roles.Except(oldRoles).Select(s => s.Value).ToList(); var addedRoles = member.Roles.Except(oldRoles).ToList();
var removedRoles = oldRoles.Except(member.Roles).Select(s => s.Value).ToList(); var removedRoles = oldRoles.Except(member.Roles).ToList();
if (addedRoles.Count != 0) if (addedRoles.Count != 0)
{ {
roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>"))); roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>")));
// Add all added key roles to the log // 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( var value = string.Join(
"\n", "\n",
addedRoles addedRoles
.Select(s => s.Value)
.Where(guildConfig.KeyRoles.Contains) .Where(guildConfig.KeyRoles.Contains)
.Select(id => .Select(id =>
{ {
@ -319,11 +351,12 @@ public class GuildMemberUpdateResponder(
); );
// Add all removed key roles to the log // 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( var value = string.Join(
"\n", "\n",
removedRoles removedRoles
.Select(s => s.Value)
.Where(guildConfig.KeyRoles.Contains) .Where(guildConfig.KeyRoles.Contains)
.Select(id => .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) if (roleUpdate.Fields.Count != 0)
{ {
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.GuildMemberUpdate, guildConfig,
LogChannelType.GuildMemberUpdate,
// Check for all added and removed roles
roleIds: addedRoles.Concat(removedRoles).ToList()
),
roleUpdate.Build().GetOrThrow() roleUpdate.Build().GetOrThrow()
); );
} }

View file

@ -38,6 +38,8 @@ public class MessageCreateResponder(
public async Task<Result> RespondAsync(IMessageCreate msg, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageCreate msg, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(msg);
userCache.UpdateUser(msg.Author); userCache.UpdateUser(msg.Author);
CataloggerMetrics.MessagesReceived.Inc(); CataloggerMetrics.MessagesReceived.Inc();
@ -53,7 +55,13 @@ public class MessageCreateResponder(
var guild = await guildRepository.GetAsync(msg.GuildID); var guild = await guildRepository.GetAsync(msg.GuildID);
// The guild needs to have enabled at least one of the message logging events, // 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. // 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); await messageRepository.IgnoreMessageAsync(msg.ID.Value);
return Result.Success; return Result.Success;
@ -69,7 +77,7 @@ public class MessageCreateResponder(
return Result.Success; return Result.Success;
} }
await messageRepository.SaveMessageAsync(msg, ct); await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct);
return Result.Success; return Result.Success;
} }
} }
@ -88,18 +96,11 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
public async Task HandlePkMessageAsync(IMessageCreate msg) public async Task HandlePkMessageAsync(IMessageCreate msg)
{ {
_logger.Debug("Received PluralKit message");
await Task.Delay(500.Milliseconds()); 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) // 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)) if (!LinkRegex().IsMatch(msg.Content))
{
_logger.Debug("PluralKit message is not a log message because content is not a link");
return; return;
}
// The first (only, I think always?) embed's footer must match the expected format // The first (only, I think always?) embed's footer must match the expected format
var firstEmbed = msg.Embeds.FirstOrDefault(); var firstEmbed = msg.Embeds.FirstOrDefault();
@ -108,12 +109,7 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
|| !firstEmbed.Footer.TryGet(out var footer) || !firstEmbed.Footer.TryGet(out var footer)
|| !FooterRegex().IsMatch(footer.Text) || !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; return;
}
var match = FooterRegex().Match(footer.Text); var match = FooterRegex().Match(footer.Text);
@ -148,16 +144,33 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
await using var messageRepository = await using var messageRepository =
scope.ServiceProvider.GetRequiredService<MessageRepository>(); scope.ServiceProvider.GetRequiredService<MessageRepository>();
await Task.WhenAll( if (await messageRepository.IsMessageIgnoredAsync(originalId))
messageRepository.SetProxiedMessageDataAsync( {
_logger.Debug(
"Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored",
msgId, msgId,
originalId, originalId
authorId, );
systemId: match.Groups[1].Value,
memberId: match.Groups[2].Value await messageRepository.IgnoreMessageAsync(originalId);
), await messageRepository.IgnoreMessageAsync(msgId);
messageRepository.IgnoreMessageAsync(originalId) 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) public async Task HandleProxiedMessageAsync(ulong msgId)
@ -189,15 +202,32 @@ public partial class PkMessageHandler(ILogger logger, IServiceProvider services)
return; return;
} }
await Task.WhenAll( _logger.Debug(
messageRepository.SetProxiedMessageDataAsync( "Setting proxy data for {MessageId} and ignoring {OriginalId}",
msgId, msgId,
pkMessage.Original, pkMessage.Original
pkMessage.Sender,
pkMessage.System?.Id,
pkMessage.Member?.Id
),
messageRepository.IgnoreMessageAsync(pkMessage.Original)
); );
if (await messageRepository.IsMessageIgnoredAsync(pkMessage.Original))
{
_logger.Debug(
"Proxied message {MessageId} should be ignored as trigger {OriginalId} is already ignored",
pkMessage.Id,
pkMessage.Original
);
await messageRepository.IgnoreMessageAsync(pkMessage.Original);
await messageRepository.IgnoreMessageAsync(msgId);
return;
}
await messageRepository.SetProxiedMessageDataAsync(
msgId,
pkMessage.Original,
pkMessage.Sender,
pkMessage.System?.Id,
pkMessage.Member?.Id
);
await messageRepository.IgnoreMessageAsync(pkMessage.Original);
} }
} }

View file

@ -41,8 +41,10 @@ public class MessageDeleteBulkResponder(
public async Task<Result> RespondAsync(IMessageDeleteBulk evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageDeleteBulk evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
var guild = await guildRepository.GetAsync(evt.GuildID); var guild = await guildRepository.GetAsync(evt.GuildID);
if (guild.IsMessageIgnored(evt.ChannelID, null)) if (guild.IsMessageIgnored(evt.ChannelID, null, null))
return Result.Success; return Result.Success;
var logChannel = webhookExecutor.GetLogChannel( var logChannel = webhookExecutor.GetLogChannel(

View file

@ -18,7 +18,6 @@ using Catalogger.Backend.Database.Repositories;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Humanizer; using Humanizer;
using NodaTime;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
@ -37,7 +36,6 @@ public class MessageDeleteResponder(
WebhookExecutorService webhookExecutor, WebhookExecutorService webhookExecutor,
ChannelCache channelCache, ChannelCache channelCache,
UserCache userCache, UserCache userCache,
IClock clock,
PluralkitApiService pluralkitApi PluralkitApiService pluralkitApi
) : IResponder<IMessageDelete> ) : IResponder<IMessageDelete>
{ {
@ -48,6 +46,8 @@ public class MessageDeleteResponder(
public async Task<Result> RespondAsync(IMessageDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageDelete evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
if (!evt.GuildID.IsDefined()) if (!evt.GuildID.IsDefined())
return Result.Success; return Result.Success;
@ -64,27 +64,22 @@ public class MessageDeleteResponder(
return Result.Success; return Result.Success;
var guild = await guildRepository.GetAsync(evt.GuildID); 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); 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 // Sometimes a message that *should* be logged isn't stored in the database, notify the user of that
if (msg == null) if (msg == null)
{ {
if (logChannel == null) _logger.Debug(
return Result.Success; "Deleted message {MessageId} should be logged but is not in the database",
evt.ID
);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
logChannel.Value, webhookExecutor.GetLogChannel(guild, LogChannelType.MessageDelete, evt.ChannelID),
new Embed( new Embed(
Title: "Message deleted", Title: "Message deleted",
Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).", Description: $"A message not found in the database was deleted in <#{evt.ChannelID}> ({evt.ChannelID}).",
Footer: new EmbedFooter(Text: $"ID: {evt.ID}"), Footer: new EmbedFooter(Text: $"ID: {evt.ID} | Original sent at"),
Timestamp: clock.GetCurrentInstant().ToDateTimeOffset() Timestamp: evt.ID.Timestamp
) )
); );
@ -107,21 +102,26 @@ public class MessageDeleteResponder(
} }
} }
logChannel = webhookExecutor.GetLogChannel( var logChannel = webhookExecutor.GetLogChannel(
guild, guild,
LogChannelType.MessageDelete, LogChannelType.MessageDelete,
evt.ChannelID, evt.ChannelID,
msg.UserId msg.UserId
); );
if (logChannel == null) if (logChannel is null or 0)
return Result.Success; {
_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 user = await userCache.GetUserAsync(DiscordSnowflake.New(msg.UserId));
var builder = new EmbedBuilder() var builder = new EmbedBuilder()
.WithTitle("Message deleted") .WithTitle("Message deleted")
.WithDescription(msg.Content) .WithDescription(msg.Content)
.WithColour(DiscordUtils.Red) .WithColour(DiscordUtils.Red)
.WithFooter($"ID: {msg.Id}") .WithFooter($"ID: {msg.Id} | Original sent at")
.WithTimestamp(evt.ID); .WithTimestamp(evt.ID);
if (user != null) if (user != null)
@ -173,7 +173,7 @@ public class MessageDeleteResponder(
builder.AddField("Attachments", attachmentInfo, false); builder.AddField("Attachments", attachmentInfo, false);
} }
webhookExecutor.QueueLog(logChannel.Value, builder.Build().GetOrThrow()); webhookExecutor.QueueLog(logChannel, builder.Build().GetOrThrow());
return Result.Success; return Result.Success;
} }
} }

View file

@ -20,7 +20,6 @@ using Catalogger.Backend.Services;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Gateway.Events;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Extensions.Embeds; using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
@ -40,11 +39,9 @@ public class MessageUpdateResponder(
{ {
private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<MessageUpdateResponder>();
public async Task<Result> RespondAsync(IMessageUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IMessageUpdate msg, CancellationToken ct = default)
{ {
// Discord only *very* recently changed message update events to have all fields, using var _ = LogUtils.Enrich(msg);
// so we convert the event to a MessageCreate to avoid having to unwrap every single field
var msg = ConvertToMessageCreate(evt);
if (!msg.GuildID.IsDefined()) if (!msg.GuildID.IsDefined())
{ {
@ -58,10 +55,7 @@ public class MessageUpdateResponder(
var guildConfig = await guildRepository.GetAsync(msg.GuildID); var guildConfig = await guildRepository.GetAsync(msg.GuildID);
if (await messageRepository.IsMessageIgnoredAsync(msg.ID.Value)) if (await messageRepository.IsMessageIgnoredAsync(msg.ID.Value))
{
_logger.Debug("Message {MessageId} should be ignored", msg.ID);
return Result.Success; return Result.Success;
}
try try
{ {
@ -135,7 +129,7 @@ public class MessageUpdateResponder(
if (oldMessage is { System: not null, Member: not null }) if (oldMessage is { System: not null, Member: not null })
{ {
embedBuilder.WithTitle($"Message by {msg.Author.Username} edited"); 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("System ID", oldMessage.System, true);
embedBuilder.AddField("Member ID", oldMessage.Member, true); embedBuilder.AddField("Member ID", oldMessage.Member, true);
} }
@ -175,7 +169,7 @@ public class MessageUpdateResponder(
) )
{ {
if ( if (
!await messageRepository.SaveMessageAsync(msg, ct) !await messageRepository.SaveMessageAsync(msg, msg.GuildID, ct)
&& msg.ApplicationID.Is(DiscordUtils.PkUserId) && 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<string> ChunksUpTo(string str, int maxChunkSize) private static IEnumerable<string> ChunksUpTo(string str, int maxChunkSize)
{ {
for (var i = 0; i < str.Length; i += maxChunkSize) for (var i = 0; i < str.Length; i += maxChunkSize)

View file

@ -26,19 +26,19 @@ public class ReadyResponder(ILogger logger, WebhookExecutorService webhookExecut
{ {
private readonly ILogger _logger = logger.ForContext<ReadyResponder>(); private readonly ILogger _logger = logger.ForContext<ReadyResponder>();
public Task<Result> RespondAsync(IReady gatewayEvent, CancellationToken ct = default) public Task<Result> RespondAsync(IReady evt, CancellationToken ct = default)
{ {
var shardId = gatewayEvent.Shard.TryGet(out var shard) using var _ = LogUtils.Enrich(evt);
? (shard.ShardID, shard.ShardCount)
: (0, 1); var shardId = evt.Shard.TryGet(out var shard) ? (shard.ShardID, shard.ShardCount) : (0, 1);
_logger.Information( _logger.Information(
"Ready as {User} on shard {ShardId}/{ShardCount}", "Ready as {User} on shard {ShardId}/{ShardCount}",
gatewayEvent.User.Tag(), evt.User.Tag(),
shardId.Item1, shardId.Item1,
shardId.Item2 shardId.Item2
); );
if (shardId.Item1 == 0) if (shardId.Item1 == 0)
webhookExecutorService.SetSelfUser(gatewayEvent.User); webhookExecutorService.SetSelfUser(evt.User);
return Task.FromResult(Result.Success); return Task.FromResult(Result.Success);
} }

View file

@ -35,6 +35,8 @@ public class RoleCreateResponder(
public async Task<Result> RespondAsync(IGuildRoleCreate evt, CancellationToken ct = default) public async Task<Result> 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); _logger.Debug("Received new role {RoleId} in guild {GuildId}", evt.Role.ID, evt.GuildID);
roleCache.Set(evt.Role, evt.GuildID); roleCache.Set(evt.Role, evt.GuildID);
@ -54,8 +56,11 @@ public class RoleCreateResponder(
} }
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.GuildRoleCreate, guildConfig,
LogChannelType.GuildRoleCreate,
roleId: evt.Role.ID
),
embed.Build().GetOrThrow() embed.Build().GetOrThrow()
); );

View file

@ -35,6 +35,8 @@ public class RoleDeleteResponder(
public async Task<Result> RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildRoleDelete evt, CancellationToken ct = default)
{ {
using var __ = LogUtils.Enrich(evt);
try try
{ {
if (!roleCache.TryGet(evt.RoleID, out var role)) if (!roleCache.TryGet(evt.RoleID, out var role))
@ -70,8 +72,11 @@ public class RoleDeleteResponder(
} }
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.GuildRoleDelete, guildConfig,
LogChannelType.GuildRoleDelete,
roleId: role.ID
),
embed.Build().GetOrThrow() embed.Build().GetOrThrow()
); );
} }

View file

@ -37,6 +37,8 @@ public class RoleUpdateResponder(
public async Task<Result> RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default) public async Task<Result> RespondAsync(IGuildRoleUpdate evt, CancellationToken ct = default)
{ {
using var _ = LogUtils.Enrich(evt);
try try
{ {
var newRole = evt.Role; var newRole = evt.Role;
@ -96,8 +98,11 @@ public class RoleUpdateResponder(
var guildConfig = await guildRepository.GetAsync(evt.GuildID); var guildConfig = await guildRepository.GetAsync(evt.GuildID);
webhookExecutor.QueueLog( webhookExecutor.QueueLog(
guildConfig, webhookExecutor.GetLogChannel(
LogChannelType.GuildRoleUpdate, guildConfig,
LogChannelType.GuildRoleUpdate,
roleId: evt.Role.ID
),
embed.Build().GetOrThrow() embed.Build().GetOrThrow()
); );
} }

View file

@ -115,7 +115,9 @@ public class ShardedGatewayClient(
_logger.Information("Started shard {ShardId}/{ShardCount}", shardIndex, TotalShards); _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); public int ShardIdFor(ulong guildId) => (int)((guildId >> 22) % (ulong)TotalShards);
@ -136,6 +138,17 @@ public class ShardedGatewayClient(
client.Dispose(); 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<DiscordGatewayClientOptions> CloneOptions( private IOptions<DiscordGatewayClientOptions> CloneOptions(
DiscordGatewayClientOptions options, DiscordGatewayClientOptions options,
int shardId int shardId

View file

@ -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 <https://www.gnu.org/licenses/>.
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";
}
}

View file

@ -24,6 +24,7 @@ public interface IWebhookCache
{ {
Task<Webhook?> GetWebhookAsync(ulong channelId); Task<Webhook?> GetWebhookAsync(ulong channelId);
Task SetWebhookAsync(ulong channelId, Webhook webhook); Task SetWebhookAsync(ulong channelId, Webhook webhook);
Task RemoveWebhooksAsync(ulong[] channelIds);
public async Task<Webhook> GetOrFetchWebhookAsync( public async Task<Webhook> GetOrFetchWebhookAsync(
ulong channelId, ulong channelId,

View file

@ -33,4 +33,11 @@ public class InMemoryWebhookCache : IWebhookCache
_cache[channelId] = webhook; _cache[channelId] = webhook;
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task RemoveWebhooksAsync(ulong[] channelIds)
{
foreach (var id in channelIds)
_cache.TryRemove(id, out _);
return Task.CompletedTask;
}
} }

View file

@ -234,6 +234,7 @@ internal record RedisMember(
User.ToRemoraUser(), User.ToRemoraUser(),
Nickname, Nickname,
Avatar != null ? new ImageHash(Avatar) : null, Avatar != null ? new ImageHash(Avatar) : null,
Banner: null,
Roles.Select(DiscordSnowflake.New).ToList(), Roles.Select(DiscordSnowflake.New).ToList(),
JoinedAt, JoinedAt,
PremiumSince, PremiumSince,

View file

@ -26,5 +26,8 @@ public class RedisWebhookCache(RedisService redisService) : IWebhookCache
public async Task SetWebhookAsync(ulong channelId, Webhook webhook) => public async Task SetWebhookAsync(ulong channelId, Webhook webhook) =>
await redisService.SetAsync(WebhookKey(channelId), webhook, 24.Hours()); 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}"; private static string WebhookKey(ulong channelId) => $"webhook:{channelId}";
} }

View file

@ -1,44 +1,44 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup> </PropertyGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
<Exec Command="../build_info.sh" IgnoreExitCode="false">
</Exec>
</Target>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Database/**/*.sql" /> <EmbeddedResource Include="Database/**/*.sql" />
<EmbeddedResource Watch="false" Include="..\.version" LogicalName="version"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Humanizer.Core" Version="2.14.1"/> <PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="LazyCache" Version="2.4.0"/> <PackageReference Include="LazyCache" Version="2.4.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.8"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8"/> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.1.12"/> <PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0"/>
<PackageReference Include="Npgsql" Version="8.0.5" /> <PackageReference Include="Npgsql" Version="9.0.0" />
<PackageReference Include="Npgsql.NodaTime" Version="8.0.5" /> <PackageReference Include="Npgsql.NodaTime" Version="9.0.0" />
<PackageReference Include="Polly.Core" Version="8.4.2"/> <PackageReference Include="Polly.Core" Version="8.5.2" />
<PackageReference Include="Polly.RateLimiting" Version="8.4.2"/> <PackageReference Include="Polly.RateLimiting" Version="8.5.0" />
<PackageReference Include="prometheus-net" Version="8.2.1"/> <PackageReference Include="prometheus-net" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Remora.Sdk" Version="3.1.2"/> <PackageReference Include="Remora.Sdk" Version="3.1.2"/>
<PackageReference Include="Remora.Discord" Version="2024.3.0-github11168366508"/> <PackageReference Include="Remora.Discord" Version="2025.1.0" />
<PackageReference Include="Serilog" Version="4.0.2"/> <PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2"/> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/> <PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/> <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.16"/> <PackageReference Include="StackExchange.Redis" Version="2.8.16"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Database\Dapper\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -13,6 +13,13 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Remora.Results;
using RemoraResult = Remora.Results.Result;
namespace Catalogger.Backend; 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));
}

View file

@ -29,6 +29,11 @@ public static class CataloggerMetrics
public static long MessageRateMinute { get; set; } 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( public static readonly Gauge GuildsCached = Metrics.CreateGauge(
"catalogger_cache_guilds", "catalogger_cache_guilds",
"Number of guilds in the cache" "Number of guilds in the cache"
@ -39,6 +44,11 @@ public static class CataloggerMetrics
"Number of channels in the cache" "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( public static readonly Gauge UsersCached = Metrics.CreateGauge(
"catalogger_cache_users", "catalogger_cache_users",
"Number of users in the cache" "Number of users in the cache"

View file

@ -33,6 +33,7 @@ public class Config
public bool EnableMetrics { get; init; } = true; public bool EnableMetrics { get; init; } = true;
public string? SeqLogUrl { get; init; } public string? SeqLogUrl { get; init; }
public string? PrometheusUrl { get; init; }
} }
public class DatabaseConfig public class DatabaseConfig
@ -60,6 +61,9 @@ public class Config
// If enabled, nothing will be logged. // If enabled, nothing will be logged.
public bool TestMode { get; init; } = false; public bool TestMode { get; init; } = false;
// Token for discord.bots.gg stats
public string? BotsGgToken { get; init; }
} }
public class WebConfig public class WebConfig

View file

@ -17,16 +17,13 @@ using System.Data;
using System.Data.Common; using System.Data.Common;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Npgsql; using Npgsql;
using Serilog;
namespace Catalogger.Backend.Database; namespace Catalogger.Backend.Database;
public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner) public class DatabaseConnection(NpgsqlConnection inner) : DbConnection, IDisposable
: DbConnection,
IDisposable
{ {
public Guid ConnectionId => id; public NpgsqlConnection Inner => inner;
private readonly ILogger _logger = logger.ForContext<DatabaseConnection>();
private readonly DateTimeOffset _openTime = DateTimeOffset.UtcNow;
private bool _hasClosed; private bool _hasClosed;
@ -42,8 +39,6 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner)
} }
DatabasePool.DecrementConnections(); DatabasePool.DecrementConnections();
var openFor = DateTimeOffset.UtcNow - _openTime;
_logger.Verbose("Closing connection {ConnId}, open for {OpenFor}", ConnectionId, openFor);
_hasClosed = true; _hasClosed = true;
await inner.CloseAsync(); await inner.CloseAsync();
} }
@ -51,19 +46,22 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner)
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync( protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel, IsolationLevel isolationLevel,
CancellationToken cancellationToken CancellationToken cancellationToken
) ) => await inner.BeginTransactionAsync(isolationLevel, cancellationToken);
{
_logger.Verbose("Beginning transaction on connection {ConnId}", ConnectionId);
return await inner.BeginTransactionAsync(isolationLevel, cancellationToken);
}
public new void Dispose() public new void Dispose()
{ {
Close(); Dispose(true);
inner.Dispose();
GC.SuppressFinalize(this); 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() public override async ValueTask DisposeAsync()
{ {
await CloseAsync(); await CloseAsync();
@ -72,13 +70,13 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner)
} }
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel) => 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 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] [AllowNull]
public override string ConnectionString public override string ConnectionString
@ -93,4 +91,6 @@ public class DatabaseConnection(Guid id, ILogger logger, NpgsqlConnection inner)
public override string ServerVersion => inner.ServerVersion; public override string ServerVersion => inner.ServerVersion;
protected override DbCommand CreateDbCommand() => inner.CreateCommand(); protected override DbCommand CreateDbCommand() => inner.CreateCommand();
public class SyncException(string method) : Exception($"Tried to use sync method {method}");
} }

View file

@ -19,6 +19,9 @@ using NodaTime;
namespace Catalogger.Backend.Database; namespace Catalogger.Backend.Database;
/// <summary>
/// Manages database migrations.
/// </summary>
public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection conn) public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection conn)
: IDisposable, : IDisposable,
IAsyncDisposable IAsyncDisposable
@ -26,7 +29,10 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c
private const string RootPath = "Catalogger.Backend.Database"; private const string RootPath = "Catalogger.Backend.Database";
private static readonly int MigrationsPathLength = $"{RootPath}.Migrations.".Length; private static readonly int MigrationsPathLength = $"{RootPath}.Migrations.".Length;
public async Task Migrate() /// <summary>
/// Migrates the database to the latest version.
/// </summary>
public async Task MigrateUp()
{ {
var migrations = GetMigrationNames().ToArray(); var migrations = GetMigrationNames().ToArray();
logger.Debug("Getting current database migration"); logger.Debug("Getting current database migration");
@ -65,17 +71,62 @@ public class DatabaseMigrator(ILogger logger, IClock clock, DatabaseConnection c
await tx.CommitAsync(); await tx.CommitAsync();
} }
private async Task ExecuteMigration(DbTransaction tx, string migrationName) /// <summary>
/// Migrates the database to a previous version.
/// </summary>
/// <param name="count">The number of migrations to revert. If higher than the number of applied migrations,
/// reverts the database to a clean slate.</param>
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 // Run the migration
await conn.ExecuteAsync(query, transaction: tx); await conn.ExecuteAsync(query, transaction: tx);
// Store that we ran the migration // Store that we ran the migration (or reverted it)
await conn.ExecuteAsync( if (up)
"INSERT INTO migrations (migration_name, applied_at) VALUES (@MigrationName, @AppliedAt)", await conn.ExecuteAsync(
new { MigrationName = migrationName, AppliedAt = clock.GetCurrentInstant() } "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 /// 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) if (hasMigrationTable)
{ {
return await conn.QuerySingleOrDefaultAsync<MigrationEntry>( return await conn.QuerySingleOrDefaultAsync<MigrationEntry>(
"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(); return await reader.ReadToEndAsync();
} }
public static IEnumerable<string> GetMigrationNames() => private static IEnumerable<string> GetMigrationNames() =>
typeof(DatabasePool) typeof(DatabasePool)
.Assembly.GetManifestResourceNames() .Assembly.GetManifestResourceNames()
.Where(s => s.StartsWith($"{RootPath}.Migrations")) .Where(s => s.StartsWith($"{RootPath}.Migrations"))

View file

@ -24,18 +24,13 @@ namespace Catalogger.Backend.Database;
public class DatabasePool public class DatabasePool
{ {
private readonly ILogger _rootLogger;
private readonly ILogger _logger;
private readonly NpgsqlDataSource _dataSource; private readonly NpgsqlDataSource _dataSource;
private static int _openConnections; private static int _openConnections;
public static int OpenConnections => _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<DatabasePool>();
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{ {
Timeout = config.Database.Timeout ?? 5, Timeout = config.Database.Timeout ?? 5,
@ -51,24 +46,14 @@ public class DatabasePool
public async Task<DatabaseConnection> AcquireAsync(CancellationToken ct = default) public async Task<DatabaseConnection> AcquireAsync(CancellationToken ct = default)
{ {
return new DatabaseConnection( IncrementConnections();
LogOpen(), return new DatabaseConnection(await _dataSource.OpenConnectionAsync(ct));
_rootLogger,
await _dataSource.OpenConnectionAsync(ct)
);
} }
public DatabaseConnection Acquire() 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(); IncrementConnections();
return connId; return new DatabaseConnection(_dataSource.OpenConnection());
} }
public async Task ExecuteAsync( public async Task ExecuteAsync(
@ -112,10 +97,12 @@ public class DatabasePool
SqlMapper.RemoveTypeMap(typeof(ulong)); SqlMapper.RemoveTypeMap(typeof(ulong));
SqlMapper.AddTypeHandler(new UlongEncodeAsLongHandler()); SqlMapper.AddTypeHandler(new UlongEncodeAsLongHandler());
SqlMapper.AddTypeHandler(new UlongArrayHandler());
SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>()); SqlMapper.AddTypeHandler(new PassthroughTypeHandler<Instant>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<Guild.ChannelConfig>()); SqlMapper.AddTypeHandler(new JsonTypeHandler<Guild.ChannelConfig>());
SqlMapper.AddTypeHandler(new JsonTypeHandler<Guild.MessageConfig>());
SqlMapper.AddTypeHandler(new UlongListHandler());
} }
// Copied from PluralKit: // Copied from PluralKit:
@ -131,36 +118,34 @@ public class DatabasePool
private class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong> private class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong>
{ {
public override void SetValue(IDbDataParameter parameter, ulong value) =>
parameter.Value = (long)value;
public override ulong Parse(object value) => public override ulong Parse(object value) =>
// Cast to long to unbox, then to ulong (???) // Cast to long to unbox, then to ulong (???)
(ulong)(long)value; (ulong)(long)value;
public override void SetValue(IDbDataParameter parameter, ulong value) =>
parameter.Value = (long)value;
} }
private class UlongArrayHandler : SqlMapper.TypeHandler<ulong[]> private class UlongListHandler : SqlMapper.TypeHandler<List<ulong>>
{ {
public override void SetValue(IDbDataParameter parameter, ulong[]? value) => public override void SetValue(IDbDataParameter parameter, List<ulong>? value) =>
parameter.Value = value != null ? Array.ConvertAll(value, i => (long)i) : null; parameter.Value = value?.Select(i => (long)i).ToArray();
public override ulong[] Parse(object value) => public override List<ulong>? Parse(object value) =>
Array.ConvertAll((long[])value, i => (ulong)i); ((long[])value).Select(i => (ulong)i).ToList();
} }
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T> private class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
{ {
public override void SetValue(IDbDataParameter parameter, T? value) =>
parameter.Value = JsonSerializer.Serialize(value);
public override T Parse(object value) public override T Parse(object value)
{ {
string json = (string)value; var json = (string)value;
return JsonSerializer.Deserialize<T>(json) return JsonSerializer.Deserialize<T>(json)
?? throw new CataloggerError("JsonTypeHandler<T> returned null"); ?? throw new CataloggerError("JsonTypeHandler<T> returned null");
} }
public override void SetValue(IDbDataParameter parameter, T? value)
{
parameter.Value = JsonSerializer.Serialize(value);
}
} }
} }

View file

@ -0,0 +1 @@
drop table pluralkit_systems;

View file

@ -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);

View file

@ -0,0 +1 @@
drop table timeouts;

View file

@ -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);

View file

@ -0,0 +1,3 @@
update guilds set channels = (channels || messages) - 'IgnoredRoles';
alter table guilds drop column messages;

View file

@ -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

View file

@ -0,0 +1,2 @@
alter table guilds drop column ignored_channels;
alter table guilds drop column ignored_roles;

View file

@ -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[];

View file

@ -0,0 +1,120 @@
using NodaTime;
namespace Catalogger.Backend.Database.Models;
public record ConfigExport(
ulong Id,
ChannelsBackup Channels,
string[] BannedSystems,
List<ulong> KeyRoles,
IEnumerable<InviteExport> Invites,
IEnumerable<WatchlistExport> Watchlist
);
public record InviteExport(string Code, string Name);
public record WatchlistExport(ulong UserId, Instant AddedAt, ulong ModeratorId, string Reason);
public class ChannelsBackup
{
public List<ulong> IgnoredChannels { get; init; } = [];
public List<ulong> IgnoredUsers { get; init; } = [];
public List<ulong> IgnoredRoles { get; init; } = [];
public Dictionary<ulong, List<ulong>> IgnoredUsersPerChannel { get; init; } = [];
public Dictionary<ulong, ulong> 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,
};
}

View file

@ -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; }
}

View file

@ -13,7 +13,6 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations.Schema;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using Remora.Rest.Core; using Remora.Rest.Core;
@ -22,23 +21,31 @@ namespace Catalogger.Backend.Database.Models;
public class Guild public class Guild
{ {
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public required ulong Id { get; init; } public required ulong Id { get; init; }
[Column(TypeName = "jsonb")]
public ChannelConfig Channels { get; init; } = new(); public ChannelConfig Channels { get; init; } = new();
public MessageConfig Messages { get; init; } = new();
public string[] BannedSystems { get; set; } = []; public string[] BannedSystems { get; set; } = [];
public ulong[] KeyRoles { get; set; } = []; public List<ulong> KeyRoles { get; set; } = [];
// These channels and roles are ignored for channel/role update/delete events.
public List<ulong> IgnoredChannels { get; set; } = [];
public List<ulong> IgnoredRoles { get; set; } = [];
public bool IsSystemBanned(PluralkitApiService.PkSystem system) => public bool IsSystemBanned(PluralkitApiService.PkSystem system) =>
BannedSystems.Contains(system.Id) || BannedSystems.Contains(system.Uuid.ToString()); BannedSystems.Contains(system.Id) || BannedSystems.Contains(system.Uuid.ToString());
public bool IsMessageIgnored(Snowflake channelId, Snowflake? userId) public bool IsMessageIgnored(
Snowflake channelId,
Snowflake? userId,
IReadOnlyList<Snowflake>? roleIds
)
{ {
if ( if (
Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 } Channels is { MessageDelete: 0, MessageUpdate: 0, MessageDeleteBulk: 0 }
|| Channels.IgnoredChannels.Contains(channelId.ToUlong()) || Messages.IgnoredChannels.Contains(channelId.ToUlong())
|| (userId != null && Channels.IgnoredUsers.Contains(userId.Value.ToUlong())) || (userId != null && Messages.IgnoredUsers.Contains(userId.Value.ToUlong()))
|| (roleIds != null && roleIds.Any(r => Messages.IgnoredRoles.Any(id => r.Value == id)))
) )
return true; return true;
@ -46,7 +53,7 @@ public class Guild
return false; return false;
if ( if (
Channels.IgnoredUsersPerChannel.TryGetValue( Messages.IgnoredUsersPerChannel.TryGetValue(
channelId.ToUlong(), channelId.ToUlong(),
out var thisChannelIgnoredUsers out var thisChannelIgnoredUsers
) )
@ -56,11 +63,16 @@ public class Guild
return false; return false;
} }
public class ChannelConfig public class MessageConfig
{ {
public List<ulong> IgnoredChannels { get; set; } = []; public List<ulong> IgnoredChannels { get; set; } = [];
public List<ulong> IgnoredRoles { get; set; } = [];
public List<ulong> IgnoredUsers { get; init; } = []; public List<ulong> IgnoredUsers { get; init; } = [];
public Dictionary<ulong, List<ulong>> IgnoredUsersPerChannel { get; init; } = []; public Dictionary<ulong, List<ulong>> IgnoredUsersPerChannel { get; init; } = [];
}
public class ChannelConfig
{
public Dictionary<ulong, ulong> Redirects { get; init; } = []; public Dictionary<ulong, ulong> Redirects { get; init; } = [];
public ulong GuildUpdate { get; set; } public ulong GuildUpdate { get; set; }
@ -86,5 +98,35 @@ public class Guild
public ulong MessageUpdate { get; set; } public ulong MessageUpdate { get; set; }
public ulong MessageDelete { get; set; } public ulong MessageDelete { get; set; }
public ulong MessageDeleteBulk { 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();
} }
} }

View file

@ -13,13 +13,10 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations.Schema;
namespace Catalogger.Backend.Database.Models; namespace Catalogger.Backend.Database.Models;
public class Message public class Message
{ {
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public required ulong Id { get; init; } public required ulong Id { get; init; }
public ulong? OriginalId { get; set; } public ulong? OriginalId { get; set; }
@ -38,5 +35,3 @@ public class Message
public int AttachmentSize { get; set; } = 0; public int AttachmentSize { get; set; } = 0;
} }
public record IgnoredMessage([property: DatabaseGenerated(DatabaseGeneratedOption.None)] ulong Id);

View file

@ -24,8 +24,10 @@ public class RedisService(Config config)
config.Database.Redis! config.Database.Redis!
); );
private readonly JsonSerializerOptions _options = private readonly JsonSerializerOptions _options = new()
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db); public IDatabase GetDatabase(int db = -1) => _multiplexer.GetDatabase(db);
@ -44,6 +46,9 @@ public class RedisService(Config config)
await GetDatabase().StringSetAsync(key, json, expiry); 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<T?> GetAsync<T>(string key) public async Task<T?> GetAsync<T>(string key)
{ {
var value = await GetDatabase().StringGetAsync(key); var value = await GetDatabase().StringGetAsync(key);

View file

@ -15,6 +15,7 @@
using Catalogger.Backend.Database.Models; using Catalogger.Backend.Database.Models;
using Dapper; using Dapper;
using Remora.Discord.API;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Database.Repositories; namespace Catalogger.Backend.Database.Repositories;
@ -31,7 +32,7 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn)
public async Task<Guild> GetAsync(ulong id) public async Task<Guild> GetAsync(ulong id)
{ {
_logger.Debug("Getting guild config for {GuildId}", id); _logger.Verbose("Getting guild config for {GuildId}", id);
var guild = await conn.QueryFirstOrDefaultAsync<Guild>( var guild = await conn.QueryFirstOrDefaultAsync<Guild>(
"select * from guilds where id = @Id", "select * from guilds where id = @Id",
@ -51,20 +52,37 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn)
public async Task AddGuildAsync(ulong id) => public async Task AddGuildAsync(ulong id) =>
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
insert into guilds (id, key_roles, banned_systems, key_roles, channels) insert into guilds (id, key_roles, banned_systems, channels)
values (@Id, array[]::bigint[], array[]::text[], array[]::bigint[], @Channels) values (@Id, array[]::bigint[], array[]::text[], @Channels::jsonb)
on conflict do nothing on conflict do nothing
""", """,
new { Id = id, Channels = new Guild.ChannelConfig() } 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( await conn.ExecuteAsync(
"update guilds set banned_systems = array_cat(banned_systems, @SystemIds) where id = @GuildId", "update guilds set banned_systems = array_cat(banned_systems, @SystemIds) where id = @GuildId",
new { GuildId = guildId.Value, SystemIds = (string[])[hid, uuid.ToString()] } 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( await conn.ExecuteAsync(
"update guilds set banned_systems = array_remove(array_remove(banned_systems, @Hid), @Uuid) where id = @Id", "update guilds set banned_systems = array_remove(array_remove(banned_systems, @Hid), @Uuid) where id = @Id",
new new
@ -75,22 +93,70 @@ public class GuildRepository(ILogger logger, DatabaseConnection conn)
} }
); );
public async Task AddKeyRoleAsync(Snowflake guildId, Snowflake roleId) =>
await conn.ExecuteAsync( 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<Snowflake[]> GetSystemAccountsAsync(Snowflake guildId, Guid systemId)
{
var bannedAccounts = await conn.QueryAsync<BannedSystem>(
"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<ulong> keyRoles
) =>
await conn.ExecuteAsync( await conn.ExecuteAsync(
"update guilds set key_roles = array_remove(key_roles, @RoleId) where id = @GuildId", "update guilds set channels = @channels::jsonb, messages = @messages::jsonb, banned_systems = @bannedSystems, key_roles = @keyRoles where id = @id",
new { GuildId = guildId.Value, RoleId = roleId.Value } new
); {
id,
public async Task UpdateChannelConfigAsync(Snowflake id, Guild.ChannelConfig config) => channels,
await conn.ExecuteAsync( messages,
"update guilds set channels = @Channels::jsonb where id = @Id", bannedSystems,
new { Id = id.Value, Channels = config } keyRoles,
}
); );
public void Dispose() public void Dispose()

View file

@ -37,7 +37,7 @@ public class InviteRepository(ILogger logger, DatabaseConnection conn)
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
insert into invites (code, guild_id, name) values 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 new
{ {
@ -65,6 +65,34 @@ public class InviteRepository(ILogger logger, DatabaseConnection conn)
new { GuildId = guildId.Value, Code = code } new { GuildId = guildId.Value, Code = code }
); );
/// <summary>
/// Bulk imports an array of invite codes and names.
/// The GuildId property in the Invite object is ignored.
/// </summary>
public async Task ImportInvitesAsync(Snowflake guildId, IEnumerable<Invite> 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() public void Dispose()
{ {
conn.Dispose(); conn.Dispose();

View file

@ -18,6 +18,7 @@ using Catalogger.Backend.Extensions;
using Dapper; using Dapper;
using Remora.Discord.API; using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Rest.Core; using Remora.Rest.Core;
namespace Catalogger.Backend.Database.Repositories; namespace Catalogger.Backend.Database.Repositories;
@ -63,7 +64,11 @@ public class MessageRepository(
/// <summary> /// <summary>
/// Adds a new message. If the message is already in the database, updates the existing message instead. /// Adds a new message. If the message is already in the database, updates the existing message instead.
/// </summary> /// </summary>
public async Task<bool> SaveMessageAsync(IMessageCreate msg, CancellationToken ct = default) public async Task<bool> SaveMessageAsync(
IMessage msg,
Optional<Snowflake> guildId,
CancellationToken ct = default
)
{ {
var content = await Task.Run( var content = await Task.Run(
() => () =>
@ -107,7 +112,9 @@ public class MessageRepository(
Id = msg.ID.Value, Id = msg.ID.Value,
UserId = msg.Author.ID.Value, UserId = msg.Author.ID.Value,
ChannelId = msg.ChannelID.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, Content = content,
Username = username, Username = username,
Metadata = metadata, Metadata = metadata,

View file

@ -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<DiscordTimeout?> GetAsync(int id) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"select * from timeouts where id = @id",
new { id }
);
public async Task<DiscordTimeout?> GetAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"select * from timeouts where guild_id = @GuildId and user_id = @UserId",
new { GuildId = guildId.Value, UserId = userId.Value }
);
public async Task<List<DiscordTimeout>> GetAllAsync() =>
(
await conn.QueryAsync<DiscordTimeout>(
"select * from timeouts where until > now() order by id"
)
).ToList();
public async Task<DiscordTimeout> SetAsync(
Snowflake guildId,
Snowflake userId,
Instant until,
Snowflake? moderatorId
) =>
await conn.QueryFirstAsync<DiscordTimeout>(
"""
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<DiscordTimeout?> RemoveAsync(int id) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"delete from timeouts where id = @id returning *",
new { id }
);
public async Task<DiscordTimeout?> RemoveAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync<DiscordTimeout>(
"delete from timeouts where guild_id = @GuildId and user_id = @UserId returning *",
new { GuildId = guildId.Value, UserId = userId.Value }
);
public async Task<int> 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);
}
}

View file

@ -33,12 +33,76 @@ public class WatchlistRepository(ILogger logger, DatabaseConnection conn)
) )
).ToList(); ).ToList();
public async Task<Watchlist?> GetWatchlistEntryAsync(Snowflake guildId, Snowflake userId) => public async Task<Watchlist?> GetEntryAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync<Watchlist>( await conn.QueryFirstOrDefaultAsync<Watchlist>(
"select * from watchlists where guild_id = @GuildId and user_id = @UserId", "select * from watchlists where guild_id = @GuildId and user_id = @UserId",
new { GuildId = guildId.Value, UserId = userId.Value } new { GuildId = guildId.Value, UserId = userId.Value }
); );
public async Task<Watchlist> CreateEntryAsync(
Snowflake guildId,
Snowflake userId,
Snowflake moderatorId,
string reason
) =>
await conn.QueryFirstAsync<Watchlist>(
"""
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<bool> 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;
/// <summary>
/// Bulk imports an array of watchlist entries.
/// The GuildId property in the Watchlist object is ignored.
/// </summary>
public async Task ImportWatchlistAsync(Snowflake guildId, IEnumerable<Watchlist> 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() public void Dispose()
{ {
conn.Dispose(); conn.Dispose();

View file

@ -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>(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<Snowflake> 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();
}
}
}

View file

@ -51,19 +51,22 @@ public static class StartupExtensions
{ {
var logCfg = new LoggerConfiguration() var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext() .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. // 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. // 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.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", 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 // 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) if (config.Logging.SeqLogUrl != null)
{ {
@ -108,6 +111,7 @@ public static class StartupExtensions
.AddScoped<GuildRepository>() .AddScoped<GuildRepository>()
.AddScoped<InviteRepository>() .AddScoped<InviteRepository>()
.AddScoped<WatchlistRepository>() .AddScoped<WatchlistRepository>()
.AddScoped<TimeoutRepository>()
.AddSingleton<GuildCache>() .AddSingleton<GuildCache>()
.AddSingleton<RoleCache>() .AddSingleton<RoleCache>()
.AddSingleton<ChannelCache>() .AddSingleton<ChannelCache>()
@ -117,12 +121,13 @@ public static class StartupExtensions
.AddSingleton<PluralkitApiService>() .AddSingleton<PluralkitApiService>()
.AddSingleton<NewsService>() .AddSingleton<NewsService>()
.AddScoped<IEncryptionService, EncryptionService>() .AddScoped<IEncryptionService, EncryptionService>()
.AddSingleton<TimeoutService>()
.AddSingleton<MetricsCollectionService>() .AddSingleton<MetricsCollectionService>()
.AddSingleton<WebhookExecutorService>() .AddSingleton<WebhookExecutorService>()
.AddSingleton<PkMessageHandler>() .AddSingleton<PkMessageHandler>()
.AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance) .AddSingleton(InMemoryDataService<Snowflake, ChannelCommandData>.Instance)
.AddSingleton<GuildFetchService>()
.AddTransient<PermissionResolverService>() .AddTransient<PermissionResolverService>()
.AddSingleton<GuildFetchService>()
// Background services // Background services
// GuildFetchService is added as a separate singleton as it's also injected into other services. // GuildFetchService is added as a separate singleton as it's also injected into other services.
.AddHostedService(serviceProvider => .AddHostedService(serviceProvider =>
@ -186,9 +191,15 @@ public static class StartupExtensions
public static async Task Initialize(this WebApplication app) public static async Task Initialize(this WebApplication app)
{ {
await BuildInfo.ReadBuildInfo();
await using var scope = app.Services.CreateAsyncScope(); await using var scope = app.Services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>(); var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<Program>();
logger.Information("Starting Catalogger.NET"); logger.Information(
"Starting Catalogger.NET {Version} ({Hash})",
BuildInfo.Version,
BuildInfo.Hash
);
CataloggerMetrics.Startup = scope CataloggerMetrics.Startup = scope
.ServiceProvider.GetRequiredService<IClock>() .ServiceProvider.GetRequiredService<IClock>()
@ -197,10 +208,11 @@ public static class StartupExtensions
DatabasePool.ConfigureDapper(); DatabasePool.ConfigureDapper();
await using var migrator = scope.ServiceProvider.GetRequiredService<DatabaseMigrator>(); await using var migrator = scope.ServiceProvider.GetRequiredService<DatabaseMigrator>();
await migrator.Migrate(); await migrator.MigrateUp();
var config = scope.ServiceProvider.GetRequiredService<Config>(); var config = scope.ServiceProvider.GetRequiredService<Config>();
var slashService = scope.ServiceProvider.GetRequiredService<SlashService>(); var slashService = scope.ServiceProvider.GetRequiredService<SlashService>();
var timeoutService = scope.ServiceProvider.GetRequiredService<TimeoutService>();
if (config.Discord.TestMode) if (config.Discord.TestMode)
logger.Warning( logger.Warning(
@ -243,6 +255,9 @@ public static class StartupExtensions
logger.Information( logger.Information(
"Not syncing slash commands, Discord.SyncCommands is false or unset" "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) public static void MaybeAddDashboard(this WebApplication app)
@ -262,8 +277,6 @@ public static class StartupExtensions
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseRouting(); app.UseRouting();
app.UseHttpMetrics(); app.UseHttpMetrics();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors(); app.UseCors();
app.UseMiddleware<ErrorMiddleware>(); app.UseMiddleware<ErrorMiddleware>();
app.UseMiddleware<AuthenticationMiddleware>(); app.UseMiddleware<AuthenticationMiddleware>();

View file

@ -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<Instant>(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);
}

View file

@ -15,9 +15,11 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Catalogger.Backend;
using Catalogger.Backend.Bot.Commands; using Catalogger.Backend.Bot.Commands;
using Catalogger.Backend.Extensions; using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services; using Catalogger.Backend.Services;
using NodaTime.Serialization.SystemTextJson;
using Prometheus; using Prometheus;
using Remora.Commands.Extensions; using Remora.Commands.Extensions;
using Remora.Discord.API.Abstractions.Gateway.Commands; 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.Gateway.Commands;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
using Remora.Discord.Commands.Extensions; using Remora.Discord.Commands.Extensions;
using Remora.Discord.Commands.Responders;
using Remora.Discord.Extensions.Extensions; using Remora.Discord.Extensions.Extensions;
using Remora.Discord.Gateway; using Remora.Discord.Gateway;
using Remora.Discord.Interactivity.Extensions; using Remora.Discord.Interactivity.Extensions;
@ -45,6 +48,7 @@ builder
options.JsonSerializerOptions.IncludeFields = true; options.JsonSerializerOptions.IncludeFields = true;
options.JsonSerializerOptions.NumberHandling = options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString; JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString;
options.JsonSerializerOptions.ConfigureForNodaTime(JsonUtils.NodaTimeSettings);
}); });
builder builder
@ -62,8 +66,7 @@ builder
| GatewayIntents.GuildMessages | GatewayIntents.GuildMessages
| GatewayIntents.GuildWebhooks | GatewayIntents.GuildWebhooks
| GatewayIntents.MessageContents | GatewayIntents.MessageContents
// Actually GUILD_EXPRESSIONS | GatewayIntents.GuildExpressions;
| GatewayIntents.GuildEmojisAndStickers;
// Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService. // Set a default status for all shards. This is updated to a shard-specific one in StatusUpdateService.
g.Presence = new UpdatePresence( g.Presence = new UpdatePresence(
@ -80,6 +83,7 @@ builder
] ]
); );
}) })
.Configure<InteractionResponderOptions>(opts => opts.SuppressAutomaticResponses = true)
.AddDiscordCommands( .AddDiscordCommands(
enableSlash: true, enableSlash: true,
useDefaultCommandResponder: false, useDefaultCommandResponder: false,
@ -91,8 +95,10 @@ builder
.WithCommandGroup<ChannelCommands>() .WithCommandGroup<ChannelCommands>()
.WithCommandGroup<KeyRoleCommands>() .WithCommandGroup<KeyRoleCommands>()
.WithCommandGroup<InviteCommands>() .WithCommandGroup<InviteCommands>()
.WithCommandGroup<IgnoreChannelCommands>() .WithCommandGroup<IgnoreMessageCommands>()
.WithCommandGroup<IgnoreEntitiesCommands>()
.WithCommandGroup<RedirectCommands>() .WithCommandGroup<RedirectCommands>()
.WithCommandGroup<WatchlistCommands>()
// End command tree // End command tree
.Finish() .Finish()
.AddPagination() .AddPagination()
@ -108,12 +114,7 @@ builder.Services.AddMetricServer(o => o.Port = (ushort)config.Logging.MetricsPor
if (!config.Logging.EnableMetrics) if (!config.Logging.EnableMetrics)
builder.Services.AddHostedService<BackgroundMetricsCollectionService>(); builder.Services.AddHostedService<BackgroundMetricsCollectionService>();
builder builder.Services.MaybeAddDashboardServices(config).MaybeAddRedisCaches(config).AddCustomServices();
.Services.MaybeAddDashboardServices(config)
.MaybeAddRedisCaches(config)
.AddCustomServices()
.AddEndpointsApiExplorer()
.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();

View file

@ -35,16 +35,23 @@ public class BackgroundTasksService(ILogger logger, IServiceProvider services) :
await using var scope = services.CreateAsyncScope(); await using var scope = services.CreateAsyncScope();
await using var messageRepository = await using var messageRepository =
scope.ServiceProvider.GetRequiredService<MessageRepository>(); scope.ServiceProvider.GetRequiredService<MessageRepository>();
await using var timeoutRepository =
scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
var (msgCount, ignoredCount) = await messageRepository.DeleteExpiredMessagesAsync(); var (msgCount, ignoredCount) = await messageRepository.DeleteExpiredMessagesAsync();
if (msgCount != 0 || ignoredCount != 0) if (msgCount != 0 || ignoredCount != 0)
{
_logger.Information( _logger.Information(
"Deleted {Count} messages and {IgnoredCount} ignored message IDs older than {MaxDays} days old", "Deleted {Count} messages and {IgnoredCount} ignored message IDs older than {MaxDays} days old",
msgCount, msgCount,
ignoredCount, ignoredCount,
MessageRepository.MaxMessageAgeDays MessageRepository.MaxMessageAgeDays
); );
}
var timeoutCount = await timeoutRepository.RemoveExpiredTimeoutsAsync();
if (timeoutCount != 0)
_logger.Information(
"Deleted {Count} expired timeouts that were never logged",
timeoutCount
);
} }
} }

View file

@ -26,6 +26,7 @@ public class MetricsCollectionService(
ILogger logger, ILogger logger,
GuildCache guildCache, GuildCache guildCache,
ChannelCache channelCache, ChannelCache channelCache,
RoleCache roleCache,
UserCache userCache, UserCache userCache,
EmojiCache emojiCache, EmojiCache emojiCache,
IServiceProvider services IServiceProvider services
@ -42,8 +43,10 @@ public class MetricsCollectionService(
var messageCount = await conn.ExecuteScalarAsync<int>("select count(id) from messages"); var messageCount = await conn.ExecuteScalarAsync<int>("select count(id) from messages");
CataloggerMetrics.DatabaseConnections.Set(DatabasePool.OpenConnections);
CataloggerMetrics.GuildsCached.Set(guildCache.Size); CataloggerMetrics.GuildsCached.Set(guildCache.Size);
CataloggerMetrics.ChannelsCached.Set(channelCache.Size); CataloggerMetrics.ChannelsCached.Set(channelCache.Size);
CataloggerMetrics.RolesCached.Set(roleCache.Size);
CataloggerMetrics.UsersCached.Set(userCache.Size); CataloggerMetrics.UsersCached.Set(userCache.Size);
CataloggerMetrics.EmojiCached.Set(emojiCache.Size); CataloggerMetrics.EmojiCached.Set(emojiCache.Size);
CataloggerMetrics.MessagesStored.Set(messageCount); CataloggerMetrics.MessagesStored.Set(messageCount);

View file

@ -34,8 +34,9 @@ public class NewsService(
private readonly ILogger _logger = logger.ForContext<NewsService>(); private readonly ILogger _logger = logger.ForContext<NewsService>();
private List<IMessage>? _messages; private List<IMessage>? _messages;
private Instant _lastUpdated = Instant.MinValue;
private readonly SemaphoreSlim _lock = new(1); private readonly SemaphoreSlim _lock = new(1);
private bool _isExpired => clock.GetCurrentInstant() > clock.GetCurrentInstant() + ExpiresAfter; private bool _isExpired => clock.GetCurrentInstant() > _lastUpdated + ExpiresAfter;
public async Task<IEnumerable<NewsMessage>> GetNewsAsync() public async Task<IEnumerable<NewsMessage>> GetNewsAsync()
{ {
@ -74,6 +75,7 @@ public class NewsService(
} }
finally finally
{ {
_lastUpdated = clock.GetCurrentInstant();
_lock.Release(); _lock.Release();
} }
} }

View file

@ -14,12 +14,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net; using System.Net;
using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Humanizer; using Humanizer;
using NodaTime; using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using NodaTime.Text;
using Polly; using Polly;
namespace Catalogger.Backend.Services; namespace Catalogger.Backend.Services;
@ -31,16 +28,6 @@ public class PluralkitApiService(ILogger logger)
private readonly HttpClient _client = new(); private readonly HttpClient _client = new();
private readonly ILogger _logger = logger.ForContext<PluralkitApiService>(); private readonly ILogger _logger = logger.ForContext<PluralkitApiService>();
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
}.ConfigureForNodaTime(
new NodaJsonSettings
{
InstantConverter = new NodaPatternConverter<Instant>(InstantPattern.ExtendedIso),
}
);
private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder() private readonly ResiliencePipeline _pipeline = new ResiliencePipelineBuilder()
.AddRateLimiter( .AddRateLimiter(
new FixedWindowRateLimiter( new FixedWindowRateLimiter(
@ -84,7 +71,7 @@ public class PluralkitApiService(ILogger logger)
throw new CataloggerError("Non-200 status code from PluralKit API"); throw new CataloggerError("Non-200 status code from PluralKit API");
} }
return await resp.Content.ReadFromJsonAsync<T>(_jsonOptions, ct) return await resp.Content.ReadFromJsonAsync<T>(JsonUtils.ApiJsonOptions, ct)
?? throw new CataloggerError("JSON response from PluralKit API was null"); ?? throw new CataloggerError("JSON response from PluralKit API was null");
} }

View file

@ -13,6 +13,9 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using Catalogger.Backend.Bot; using Catalogger.Backend.Bot;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Gateway.Commands; using Remora.Discord.API.Gateway.Commands;
@ -20,19 +23,20 @@ using Remora.Discord.API.Objects;
namespace Catalogger.Backend.Services; namespace Catalogger.Backend.Services;
public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient) public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedClient, Config config)
: BackgroundService : BackgroundService
{ {
private readonly ILogger _logger = logger.ForContext<StatusUpdateService>(); private readonly ILogger _logger = logger.ForContext<StatusUpdateService>();
private readonly HttpClient _client = new();
protected override async Task ExecuteAsync(CancellationToken ct) protected override async Task ExecuteAsync(CancellationToken ct)
{ {
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(3)); using var timer = new PeriodicTimer(TimeSpan.FromMinutes(3));
while (await timer.WaitForNextTickAsync(ct)) 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( _logger.Information(
"Updating status for {TotalShards} shards. Guild count is {GuildCount}", "Updating status for {TotalShards} shards. Guild count is {GuildCount}",
@ -40,6 +44,12 @@ public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedCli
CataloggerMetrics.GuildsCached.Value 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) foreach (var (shardId, client) in shardedClient.Shards)
{ {
if (!ShardedGatewayClient.IsConnected(client)) if (!ShardedGatewayClient.IsConnected(client))
@ -53,11 +63,13 @@ public class StatusUpdateService(ILogger logger, ShardedGatewayClient shardedCli
client.SubmitCommand(PresenceFor(shardId)); client.SubmitCommand(PresenceFor(shardId));
} }
await ReportStatsAsync();
} }
private UpdatePresence PresenceFor(int shardId) 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) if (shardedClient.TotalShards != 1)
status += $" | shard {shardId + 1}/{shardedClient.TotalShards}"; 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)] 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
);
} }

View file

@ -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<TimeoutService>();
private readonly ConcurrentDictionary<int, Timer> _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<TimeoutRepository>();
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<GuildRepository>();
await using var timeoutRepository =
scope.ServiceProvider.GetRequiredService<TimeoutRepository>();
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();
}
}

View file

@ -43,7 +43,7 @@ public class WebhookExecutorService(
private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>(); private readonly ILogger _logger = logger.ForContext<WebhookExecutorService>();
private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId); private readonly Snowflake _applicationId = DiscordSnowflake.New(config.Discord.ApplicationId);
private readonly ConcurrentDictionary<ulong, ConcurrentQueue<IEmbed>> _cache = new(); private readonly ConcurrentDictionary<ulong, ConcurrentQueue<IEmbed>> _cache = new();
private readonly ConcurrentDictionary<ulong, object> _locks = new(); private readonly ConcurrentDictionary<ulong, Lock> _locks = new();
private readonly ConcurrentDictionary<ulong, Timer> _timers = new(); private readonly ConcurrentDictionary<ulong, Timer> _timers = new();
private IUser? _selfUser; private IUser? _selfUser;
@ -60,8 +60,14 @@ public class WebhookExecutorService(
/// </summary> /// </summary>
public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed) public void QueueLog(Guild guildConfig, LogChannelType logChannelType, IEmbed embed)
{ {
var logChannel = GetLogChannel(guildConfig, logChannelType, channelId: null, userId: null); var logChannel = GetLogChannel(
_logger.Debug("Channel to log {Type} to: {LogChannel}", logChannelType, logChannel); guildConfig,
logChannelType,
channelId: null,
userId: null,
roleId: null,
roleIds: null
);
if (logChannel == null) if (logChannel == null)
return; return;
@ -71,17 +77,16 @@ public class WebhookExecutorService(
/// <summary> /// <summary>
/// Queues a log embed for the given channel ID. /// Queues a log embed for the given channel ID.
/// </summary> /// </summary>
public void QueueLog(ulong channelId, IEmbed embed) public void QueueLog(ulong? channelId, IEmbed embed)
{ {
_logger.Debug("Channel to log to: {LogChannel}", channelId); if (channelId is null or 0)
if (channelId == 0)
return; return;
var queue = _cache.GetOrAdd(channelId, []); var queue = _cache.GetOrAdd(channelId.Value, []);
queue.Enqueue(embed); queue.Enqueue(embed);
_cache[channelId] = queue; _cache[channelId.Value] = queue;
SetTimer(channelId, queue); SetTimer(channelId.Value, queue);
} }
/// <summary> /// <summary>
@ -184,7 +189,7 @@ public class WebhookExecutorService(
private List<IEmbed> TakeFromQueue(ulong channelId) private List<IEmbed> TakeFromQueue(ulong channelId)
{ {
var queue = _cache.GetOrAdd(channelId, []); var queue = _cache.GetOrAdd(channelId, []);
var channelLock = _locks.GetOrAdd(channelId, channelId); var channelLock = _locks.GetOrAdd(channelId, new Lock());
lock (channelLock) lock (channelLock)
{ {
var totalContentLength = 0; var totalContentLength = 0;
@ -253,18 +258,248 @@ public class WebhookExecutorService(
} }
public ulong? GetLogChannel( public ulong? GetLogChannel(
Guild guild,
LogChannelType logChannelType,
Snowflake? channelId = null,
ulong? userId = null,
Snowflake? roleId = null,
IReadOnlyList<Snowflake>? 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, Guild guild,
LogChannelType logChannelType, LogChannelType logChannelType,
Snowflake? channelId = null, Snowflake? channelId = null,
ulong? userId = null ulong? userId = null
) )
{ {
if (channelId == null) _logger.Verbose(
return GetDefaultLogChannel(guild, logChannelType); "Getting log channel for event {Event}. Channel ID: {ChannelId}, user ID: {UserId}",
if (!channelCache.TryGet(channelId.Value, out var channel)) logChannelType,
return null; 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 ( if (
channel.Type channel.Type
is ChannelType.AnnouncementThread is ChannelType.AnnouncementThread
@ -274,8 +509,17 @@ public class WebhookExecutorService(
{ {
// parent_id should always have a value for threads // parent_id should always have a value for threads
channelId = channel.ParentID.Value!.Value; channelId = channel.ParentID.Value!.Value;
if (!channelCache.TryGet(channelId.Value, out var parentChannel)) if (!channelCache.TryGet(channelId, out var parentChannel))
return GetDefaultLogChannel(guild, logChannelType); {
_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; categoryId = parentChannel.ParentID.Value;
} }
else else
@ -284,64 +528,11 @@ public class WebhookExecutorService(
categoryId = channel.ParentID.Value; categoryId = channel.ParentID.Value;
} }
// Check if the channel, or its category, or the user is ignored return true;
if (
guild.Channels.IgnoredChannels.Contains(channelId.Value.Value)
|| categoryId != null && guild.Channels.IgnoredChannels.Contains(categoryId.Value.Value)
)
return null;
if (userId != null)
{
if (guild.Channels.IgnoredUsers.Contains(userId.Value))
return null;
// Check the channel-local and category-local ignored users
var channelIgnoredUsers =
guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(channelId.Value.Value)
?? [];
var categoryIgnoredUsers =
(
categoryId != null
? guild.Channels.IgnoredUsersPerChannel.GetValueOrDefault(
categoryId.Value.Value
)
: []
) ?? [];
if (channelIgnoredUsers.Concat(categoryIgnoredUsers).Contains(userId.Value))
return null;
}
// These three events can be redirected to other channels. Redirects can be on a channel or category level.
// Obviously, the events are only redirected if they're supposed to be logged in the first place.
if (
logChannelType
is LogChannelType.MessageUpdate
or LogChannelType.MessageDelete
or LogChannelType.MessageDeleteBulk
)
{
if (GetDefaultLogChannel(guild, logChannelType) == 0)
return null;
var categoryRedirect =
categoryId != null
? guild.Channels.Redirects.GetValueOrDefault(categoryId.Value.Value)
: 0;
if (
guild.Channels.Redirects.TryGetValue(channelId.Value.Value, out var channelRedirect)
)
return channelRedirect;
return categoryRedirect != 0
? categoryRedirect
: GetDefaultLogChannel(guild, logChannelType);
}
return GetDefaultLogChannel(guild, logChannelType);
} }
public static ulong GetDefaultLogChannel(Guild guild, LogChannelType channelType) => public static ulong GetDefaultLogChannel(Guild guild, LogChannelType logChannelType) =>
channelType switch logChannelType switch
{ {
LogChannelType.GuildUpdate => guild.Channels.GuildUpdate, LogChannelType.GuildUpdate => guild.Channels.GuildUpdate,
LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate, LogChannelType.GuildEmojisUpdate => guild.Channels.GuildEmojisUpdate,
@ -366,7 +557,7 @@ public class WebhookExecutorService(
LogChannelType.MessageUpdate => guild.Channels.MessageUpdate, LogChannelType.MessageUpdate => guild.Channels.MessageUpdate,
LogChannelType.MessageDelete => guild.Channels.MessageDelete, LogChannelType.MessageDelete => guild.Channels.MessageDelete,
LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk, LogChannelType.MessageDeleteBulk => guild.Channels.MessageDeleteBulk,
_ => throw new ArgumentOutOfRangeException(nameof(channelType)), _ => throw new ArgumentOutOfRangeException(nameof(logChannelType)),
}; };
} }

View file

@ -7,6 +7,9 @@ LogQueries = false
SeqLogUrl = http://localhost:5341 SeqLogUrl = http://localhost:5341
# Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often. # Whether to enable Prometheus metrics. If disabled, Catalogger will update metrics manually every so often.
EnableMetrics = false 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] [Database]
Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres Url = Host=localhost;Database=postgres;Username=postgres;Password=postgres

View file

@ -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=="
}
}
}
}

View file

@ -1,3 +1,4 @@
.vscode
node_modules node_modules
# Output # Output
@ -19,3 +20,6 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# yarn 4
.yarn

View file

@ -0,0 +1 @@
nodeLinker: node-modules

View file

@ -17,11 +17,13 @@
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltestrap/sveltestrap": "^6.2.7", "@sveltestrap/sveltestrap": "^6.2.7",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"@types/file-saver": "^2.0.7",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0", "eslint-plugin-svelte": "^2.36.0",
"file-saver": "^2.0.5",
"globals": "^15.0.0", "globals": "^15.0.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"marked": "^14.1.3", "marked": "^14.1.3",
@ -37,5 +39,6 @@
"vite": "^5.0.3", "vite": "^5.0.3",
"vite-plugin-markdown": "^2.2.0" "vite-plugin-markdown": "^2.2.0"
}, },
"type": "module" "type": "module",
"packageManager": "yarn@4.5.1"
} }

View file

@ -77,7 +77,12 @@ export type FullGuild = {
icon_url: string; icon_url: string;
categories: GuildCategory[]; categories: GuildCategory[];
channels_without_category: GuildChannel[]; channels_without_category: GuildChannel[];
config: GuildConfig; roles: GuildRole[];
ignored_channels: string[];
ignored_roles: string[];
messages: MessageConfig;
channels: ChannelConfig;
key_roles: string[];
}; };
export type GuildCategory = { export type GuildCategory = {
@ -93,6 +98,13 @@ export type GuildChannel = {
can_redirect_from: boolean; can_redirect_from: boolean;
}; };
export type GuildRole = {
id: string;
name: string;
position: string;
colour: string;
};
export type CurrentUser = { export type CurrentUser = {
user: User; user: User;
guilds: PartialGuild[]; guilds: PartialGuild[];
@ -105,14 +117,15 @@ export type ApiError = {
message: string; message: string;
}; };
export type GuildConfig = GuildChannelConfig & { export type MessageConfig = {
ignored_channels: string[]; ignored_channels: string[];
ignored_users: string[]; ignored_users: string[];
ignored_roles: string[];
ignored_users_per_channel: Record<string, string[]>; ignored_users_per_channel: Record<string, string[]>;
redirects: Record<string, string>;
}; };
export type GuildChannelConfig = { export type ChannelConfig = {
redirects: Record<string, string>;
guild_update: string; guild_update: string;
guild_emojis_update: string; guild_emojis_update: string;
guild_role_create: string; guild_role_create: string;

Some files were not shown because too many files have changed in this diff Show more