diff --git a/BaseBotService/Commands/UserModule.cs b/BaseBotService/Commands/UserModule.cs index 37c0f5ed..2cdb8b64 100644 --- a/BaseBotService/Commands/UserModule.cs +++ b/BaseBotService/Commands/UserModule.cs @@ -20,13 +20,15 @@ public class UserModule : BaseModule private readonly ITranslationService _translationService; private readonly IEngagementService _engagementService; private readonly IMemberRepository _memberRepository; + private readonly IGuildMemberRepository _guildMemberRepository; - public UserModule(ILogger logger, ITranslationService translationService, IEngagementService engagementService, IMemberRepository memberRepository) + public UserModule(ILogger logger, ITranslationService translationService, IEngagementService engagementService, IMemberRepository memberRepository, IGuildMemberRepository guildMemberRepository) { Logger = logger.ForContext(); _translationService = translationService; _engagementService = engagementService; _memberRepository = memberRepository; + _guildMemberRepository = guildMemberRepository; } [UserCommand("User Profile")] @@ -247,6 +249,39 @@ await ModifyOriginalResponseAsync(x => }); } + [SlashCommand("leaderboard", "Show the most active users on this server.")] + public async Task LeaderboardAsync( + [Summary(description: "Number of users to display (1-100)")] int amount = 10) + { + amount = Math.Clamp(amount, 1, 100); + var top = _guildMemberRepository.GetTopUsers(Context.Guild.Id, amount) + .ToList(); + + StringBuilder description = new(); + for (int i = 0; i < top.Count; i++) + { + var member = top[i]; + var user = Context.Guild.GetUser(member.MemberId); + string name = user?.Username ?? member.MemberId.ToString(); + description.AppendLine( + _translationService.GetString( + "leaderboard-entry", + TranslationHelper.Arguments( + "rank", i + 1, + "user", name, + "points", member.ActivityPoints))); + } + + EmbedBuilder embed = GetEmbedBuilder() + .WithTitle( + _translationService.GetString( + "leaderboard-title", + TranslationHelper.Arguments("amount", amount))) + .WithDescription(description.ToString()); + + await RespondOrFollowupAsync(embed: embed.Build()); + } + internal double GetActivityScore(IGuildUser user, IGuildUser bot) { const double averageOnlineHours = 4; diff --git a/BaseBotService/Core/DiscordEventListener.cs b/BaseBotService/Core/DiscordEventListener.cs index d4a4dc55..91038485 100644 --- a/BaseBotService/Core/DiscordEventListener.cs +++ b/BaseBotService/Core/DiscordEventListener.cs @@ -41,6 +41,7 @@ public async Task StartAsync() _client.JoinedGuild += (guild) => _mediator.Publish(new JoinedGuildNotification(guild), _cancellationToken); _client.LeftGuild += (guild) => _mediator.Publish(new LeftGuildNotification(guild), _cancellationToken); _client.UserJoined += (user) => _mediator.Publish(new UserJoinedNotification(user), _cancellationToken); + _client.UserLeft += (guild, user) => _mediator.Publish(new UserLeftNotification(guild, user), _cancellationToken); _handler.Log += (msg) => _mediator.Publish(new LogNotification(msg), _cancellationToken); _ = await _handler.AddModulesAsync(Assembly.GetEntryAssembly(), _services); diff --git a/BaseBotService/Core/Messages/UserLeftNotification.cs b/BaseBotService/Core/Messages/UserLeftNotification.cs new file mode 100644 index 00000000..e8350f73 --- /dev/null +++ b/BaseBotService/Core/Messages/UserLeftNotification.cs @@ -0,0 +1,14 @@ +using Discord.WebSocket; + +namespace BaseBotService.Core.Messages; +public class UserLeftNotification : INotification +{ + public SocketGuild Guild { get; } + public SocketUser User { get; } + + public UserLeftNotification(SocketGuild guild, SocketUser user) + { + Guild = guild; + User = user; + } +} diff --git a/BaseBotService/Data/Interfaces/IGuildMemberRepository.cs b/BaseBotService/Data/Interfaces/IGuildMemberRepository.cs index d6435c7b..de2cacc5 100644 --- a/BaseBotService/Data/Interfaces/IGuildMemberRepository.cs +++ b/BaseBotService/Data/Interfaces/IGuildMemberRepository.cs @@ -36,4 +36,12 @@ public interface IGuildMemberRepository /// True if the delete was successful, otherwise false. bool DeleteUser(ulong guildId, ulong userId); int DeleteGuild(ulong guildId); + + /// + /// Gets the top users of a guild ordered by their activity points. + /// + /// The unique identifier of the guild. + /// The maximum number of users to return. + /// An enumerable of ordered by activity points. + IEnumerable GetTopUsers(ulong guildId, int limit); } \ No newline at end of file diff --git a/BaseBotService/Data/Repositories/GuildMemberRepository.cs b/BaseBotService/Data/Repositories/GuildMemberRepository.cs index 39eaf000..90788f1b 100644 --- a/BaseBotService/Data/Repositories/GuildMemberRepository.cs +++ b/BaseBotService/Data/Repositories/GuildMemberRepository.cs @@ -38,4 +38,10 @@ public int DeleteGuild(ulong guildId) } return 0; } + + public IEnumerable GetTopUsers(ulong guildId, int limit) + => _guildMembers + .Find(a => a.GuildId == guildId) + .OrderByDescending(u => u.ActivityPoints) + .Take(limit); } \ No newline at end of file diff --git a/BaseBotService/Interactions/EntityLifecycleHandler.cs b/BaseBotService/Interactions/EntityLifecycleHandler.cs index c7c383ee..e5675ebd 100644 --- a/BaseBotService/Interactions/EntityLifecycleHandler.cs +++ b/BaseBotService/Interactions/EntityLifecycleHandler.cs @@ -4,7 +4,7 @@ using BaseBotService.Data.Models; namespace BaseBotService.Interactions; -public class EntityLifecycleHandler : INotificationHandler, INotificationHandler, INotificationHandler +public class EntityLifecycleHandler : INotificationHandler, INotificationHandler, INotificationHandler, INotificationHandler { private readonly ILogger _logger; private readonly IMemberRepository _memberRepository; @@ -54,4 +54,19 @@ public Task Handle(JoinedGuildNotification notification, CancellationToken cance return Task.CompletedTask; } + + public Task Handle(UserLeftNotification notification, CancellationToken cancellationToken) + { + _logger.Debug($"{nameof(EntityLifecycleHandler)} received {nameof(UserLeftNotification)}"); + + GuildMemberHC? member = _guildMemberRepository.GetUser(notification.Guild.Id, notification.User.Id); + if (member != null) + { + member.ActivityPoints = 0; + _guildMemberRepository.UpdateUser(member); + _logger.Information($"User {notification.User.Id} left {notification.Guild.Id}, reset ActivityPoints."); + } + + return Task.CompletedTask; + } } diff --git a/BaseBotService/Locales/de.ftl b/BaseBotService/Locales/de.ftl index 1d1f9cdc..87ace986 100644 --- a/BaseBotService/Locales/de.ftl +++ b/BaseBotService/Locales/de.ftl @@ -134,6 +134,9 @@ profile = { $username } @ { $guildname } *[other] Invalid activity score } +leaderboard-title = Top { $amount } active users +leaderboard-entry = { $rank }. { $user } - { $points } pts + diff --git a/BaseBotService/Locales/en.ftl b/BaseBotService/Locales/en.ftl index 6cec1074..55be16c8 100644 --- a/BaseBotService/Locales/en.ftl +++ b/BaseBotService/Locales/en.ftl @@ -138,6 +138,9 @@ profile = { $username } @ { $guildname } *[other] Invalid activity score } +leaderboard-title = Top { $amount } active users +leaderboard-entry = { $rank }. { $user } - { $points } pts + #################################### diff --git a/BaseBotService/Locales/es.ftl b/BaseBotService/Locales/es.ftl index 95bb7a19..88f649e4 100644 --- a/BaseBotService/Locales/es.ftl +++ b/BaseBotService/Locales/es.ftl @@ -134,6 +134,9 @@ profile = { $username } @ { $guildname } *[other] Invalid activity score } +leaderboard-title = Top { $amount } active users +leaderboard-entry = { $rank }. { $user } - { $points } pts + diff --git a/BaseBotService/Locales/fr.ftl b/BaseBotService/Locales/fr.ftl index 7a3d5a07..3eff7486 100644 --- a/BaseBotService/Locales/fr.ftl +++ b/BaseBotService/Locales/fr.ftl @@ -134,6 +134,9 @@ profile = { $username } @ { $guildname } *[other] Invalid activity score } +leaderboard-title = Top { $amount } active users +leaderboard-entry = { $rank }. { $user } - { $points } pts + diff --git a/BaseBotServiceTests/Commands/UserModuleTests.cs b/BaseBotServiceTests/Commands/UserModuleTests.cs index ce0ef803..3df891a9 100644 --- a/BaseBotServiceTests/Commands/UserModuleTests.cs +++ b/BaseBotServiceTests/Commands/UserModuleTests.cs @@ -13,6 +13,7 @@ public class UserModuleTests private ITranslationService _translationService; private IEngagementService _engagementService; private IMemberRepository _memberRepository; + private IGuildMemberRepository _guildMemberRepository; private ILogger _logger; private UserModule _userModule; private readonly Faker _faker = new(); @@ -23,9 +24,10 @@ public void SetUp() _translationService = Substitute.For(); _engagementService = Substitute.For(); _memberRepository = Substitute.For(); + _guildMemberRepository = Substitute.For(); _logger = Substitute.For(); - _userModule = new UserModule(_logger, _translationService, _engagementService, _memberRepository); + _userModule = new UserModule(_logger, _translationService, _engagementService, _memberRepository, _guildMemberRepository); } [Test] diff --git a/BaseBotServiceTests/Data/Repositories/GuildMemberRepositoryTests.cs b/BaseBotServiceTests/Data/Repositories/GuildMemberRepositoryTests.cs index be2243ee..7c190c18 100644 --- a/BaseBotServiceTests/Data/Repositories/GuildMemberRepositoryTests.cs +++ b/BaseBotServiceTests/Data/Repositories/GuildMemberRepositoryTests.cs @@ -91,4 +91,28 @@ public void DeleteUser_ShouldDeleteExistingUser() var deletedUser = _guildMembers.FindOne(u => u.MemberId == existingUser.MemberId && u.GuildId == existingUser.GuildId); deletedUser.ShouldBeNull(); } + + [Test] + public void GetTopUsers_ShouldReturnOrderedLimitedList() + { + // Arrange + var guild = FakeDataHelper.GuildFaker.Generate(); + _guilds.Insert(guild); + foreach (var member in guild.Members) + { + _guildMembers.Insert(member); + } + int limit = Math.Min(3, guild.Members.Count); + + // Act + var result = _repository.GetTopUsers(guild.GuildId, limit).ToList(); + + // Assert + result.Count.ShouldBe(limit); + var expected = guild.Members + .OrderByDescending(m => m.ActivityPoints) + .Take(limit) + .Select(m => m.MemberId); + result.Select(r => r.MemberId).ShouldBe(expected); + } } diff --git a/README.md b/README.md index 024425fc..30cb19bd 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Project Honeycomb is a Discord bot designed to provide artists with some useful - **Commission Request Form:** Users can request a commission by filling out a form with necessary details. - **OC Reference Management:** Users can add references with descriptions for their OCs to easily access them when needed. - **Loyalty Points:** Artists can reward their loyal customers by awarding loyalty points, which can be redeemed for future commissions. +- **Activity Leaderboard:** See which members are most active in your server. - **Announcement of New Art:** Artists can announce their new artwork with references to different platforms like Twitter, DeviantArt, FurAffinity, Patreon, and more. - **Progress Tracking of Commissions:** Artists can track the progress of their commissions using the bot. - **Raffles:** Organize raffles on Discord or Twitter and randomly draw the winners.