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