From bccf7caf34bc5d48ad7d525e782584cbc700a1dd Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 20 Oct 2024 15:20:22 +0200 Subject: [PATCH] feat(dashboard): working ignored channels page --- .../Api/GuildsController.Ignores.cs | 112 ++++++++++++++++++ Catalogger.Backend/Api/GuildsController.cs | 2 + .../Guilds/GuildMembersChunkResponder.cs | 1 + .../Members/GuildMemberAddResponder.cs | 1 + .../Members/GuildMemberRemoveResponder.cs | 1 + .../Members/GuildMemberUpdateResponder.cs | 8 ++ Catalogger.Backend/Cache/IMemberCache.cs | 17 +++ .../InMemoryCache/InMemoryMemberCache.cs | 17 +++ .../Cache/RedisCache/RedisMemberCache.cs | 48 ++++++++ Catalogger.Frontend/src/lib/api.ts | 2 +- Catalogger.Frontend/src/routes/+layout.svelte | 4 +- .../src/routes/dash/+page.svelte | 1 - .../[guildId]/ignored-channels/+page.svelte | 98 ++++++++++++++- 13 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 Catalogger.Backend/Api/GuildsController.Ignores.cs diff --git a/Catalogger.Backend/Api/GuildsController.Ignores.cs b/Catalogger.Backend/Api/GuildsController.Ignores.cs new file mode 100644 index 0000000..d2b1ec4 --- /dev/null +++ b/Catalogger.Backend/Api/GuildsController.Ignores.cs @@ -0,0 +1,112 @@ +// Copyright (C) 2021-present sam (starshines.gay) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using Catalogger.Backend.Database.Queries; +using Microsoft.AspNetCore.Mvc; +using Remora.Discord.API; +using Remora.Discord.API.Abstractions.Objects; + +namespace Catalogger.Backend.Api; + +public partial class GuildsController +{ + [HttpPut("ignored-channels/{channelId}")] + public async Task AddIgnoredChannelAsync(string id, ulong channelId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await db.GetGuildAsync(guildId); + + if (guildConfig.Channels.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.Channels.IgnoredChannels.Add(channelId); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("ignored-channels/{channelId}")] + public async Task RemoveIgnoredChannelAsync(string id, ulong channelId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await db.GetGuildAsync(guildId); + + guildConfig.Channels.IgnoredChannels.Remove(channelId); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpGet("users")] + public async Task ListUsersAsync(string id, [FromQuery] string query) + { + var (guildId, _) = await ParseGuildAsync(id); + var members = await memberCache.GetMemberNamesAsync(guildId, query); + + return Ok(members.OrderBy(m => m.Name).Select(m => new UserQueryResponse(m.Name, m.Id))); + } + + private record UserQueryResponse(string Name, string Id); + + [HttpPut("ignored-users/{userId}")] + public async Task AddIgnoredUserAsync(string id, ulong userId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await db.GetGuildAsync(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); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpDelete("ignored-users/{userId}")] + public async Task RemoveIgnoredUserAsync(string id, ulong userId) + { + var (guildId, _) = await ParseGuildAsync(id); + var guildConfig = await db.GetGuildAsync(guildId); + + guildConfig.Channels.IgnoredUsers.Remove(userId); + db.Update(guildConfig); + await db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/Catalogger.Backend/Api/GuildsController.cs b/Catalogger.Backend/Api/GuildsController.cs index a793f27..9032a67 100644 --- a/Catalogger.Backend/Api/GuildsController.cs +++ b/Catalogger.Backend/Api/GuildsController.cs @@ -15,6 +15,7 @@ using System.Net; using Catalogger.Backend.Api.Middleware; +using Catalogger.Backend.Cache; using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Database; using Catalogger.Backend.Database.Queries; @@ -32,6 +33,7 @@ public partial class GuildsController( DatabaseContext db, ChannelCache channelCache, RedisService redisService, + IMemberCache memberCache, DiscordRequestService discordRequestService ) : ApiControllerBase { diff --git a/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs b/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs index 5bd4002..8a7c786 100644 --- a/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Guilds/GuildMembersChunkResponder.cs @@ -35,6 +35,7 @@ public class GuildMembersChunkResponder(ILogger logger, IMemberCache memberCache ); await memberCache.SetManyAsync(evt.GuildID, evt.Members); + await memberCache.SetMemberNamesAsync(evt.GuildID, evt.Members); if (evt.ChunkIndex == evt.ChunkCount - 1) { diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs index d9009d1..d2a4af0 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs @@ -49,6 +49,7 @@ public class GuildMemberAddResponder( public async Task RespondAsync(IGuildMemberAdd member, CancellationToken ct = default) { await memberCache.SetAsync(member.GuildID, member); + await memberCache.SetMemberNamesAsync(member.GuildID, [member]); var user = member.User.GetOrThrow(); userCache.UpdateUser(user); diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs index d2d2ff2..9734556 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberRemoveResponder.cs @@ -128,6 +128,7 @@ public class GuildMemberRemoveResponder( finally { await memberCache.RemoveAsync(evt.GuildID, evt.User.ID); + await memberCache.TryRemoveMemberNameAsync(evt.GuildID, evt.User.Username); } } } diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs index 058b139..6052a5f 100644 --- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs +++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberUpdateResponder.cs @@ -96,6 +96,7 @@ public class GuildMemberUpdateResponder( finally { await memberCache.UpdateAsync(newMember); + userCache.UpdateUser(newMember.User); } @@ -194,6 +195,13 @@ public class GuildMemberUpdateResponder( **After:** {newMember.User.Tag()} """ ); + + await memberCache.UpdateMemberNameAsync( + newMember.GuildID, + newMember.User.ID, + oldUser.Tag(), + newMember.User.Tag() + ); } var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct); diff --git a/Catalogger.Backend/Cache/IMemberCache.cs b/Catalogger.Backend/Cache/IMemberCache.cs index aaf389d..1c108a3 100644 --- a/Catalogger.Backend/Cache/IMemberCache.cs +++ b/Catalogger.Backend/Cache/IMemberCache.cs @@ -29,4 +29,21 @@ public interface IMemberCache public Task MarkAsCachedAsync(Snowflake guildId); public Task MarkAsUncachedAsync(Snowflake guildId); public Task UpdateAsync(IGuildMemberUpdate newMember); + + // These methods can be stubbed out for any implementation that isn't intended for use with the dashboard. + public Task SetMemberNamesAsync(Snowflake guildId, IEnumerable members); + + public Task UpdateMemberNameAsync( + Snowflake guildId, + Snowflake userId, + string prevName, + string newName + ); + + public Task> GetMemberNamesAsync( + Snowflake guildId, + string prefix + ); + + public Task TryRemoveMemberNameAsync(Snowflake guildId, string username); } diff --git a/Catalogger.Backend/Cache/InMemoryCache/InMemoryMemberCache.cs b/Catalogger.Backend/Cache/InMemoryCache/InMemoryMemberCache.cs index 98e6bf5..45a271c 100644 --- a/Catalogger.Backend/Cache/InMemoryCache/InMemoryMemberCache.cs +++ b/Catalogger.Backend/Cache/InMemoryCache/InMemoryMemberCache.cs @@ -128,4 +128,21 @@ public class InMemoryMemberCache(IDiscordRestGuildAPI guildApi, ILogger logger) _members[(newMember.GuildID, newMember.User.ID)] = member; } + + public Task SetMemberNamesAsync(Snowflake guildId, IEnumerable members) => + Task.CompletedTask; + + public Task UpdateMemberNameAsync( + Snowflake guildId, + Snowflake userId, + string prevName, + string newName + ) => Task.CompletedTask; + + public Task> GetMemberNamesAsync( + Snowflake guildId, + string prefix + ) => Task.FromResult>([]); + + public Task TryRemoveMemberNameAsync(Snowflake guildId, string username) => Task.CompletedTask; } diff --git a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs index b6e5e62..302f80e 100644 --- a/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs +++ b/Catalogger.Backend/Cache/RedisCache/RedisMemberCache.cs @@ -14,12 +14,14 @@ // along with this program. If not, see . using Catalogger.Backend.Database.Redis; +using Catalogger.Backend.Extensions; using Remora.Discord.API; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Objects; using Remora.Rest.Core; +using StackExchange.Redis; namespace Catalogger.Backend.Cache.RedisCache; @@ -146,9 +148,55 @@ public class RedisMemberCache( await SetInnerAsync(newMember.GuildID, member); } + public async Task SetMemberNamesAsync(Snowflake guildId, IEnumerable members) + { + await redisService + .GetDatabase() + .HashSetAsync( + MemberNamesKey(guildId), + members + .Select(m => new HashEntry(m.User.Value.Tag(), m.User.Value.ID.ToString())) + .ToArray() + ); + } + + public async Task UpdateMemberNameAsync( + Snowflake guildId, + Snowflake userId, + string prevName, + string newName + ) => + await Task.WhenAll( + redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), prevName), + redisService + .GetDatabase() + .HashSetAsync(MemberNamesKey(guildId), newName, userId.ToString()) + ); + + public async Task> GetMemberNamesAsync( + Snowflake guildId, + string prefix + ) + { + var entries = redisService + .GetDatabase() + .HashScanAsync(MemberNamesKey(guildId), $"{prefix}*", 50); + + var names = new List<(string Name, string Id)>(); + await foreach (var entry in entries) + names.Add((entry.Name, entry.Value)!); + + return names; + } + + public async Task TryRemoveMemberNameAsync(Snowflake guildId, string username) => + await redisService.GetDatabase().HashDeleteAsync(MemberNamesKey(guildId), username); + private const string GuildCacheKey = "cached-guilds"; private static string GuildMembersKey(Snowflake guildId) => $"guild-members:{guildId}"; + + private static string MemberNamesKey(Snowflake guildId) => $"guild-member-names:{guildId}"; } internal record RedisMember( diff --git a/Catalogger.Frontend/src/lib/api.ts b/Catalogger.Frontend/src/lib/api.ts index a3c1e2e..cd20aac 100644 --- a/Catalogger.Frontend/src/lib/api.ts +++ b/Catalogger.Frontend/src/lib/api.ts @@ -1,6 +1,6 @@ export const TOKEN_KEY = "catalogger-token"; -export type HttpMethod = "GET" | "POST" | "PATCH" | "DELETE"; +export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; export async function fastFetch( method: HttpMethod, diff --git a/Catalogger.Frontend/src/routes/+layout.svelte b/Catalogger.Frontend/src/routes/+layout.svelte index d0242d7..4070c7e 100644 --- a/Catalogger.Frontend/src/routes/+layout.svelte +++ b/Catalogger.Frontend/src/routes/+layout.svelte @@ -12,9 +12,9 @@
-
+
{#each $toastStore as toast} - + {#if toast.header}{toast.header}{/if} {toast.body} diff --git a/Catalogger.Frontend/src/routes/dash/+page.svelte b/Catalogger.Frontend/src/routes/dash/+page.svelte index 1c19f6b..1f35fad 100644 --- a/Catalogger.Frontend/src/routes/dash/+page.svelte +++ b/Catalogger.Frontend/src/routes/dash/+page.svelte @@ -1,7 +1,6 @@

Ignored channels

@@ -17,7 +86,28 @@ ignore channel update events, any changes to the channel will still be logged.

-
- - +
+ +
+ +
+ +
+ +
+

Currently ignored channels

+
+ + + {#each ignored as id} + + {channelName(id)} + + + {/each} +