Compare commits
57 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f3dfc74d6 | |||
| a4a6fb5d31 | |||
| 24f6aee57d | |||
| 8a4e3ff184 | |||
| 84c3b42874 | |||
| cb43ac1a50 | |||
| db3e6fa7b0 | |||
| 1a63540f89 | |||
| 0d7e809ef6 | |||
| 27e1903c4b | |||
| 5157105c35 | |||
| 7749c9d9e2 | |||
| 4047df8610 | |||
| 27e77eeaed | |||
| c06376dfda | |||
| 04d6bc958e | |||
| b3c541f743 | |||
| 48a11be7b7 | |||
| e8feedb979 | |||
| 1f4aba0868 | |||
| 223f808151 | |||
| 4eb5c16451 | |||
| e12bd6194b | |||
| 19d9f33454 | |||
| 0cac964aa6 | |||
| d48ab7e16e | |||
| cbb07f9cc3 | |||
| 681aaa8254 | |||
| 254a50da4d | |||
| 0564206bf7 | |||
| 492283b9c1 | |||
| 2deac26fc8 | |||
| db5d7bb4f8 | |||
| e6d68338db | |||
| f0fcfd7bd3 | |||
| e7eaa9f13a | |||
| 5f24a6aa88 | |||
| 5ac607fd0a | |||
| a22057b9fa | |||
| 8ed9b4b143 | |||
| 4b74005110 | |||
| c28f987240 | |||
| a34b5479c0 | |||
| 00af303555 | |||
| 8f154ce5ae | |||
| dce148b844 | |||
| ae4d9018ea | |||
| 87b3281c8d | |||
| 225c162603 | |||
| 735c71b6f7 | |||
| 8ae4ba722a | |||
| be8bc9b199 | |||
| 65d286389d | |||
| b52df95b65 | |||
| a50a8567dd | |||
| b56a71e105 | |||
| 56af787e57 |
148 changed files with 8268 additions and 4577 deletions
|
|
@ -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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
config.ini
|
config.ini
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
|
.version
|
||||||
|
|
|
||||||
1
.idea/.idea.catalogger/.idea/sqldialects.xml
generated
1
.idea/.idea.catalogger/.idea/sqldialects.xml
generated
|
|
@ -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>
|
||||||
|
|
@ -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}";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
102
Catalogger.Backend/Api/GuildsController.Backup.cs
Normal file
102
Catalogger.Backend/Api/GuildsController.Backup.cs
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs
Normal file
166
Catalogger.Backend/Api/GuildsController.ChannelsRoles.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Catalogger.Backend/Api/GuildsController.KeyRoles.cs
Normal file
63
Catalogger.Backend/Api/GuildsController.KeyRoles.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
304
Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs
Normal file
304
Catalogger.Backend/Bot/Commands/IgnoreEntitiesCommands.cs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs
Normal file
122
Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Roles.cs
Normal 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(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs
Normal file
124
Catalogger.Backend/Bot/Commands/IgnoreMessageCommands.Users.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
136
Catalogger.Backend/Bot/Commands/WatchlistCommands.cs
Normal file
136
Catalogger.Backend/Bot/Commands/WatchlistCommands.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
46
Catalogger.Backend/BuildInfo.cs
Normal file
46
Catalogger.Backend/BuildInfo.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
drop table pluralkit_systems;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
drop table timeouts;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
update guilds set channels = (channels || messages) - 'IgnoredRoles';
|
||||||
|
|
||||||
|
alter table guilds drop column messages;
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table guilds drop column ignored_channels;
|
||||||
|
alter table guilds drop column ignored_roles;
|
||||||
|
|
@ -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[];
|
||||||
120
Catalogger.Backend/Database/Models/ConfigExport.cs
Normal file
120
Catalogger.Backend/Database/Models/ConfigExport.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
Catalogger.Backend/Database/Models/DiscordTimeout.cs
Normal file
12
Catalogger.Backend/Database/Models/DiscordTimeout.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
152
Catalogger.Backend/Extensions/LogUtils.cs
Normal file
152
Catalogger.Backend/Extensions/LogUtils.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
27
Catalogger.Backend/JsonUtils.cs
Normal file
27
Catalogger.Backend/JsonUtils.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
Catalogger.Backend/Services/TimeoutService.cs
Normal file
122
Catalogger.Backend/Services/TimeoutService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4
Catalogger.Frontend/.gitignore
vendored
4
Catalogger.Frontend/.gitignore
vendored
|
|
@ -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
|
||||||
1
Catalogger.Frontend/.yarnrc.yml
Normal file
1
Catalogger.Frontend/.yarnrc.yml
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue