From aa06f62258273f969b864d15ff8bc0f9b02a334c Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Sun, 30 Mar 2025 13:48:14 +1300
Subject: [PATCH] added .fishlb improve vote auth added discord and dbl stat
 reporting updated check lb quest to require fishlb too

---
 src/EllieBot.VotesApi/Common/AuthHandler.cs   | 55 ++++++++++--
 .../Modules/Gambling/VoteRewardService.cs     | 89 +++++++++++++++++++
 .../Modules/Games/Fish/FishService.cs         | 27 ++++++
 .../Modules/Games/Fish/FishingCommands.cs     | 46 +++++++++-
 .../QuestModels/CheckLeaderboardsQuest.cs     | 24 +++--
 src/EllieBot/_common/Services/UserService.cs  | 63 +++++++++++--
 src/EllieBot/data/commandlist.json            | 18 +++-
 src/EllieBot/strings/aliases.yml              |  6 +-
 .../strings/commands/commands.en-US.yml       | 10 ++-
 .../strings/responses/responses.en-US.json    |  4 +-
 10 files changed, 310 insertions(+), 32 deletions(-)

diff --git a/src/EllieBot.VotesApi/Common/AuthHandler.cs b/src/EllieBot.VotesApi/Common/AuthHandler.cs
index 6e71448..843f4b8 100644
--- a/src/EllieBot.VotesApi/Common/AuthHandler.cs
+++ b/src/EllieBot.VotesApi/Common/AuthHandler.cs
@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Security.Claims;
+using System.Text;
 using System.Text.Encodings.Web;
 using System.Threading.Tasks;
 using Microsoft.AspNetCore.Authentication;
@@ -25,20 +26,58 @@ namespace EllieBot.VotesApi
             : base(options, logger, encoder)
             => _conf = conf;
 
-        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
+        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
         {
+            if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
+            {
+                return AuthenticateResult.Fail("Authorization header missing");
+            }
+
+            var authToken = authHeader.ToString().Trim();
+            if (string.IsNullOrWhiteSpace(authToken))
+            {
+                return AuthenticateResult.Fail("Authorization token empty");
+            }
+
             var claims = new List<Claim>();
 
-            if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
-                claims.Add(new(DiscordsClaim, "true"));
+            var discsKey = _conf[ConfKeys.DISCORDS_KEY]?.Trim();
+            var topggKey = _conf[ConfKeys.TOPGG_KEY]?.Trim();
+            var dblKey = _conf[ConfKeys.DISCORDBOTLIST_KEY]?.Trim();
 
-            if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
+            if (!string.IsNullOrWhiteSpace(discsKey)
+                && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
+                    Encoding.UTF8.GetBytes(discsKey),
+                    Encoding.UTF8.GetBytes(authToken)))
+            {
+                claims.Add(new Claim(DiscordsClaim, "true"));
+            }
+
+            if (!string.IsNullOrWhiteSpace(topggKey)
+                && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
+                    Encoding.UTF8.GetBytes(topggKey),
+                    Encoding.UTF8.GetBytes(authToken)))
+            {
                 claims.Add(new Claim(TopggClaim, "true"));
-            
-            if (_conf[ConfKeys.DISCORDBOTLIST_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
-                claims.Add(new Claim(DiscordbotlistClaim, "true"));
+            }
 
-            return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
+            if (!string.IsNullOrWhiteSpace(dblKey)
+                && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
+                    Encoding.UTF8.GetBytes(dblKey),
+                    Encoding.UTF8.GetBytes(authToken)))
+            {
+                claims.Add(new Claim(DiscordbotlistClaim, "true"));
+            }
+
+            if (claims.Count == 0)
+            {
+                return AuthenticateResult.Fail("Invalid authorization token");
+            }
+
+            return AuthenticateResult.Success(
+                new AuthenticationTicket(
+                    new ClaimsPrincipal(new ClaimsIdentity(claims)),
+                    SchemeName));
         }
     }
 }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Gambling/VoteRewardService.cs b/src/EllieBot/Modules/Gambling/VoteRewardService.cs
index 2be7df2..fad5da8 100644
--- a/src/EllieBot/Modules/Gambling/VoteRewardService.cs
+++ b/src/EllieBot/Modules/Gambling/VoteRewardService.cs
@@ -1,10 +1,99 @@
 using System.Globalization;
+using System.Net.Http.Json;
 using Grpc.Core;
 using EllieBot.Common.ModuleBehaviors;
 using EllieBot.GrpcVotesApi;
 
 namespace EllieBot.Modules.Gambling.Services;
 
+public sealed class ServerCountRewardService(
+    IBotCreds creds,
+    IHttpClientFactory httpFactory,
+    DiscordSocketClient client,
+    ShardData shardData
+)
+    : IEService, IReadyExecutor
+{
+    
+    private Task dblTask = Task.CompletedTask;
+    private Task discordsTask = Task.CompletedTask;
+
+    public Task OnReadyAsync()
+    {
+        if (creds.Votes is null)
+            return Task.CompletedTask;
+
+        if (!string.IsNullOrWhiteSpace(creds.Votes.DblApiKey))
+        {
+            dblTask = Task.Run(async () =>
+            {
+                var dblApiKey = creds.Votes.DblApiKey;
+                while (true)
+                {
+                    try
+                    {
+                        using var httpClient = httpFactory.CreateClient();
+                        httpClient.DefaultRequestHeaders.Clear();
+                        httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", dblApiKey);
+                        httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json");
+                        await httpClient.PostAsJsonAsync(
+                            $"https://discordbotlist.com/api/v1/bots/{116275390695079945}/stats",
+                            new
+                            {
+                                users = client.Guilds.Sum(x => x.MemberCount),
+                                shard_id = shardData.ShardId,
+                                guilds = client.Guilds.Count,
+                                voice_connections = 0
+                            });
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Warning(ex, "Unable to send server count to DBL");
+                    }
+
+                    await Task.Delay(TimeSpan.FromHours(12));
+                }
+            });
+
+            if (shardData.ShardId != 0)
+                return Task.CompletedTask;
+
+            if (!string.IsNullOrWhiteSpace(creds.Votes.DiscordsApiKey))
+            {
+                discordsTask = Task.Run(async () =>
+                {
+                    var discordsApiKey = creds.Votes.DiscordsApiKey;
+                    while (true)
+                    {
+                        try
+                        {
+                            using var httpClient = httpFactory.CreateClient();
+                            httpClient.DefaultRequestHeaders.Clear();
+                            httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", discordsApiKey);
+                            httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type",
+                                "application/json");
+                            await httpClient.PostAsJsonAsync(
+                                $"https://discords.com/bots/api/bot/{client.CurrentUser.Id}/setservers",
+                                new
+                                {
+                                    server_count = client.Guilds.Count * shardData.TotalShards,
+                                });
+                        }
+                        catch (Exception ex)
+                        {
+                            Log.Warning(ex, "Unable to send server count to Discords");
+                        }
+
+                        await Task.Delay(TimeSpan.FromHours(12));
+                    }
+                });
+            }
+        }
+
+        return Task.CompletedTask;
+    }
+}
+
 public class VoteRewardService(
     ShardData shardData,
     GamblingConfigService gcs,
diff --git a/src/EllieBot/Modules/Games/Fish/FishService.cs b/src/EllieBot/Modules/Games/Fish/FishService.cs
index b31c30c..09deb17 100644
--- a/src/EllieBot/Modules/Games/Fish/FishService.cs
+++ b/src/EllieBot/Modules/Games/Fish/FishService.cs
@@ -1,5 +1,6 @@
 using System.Security.Cryptography;
 using System.Text;
+using AngleSharp.Common;
 using LinqToDB;
 using LinqToDB.EntityFrameworkCore;
 using EllieBot.Modules.Administration;
@@ -468,6 +469,26 @@ public sealed class FishService(
         return catches;
     }
 
+    public async Task<IReadOnlyCollection<(ulong UserId, int Catches)>> GetFishLbAsync(int page)
+    {
+        await using var ctx = db.GetDbContext();
+
+        var result = await ctx.GetTable<FishCatch>()
+            .GroupBy(x => x.UserId)
+            .OrderByDescending(x => x.Sum(x => x.Count))
+            .Skip(page * 10)
+            .Take(10)
+            .Select(x => new
+            {
+                UserId = x.Key,
+                Catches = x.Sum(x => x.Count)
+            })
+            .ToListAsyncLinqToDB()
+            .Fmap(x => x.Map(y => (y.UserId, y.Catches)));
+
+        return result;
+    }
+
     public string GetStarText(int resStars, int fishStars)
     {
         if (resStars == fishStars)
@@ -493,4 +514,10 @@ public sealed class FishService(
 
         return sb.ToString();
     }
+}
+
+public sealed class IUserFishCatch
+{
+    public ulong UserId { get; set; }
+    public int Count { get; set; }
 }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Games/Fish/FishingCommands.cs b/src/EllieBot/Modules/Games/Fish/FishingCommands.cs
index ed971d5..eb62b9f 100644
--- a/src/EllieBot/Modules/Games/Fish/FishingCommands.cs
+++ b/src/EllieBot/Modules/Games/Fish/FishingCommands.cs
@@ -1,4 +1,5 @@
 using EllieBot.Modules.Games.Fish;
+using EllieBot.Modules.Xp.Services;
 using Format = Discord.Format;
 
 namespace EllieBot.Modules.Games;
@@ -10,6 +11,7 @@ public partial class Games
         FishItemService fis,
         FishConfigService fcs,
         IBotCache cache,
+        UserService us,
         CaptchaService captchaService) : EllieModule
     {
         private static readonly EllieRandom _rng = new();
@@ -18,6 +20,7 @@ public partial class Games
             => new($"fishingwhitelist:{userId}");
 
         [Cmd]
+        [RequireContext(ContextType.Guild)]
         public async Task Fish()
         {
             var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
@@ -120,6 +123,7 @@ public partial class Games
         }
 
         [Cmd]
+        [RequireContext(ContextType.Guild)]
         public async Task FishSpot()
         {
             var ws = fs.GetWeatherForPeriods(7);
@@ -139,6 +143,7 @@ public partial class Games
         }
 
         [Cmd]
+        [RequireContext(ContextType.Guild)]
         public async Task FishList(int page = 1)
         {
             if (--page < 0)
@@ -153,8 +158,8 @@ public partial class Games
 
             var items = await fis.GetEquippedItemsAsync(ctx.User.Id);
             var desc = $"""
-                       🧠 {skill} / {maxSkill}
-                       """;
+                        🧠 {skill} / {maxSkill}
+                        """;
 
             foreach (var itemType in Enum.GetValues<FishItemType>())
             {
@@ -204,6 +209,43 @@ public partial class Games
                 .SendAsync();
         }
 
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task FishLb(int page = 1)
+        {
+            if (--page < 0)
+                return;
+
+            await Response()
+                .Paginated()
+                .PageItems(async p => await fs.GetFishLbAsync(p))
+                .PageSize(9)
+                .Page(async (items, page) =>
+                {
+                    var users = await us.GetUsersAsync(items.Select(x => x.UserId).ToArray());
+
+                    var eb = CreateEmbed()
+                        .WithTitle(GetText(strs.fish_lb_title))
+                        .WithOkColor();
+
+                    for (var i = 0; i < items.Count; i++)
+                    {
+                        var data = items[i];
+                        var user = users.TryGetValue(data.UserId, out var ud)
+                            ? ud.ToString()
+                            : data.UserId.ToString();
+
+                        eb.AddField("#" + (page * 9 + i + 1) + " | " + user,
+                            GetText(strs.fish_catches(Format.Bold(data.Catches.ToString()))),
+                            false);
+                    }
+
+                    return eb;
+                })
+                .SendAsync();
+        }
+
+
         private string GetFishEmoji(FishData? fish, int count)
         {
             if (fish is null)
diff --git a/src/EllieBot/Modules/Games/Quests/QuestModels/CheckLeaderboardsQuest.cs b/src/EllieBot/Modules/Games/Quests/QuestModels/CheckLeaderboardsQuest.cs
index 4b9f9b0..ff45d85 100644
--- a/src/EllieBot/Modules/Games/Quests/QuestModels/CheckLeaderboardsQuest.cs
+++ b/src/EllieBot/Modules/Games/Quests/QuestModels/CheckLeaderboardsQuest.cs
@@ -9,7 +9,7 @@ public sealed class CheckLeaderboardsQuest : IQuest
         => "Leaderboard Enthusiast";
 
     public string Desc
-        => "Check lb, xplb and waifulb";
+        => "Check lb, xplb, fishlb and waifulb";
 
     public string ProgDesc
         => "";
@@ -18,7 +18,7 @@ public sealed class CheckLeaderboardsQuest : IQuest
         => QuestEventType.CommandUsed;
 
     public long RequiredAmount
-        => 0b111;
+        => 0b1111;
 
     public long TryUpdateProgress(IDictionary<string, string> metadata, long oldProgress)
     {
@@ -28,11 +28,13 @@ public sealed class CheckLeaderboardsQuest : IQuest
         var progress = oldProgress;
 
         if (name == "leaderboard")
-            progress |= 0b001;
+            progress |= 0b0001;
         else if (name == "xpleaderboard")
-            progress |= 0b010;
+            progress |= 0b0010;
         else if (name == "waifulb")
-            progress |= 0b100;
+            progress |= 0b0100;
+        else if (name == "fishlb")
+            progress |= 0b1000;
 
         return progress;
     }
@@ -42,23 +44,29 @@ public sealed class CheckLeaderboardsQuest : IQuest
         var msg = "";
 
         var emoji = IQuest.INCOMPLETE;
-        if ((progress & 0b001) == 0b001)
+        if ((progress & 0b0001) == 0b0001)
             emoji = IQuest.COMPLETED;
 
         msg += emoji + " flower lb seen\n";
 
         emoji = IQuest.INCOMPLETE;
-        if ((progress & 0b010) == 0b010)
+        if ((progress & 0b0010) == 0b0010)
             emoji = IQuest.COMPLETED;
             
         msg += emoji + " xp lb seen\n";
         
         emoji = IQuest.INCOMPLETE;
-        if ((progress & 0b100) == 0b100)
+        if ((progress & 0b0100) == 0b0100)
             emoji = IQuest.COMPLETED;
             
         msg += emoji + " waifu lb seen";
         
+        emoji = IQuest.INCOMPLETE;
+        if ((progress & 0b1000) == 0b1000)
+            emoji = IQuest.COMPLETED;
+            
+        msg += "\n" + emoji + " fish lb seen";
+        
         return msg;
     }
 }
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/UserService.cs b/src/EllieBot/_common/Services/UserService.cs
index 6ce71e7..35f7046 100644
--- a/src/EllieBot/_common/Services/UserService.cs
+++ b/src/EllieBot/_common/Services/UserService.cs
@@ -3,22 +3,67 @@ using EllieBot.Db.Models;
 
 namespace EllieBot.Modules.Xp.Services;
 
-public sealed class UserService : IUserService, IEService
+public sealed class UserService(DbService db, DiscordSocketClient client) : IUserService, IEService
 {
-    private readonly DbService _db;
-
-    public UserService(DbService db)
-    {
-        _db = db;
-    }
-
+    
     public async Task<DiscordUser?> GetUserAsync(ulong userId)
     {
-        await using var uow = _db.GetDbContext();
+        await using var uow = db.GetDbContext();
         var user = await uow
                          .GetTable<DiscordUser>()
                          .FirstOrDefaultAsyncLinqToDB(u => u.UserId == userId);
 
         return user;
     }
+    
+    public async Task<IReadOnlyDictionary<ulong, IUserData>> GetUsersAsync(IReadOnlyCollection<ulong> userIds)
+    {
+        var result = new Dictionary<ulong, IUserData>();
+        
+        var cachedUsers = userIds
+            .Select(userId => (userId, user: client.GetUser(userId)))
+            .Where(x => x.user is not null)
+            .ToDictionary(x => x.userId, x => (IUserData)new UserData(
+                x.user.Id,
+                x.user.Username,
+                x.user.GetAvatarUrl() ?? x.user.GetDefaultAvatarUrl()));
+        
+        foreach (var (userId, userData) in cachedUsers)
+            result[userId] = userData;
+        
+        var remainingIds = userIds.Except(cachedUsers.Keys).ToList();
+        if (remainingIds.Count == 0)
+            return result;
+        
+        // Try to get remaining users from database
+        await using var uow = db.GetDbContext();
+        var dbUsers = await uow
+            .GetTable<DiscordUser>()
+            .Where(u => remainingIds.Contains(u.UserId))
+            .ToListAsyncLinqToDB();
+            
+        foreach (var dbUser in dbUsers)
+        {
+            result[dbUser.UserId] = new UserData(
+                dbUser.UserId,
+                dbUser.Username,
+                dbUser.AvatarId);
+            remainingIds.Remove(dbUser.UserId);
+        }
+
+        return result;
+    }
+}
+
+public interface IUserData
+{
+    ulong Id { get; }
+    string? Username { get; }
+    string? AvatarUrl { get; }
+}
+
+public record struct UserData(ulong Id, string? Username, string? AvatarUrl) : IUserData
+{
+    public override string ToString()
+        => Username ?? Id.ToString();
 }
\ No newline at end of file
diff --git a/src/EllieBot/data/commandlist.json b/src/EllieBot/data/commandlist.json
index 4a7a257..fdc1d00 100644
--- a/src/EllieBot/data/commandlist.json
+++ b/src/EllieBot/data/commandlist.json
@@ -4150,6 +4150,20 @@
       "Options": null,
       "Requirements": []
     },
+    {
+      "Aliases": [
+        ".fishlb",
+        ".filb"
+      ],
+      "Description": "Shows the top anglers.",
+      "Usage": [
+        ".fishlb"
+      ],
+      "Submodule": "FishingCommands",
+      "Module": "Games",
+      "Options": null,
+      "Requirements": []
+    },
     {
       "Aliases": [
         ".fishshop",
@@ -5920,7 +5934,9 @@
       "Aliases": [
         ".questlog",
         ".qlog",
-        ".myquests"
+        ".quest",
+        ".quests",
+        ".dailies"
       ],
       "Description": "Shows your active quests and progress.",
       "Usage": [
diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml
index 9f8d448..0f5d61d 100644
--- a/src/EllieBot/strings/aliases.yml
+++ b/src/EllieBot/strings/aliases.yml
@@ -1660,7 +1660,6 @@ massping:
 questlog:
   - questlog
   - qlog
-  - myquests
   - quest
   - quests
   - dailies
@@ -1685,4 +1684,7 @@ fishunequip:
 fishinv:
   - fishinv
   - finv
-  - fiinv
\ No newline at end of file
+  - fiinv
+fishlb:
+  - fishlb
+  - filb
\ No newline at end of file
diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml
index 0425cdd..c51fd2e 100644
--- a/src/EllieBot/strings/commands/commands.en-US.yml
+++ b/src/EllieBot/strings/commands/commands.en-US.yml
@@ -5258,4 +5258,12 @@ fishunequip:
     - '1'
   params:
     - index:
-        desc: "The index of the item to unequip."
\ No newline at end of file
+        desc: "The index of the item to unequip."
+fishlb:
+  desc: |-
+    Shows the top anglers.
+  ex:
+    - ''
+  params:
+    - page:
+        desc: "The optional page to display."
\ No newline at end of file
diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json
index 9eee84a..9a826e3 100644
--- a/src/EllieBot/strings/responses/responses.en-US.json
+++ b/src/EllieBot/strings/responses/responses.en-US.json
@@ -1258,5 +1258,7 @@
   "fish_unequip_success": "Item unequipped successfully!",
   "fish_unequip_error": "Could not unequip item.",
   "fish_inv_title": "Fishing Inventory",
-  "fish_cant_uneq_potion": "You can't unequip a potion."
+  "fish_cant_uneq_potion": "You can't unequip a potion.",
+  "fish_lb_title": "Fishing Leaderboard",
+  "fish_catches": "{0} catches"
 }
\ No newline at end of file