diff --git a/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs
new file mode 100644
index 0000000..dc05f96
--- /dev/null
+++ b/Catalogger.Backend/Bot/Commands/WatchlistCommands.cs
@@ -0,0 +1,133 @@
+// Copyright (C) 2021-present sam (starshines.gay)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+using System.ComponentModel;
+using Catalogger.Backend.Cache;
+using Catalogger.Backend.Cache.InMemoryCache;
+using Catalogger.Backend.Database.Models;
+using Catalogger.Backend.Database.Repositories;
+using Catalogger.Backend.Extensions;
+using Remora.Commands.Attributes;
+using Remora.Commands.Groups;
+using Remora.Discord.API;
+using Remora.Discord.API.Abstractions.Objects;
+using Remora.Discord.API.Objects;
+using Remora.Discord.Commands.Attributes;
+using Remora.Discord.Commands.Feedback.Services;
+using Remora.Discord.Commands.Services;
+using Remora.Discord.Pagination.Extensions;
+using Remora.Rest.Core;
+using IResult = Remora.Results.IResult;
+
+namespace Catalogger.Backend.Bot.Commands;
+
+[Group("watchlist")]
+[Description("Commands for managing the server's watchlist.")]
+[DiscordDefaultMemberPermissions(DiscordPermission.ManageGuild)]
+public class WatchlistCommands(
+ WatchlistRepository watchlistRepository,
+ GuildCache guildCache,
+ IMemberCache memberCache,
+ UserCache userCache,
+ ContextInjectionService contextInjectionService,
+ FeedbackService feedbackService
+) : CommandGroup
+{
+ [Command("add")]
+ [Description("Add a user to the watchlist.")]
+ public async Task AddAsync(IUser user, string reason)
+ {
+ var (userId, guildId) = contextInjectionService.GetUserAndGuild();
+
+ var entry = await watchlistRepository.CreateEntryAsync(guildId, user.ID, userId, reason);
+ return await feedbackService.ReplyAsync(
+ $"Added {user.PrettyFormat()} to this server's watchlist, with the following reason:\n>>> {entry.Reason}"
+ );
+ }
+
+ [Command("remove")]
+ [Description("Remove a user from the watchlist.")]
+ public async Task RemoveAsync(IUser user)
+ {
+ var (userId, guildId) = contextInjectionService.GetUserAndGuild();
+ if (!await watchlistRepository.RemoveEntryAsync(guildId, user.ID))
+ {
+ return await feedbackService.ReplyAsync(
+ $"{user.PrettyFormat()} is not on the watchlist, so you can't remove them from it."
+ );
+ }
+
+ return await feedbackService.ReplyAsync(
+ $"Removed {user.PrettyFormat()} from the watchlist!"
+ );
+ }
+
+ [Command("show")]
+ [Description("Show the current watchlist.")]
+ public async Task ShowAsync()
+ {
+ var (userId, guildId) = contextInjectionService.GetUserAndGuild();
+ if (!guildCache.TryGet(guildId, out var guild))
+ throw new CataloggerError("Guild was not cached");
+
+ var watchlist = await watchlistRepository.GetGuildWatchlistAsync(guildId);
+ if (watchlist.Count == 0)
+ return await feedbackService.ReplyAsync(
+ "There are no entries on the watchlist right now."
+ );
+
+ var fields = new List();
+ foreach (var entry in watchlist)
+ fields.Add(await GenerateWatchlistEntryFieldAsync(guildId, entry));
+
+ return await feedbackService.SendContextualPaginatedMessageAsync(
+ userId,
+ DiscordUtils.PaginateFields(
+ fields,
+ title: $"Watchlist for {guild.Name} ({fields.Count})",
+ fieldsPerPage: 5
+ )
+ );
+ }
+
+ private async Task GenerateWatchlistEntryFieldAsync(
+ Snowflake guildId,
+ Watchlist entry
+ )
+ {
+ var user = await TryGetUserAsync(guildId, DiscordSnowflake.New(entry.UserId));
+ var fieldName = user != null ? user.Tag() : $"unknown user {entry.UserId}";
+
+ var moderator = await TryGetUserAsync(guildId, DiscordSnowflake.New(entry.ModeratorId));
+ var modName =
+ moderator != null
+ ? moderator.PrettyFormat()
+ : $"*(unknown user {entry.ModeratorId})* <@{entry.ModeratorId}>";
+
+ return new EmbedField(
+ Name: fieldName,
+ Value: $"""
+ **Moderator:** {modName}
+ **Added:**
+ **Reason:**
+ >>> {entry.Reason}
+ """
+ );
+ }
+
+ private async Task TryGetUserAsync(Snowflake guildId, Snowflake userId) =>
+ (await memberCache.TryGetAsync(guildId, userId))?.User.Value
+ ?? await userCache.GetUserAsync(userId);
+}
diff --git a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs
index 5ea725a..855affd 100644
--- a/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs
+++ b/Catalogger.Backend/Bot/Responders/Members/GuildMemberAddResponder.cs
@@ -156,7 +156,7 @@ public class GuildMemberAddResponder(
);
}
- var watchlist = await watchlistRepository.GetWatchlistEntryAsync(member.GuildID, user.ID);
+ var watchlist = await watchlistRepository.GetEntryAsync(member.GuildID, user.ID);
if (watchlist != null)
{
var moderator = await userCache.GetUserAsync(
diff --git a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs
index 8177f78..ce6bacd 100644
--- a/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs
+++ b/Catalogger.Backend/Database/Repositories/WatchlistRepository.cs
@@ -33,12 +33,43 @@ public class WatchlistRepository(ILogger logger, DatabaseConnection conn)
)
).ToList();
- public async Task GetWatchlistEntryAsync(Snowflake guildId, Snowflake userId) =>
+ public async Task GetEntryAsync(Snowflake guildId, Snowflake userId) =>
await conn.QueryFirstOrDefaultAsync(
"select * from watchlists where guild_id = @GuildId and user_id = @UserId",
new { GuildId = guildId.Value, UserId = userId.Value }
);
+ public async Task CreateEntryAsync(
+ Snowflake guildId,
+ Snowflake userId,
+ Snowflake moderatorId,
+ string reason
+ ) =>
+ await conn.QueryFirstAsync(
+ """
+ insert into watchlists (guild_id, user_id, added_at, moderator_id, reason)
+ values (@GuildId, @UserId, now(), @ModeratorId, @Reason)
+ on conflict (guild_id, user_id) do update
+ set moderator_id = @ModeratorId, added_at = now(), reason = @Reason
+ returning *
+ """,
+ new
+ {
+ GuildId = guildId.Value,
+ UserId = userId.Value,
+ ModeratorId = moderatorId.Value,
+ Reason = reason,
+ }
+ );
+
+ public async Task RemoveEntryAsync(Snowflake guildId, Snowflake userId) =>
+ (
+ await conn.ExecuteAsync(
+ "delete from watchlists where guild_id = @GuildId and user_id = @UserId",
+ new { GuildId = guildId.Value, UserId = userId.Value }
+ )
+ ) != 0;
+
public void Dispose()
{
conn.Dispose();
diff --git a/Catalogger.Backend/Program.cs b/Catalogger.Backend/Program.cs
index 27710e0..8516224 100644
--- a/Catalogger.Backend/Program.cs
+++ b/Catalogger.Backend/Program.cs
@@ -93,6 +93,7 @@ builder
.WithCommandGroup()
.WithCommandGroup()
.WithCommandGroup()
+ .WithCommandGroup()
// End command tree
.Finish()
.AddPagination()