From 3e0ddb8eb52d68acaa9ed02ff87b9589334dd6b8 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:56:02 +1200 Subject: [PATCH] Added Xp module --- src/EllieBot/Modules/Xp/Club/Club.cs | 483 +++++ src/EllieBot/Modules/Xp/Club/ClubService.cs | 367 ++++ src/EllieBot/Modules/Xp/Club/IClubService.cs | 34 + .../Xp/Club/Results/ClubAcceptResult.cs | 15 + .../Modules/Xp/Club/Results/ClubBanResult.cs | 10 + .../Xp/Club/Results/ClubCreateResult.cs | 10 + .../Modules/Xp/Club/Results/ClubKickResult.cs | 9 + .../Xp/Club/Results/ClubLeaveResult.cs | 8 + .../Xp/Club/Results/ClubRenameResult.cs | 9 + .../Xp/Club/Results/ClubTransferError.cs | 7 + .../Xp/Club/Results/ClubUnbanResult.cs | 8 + .../Xp/Club/Results/SetClubIconResult.cs | 9 + .../Xp/Club/Results/ToggleAdminResult.cs | 10 + src/EllieBot/Modules/Xp/Xp.cs | 591 ++++++ src/EllieBot/Modules/Xp/XpConfig.cs | 109 ++ src/EllieBot/Modules/Xp/XpConfigService.cs | 63 + src/EllieBot/Modules/Xp/XpRewards.cs | 141 ++ src/EllieBot/Modules/Xp/XpService.cs | 1617 +++++++++++++++++ .../Modules/Xp/_common/FullUserStats.cs | 31 + .../Modules/Xp/_common/IXpCleanupService.cs | 6 + .../Modules/Xp/_common/UserCacheItem.cs | 13 + .../Modules/Xp/_common/XpCleanupService.cs | 31 + src/EllieBot/Modules/Xp/_common/XpTemplate.cs | 271 +++ .../Modules/Xp/_common/db/XpShopOwnedItem.cs | 16 + 24 files changed, 3868 insertions(+) create mode 100644 src/EllieBot/Modules/Xp/Club/Club.cs create mode 100644 src/EllieBot/Modules/Xp/Club/ClubService.cs create mode 100644 src/EllieBot/Modules/Xp/Club/IClubService.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubAcceptResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubBanResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubCreateResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubKickResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubLeaveResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubRenameResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubTransferError.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubUnbanResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/SetClubIconResult.cs create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ToggleAdminResult.cs create mode 100644 src/EllieBot/Modules/Xp/Xp.cs create mode 100644 src/EllieBot/Modules/Xp/XpConfig.cs create mode 100644 src/EllieBot/Modules/Xp/XpConfigService.cs create mode 100644 src/EllieBot/Modules/Xp/XpRewards.cs create mode 100644 src/EllieBot/Modules/Xp/XpService.cs create mode 100644 src/EllieBot/Modules/Xp/_common/FullUserStats.cs create mode 100644 src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs create mode 100644 src/EllieBot/Modules/Xp/_common/UserCacheItem.cs create mode 100644 src/EllieBot/Modules/Xp/_common/XpCleanupService.cs create mode 100644 src/EllieBot/Modules/Xp/_common/XpTemplate.cs create mode 100644 src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs diff --git a/src/EllieBot/Modules/Xp/Club/Club.cs b/src/EllieBot/Modules/Xp/Club/Club.cs new file mode 100644 index 0000000..d584eba --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Club.cs @@ -0,0 +1,483 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Xp.Services; + +namespace EllieBot.Modules.Xp; + +public partial class Xp +{ + [Group] + public partial class Club : EllieModule + { + private readonly XpService _xps; + + public Club(XpService xps) + => _xps = xps; + + [Cmd] + public async Task ClubTransfer([Leftover] IUser newOwner) + { + var result = _service.TransferClub(ctx.User, newOwner); + + if (!result.TryPickT0(out var club, out var error)) + { + if (error == ClubTransferError.NotOwner) + await Response().Error(strs.club_owner_only).SendAsync(); + else + await Response().Error(strs.club_target_not_member).SendAsync(); + } + else + { + await Response() + .Confirm( + strs.club_transfered( + Format.Bold(club.Name), + Format.Bold(newOwner.ToString()) + ) + ) + .SendAsync(); + } + } + + [Cmd] + public async Task ClubAdmin([Leftover] IUser toAdmin) + { + var result = await _service.ToggleAdminAsync(ctx.User, toAdmin); + + if (result == ToggleAdminResult.AddedAdmin) + await Response().Confirm(strs.club_admin_add(Format.Bold(toAdmin.ToString()))).SendAsync(); + else if (result == ToggleAdminResult.RemovedAdmin) + await Response().Confirm(strs.club_admin_remove(Format.Bold(toAdmin.ToString()))).SendAsync(); + else if (result == ToggleAdminResult.NotOwner) + await Response().Error(strs.club_owner_only).SendAsync(); + else if (result == ToggleAdminResult.CantTargetThyself) + await Response().Error(strs.club_admin_invalid_target).SendAsync(); + else if (result == ToggleAdminResult.TargetNotMember) + await Response().Error(strs.club_target_not_member).SendAsync(); + } + + [Cmd] + public async Task ClubCreate([Leftover] string clubName) + { + var result = await _service.CreateClubAsync(ctx.User, clubName); + + if (result == ClubCreateResult.NameTooLong) + { + await Response().Error(strs.club_name_too_long).SendAsync(); + return; + } + + if (result == ClubCreateResult.NameTaken) + { + await Response().Error(strs.club_name_taken).SendAsync(); + return; + } + + if (result == ClubCreateResult.InsufficientLevel) + { + await Response().Error(strs.club_create_insuff_lvl).SendAsync(); + return; + } + + if (result == ClubCreateResult.AlreadyInAClub) + { + await Response().Error(strs.club_already_in).SendAsync(); + return; + } + + await Response().Confirm(strs.club_created(Format.Bold(clubName))).SendAsync(); + } + + [Cmd] + public async Task ClubIcon([Leftover] string url = null) + { + if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null)) + { + await Response().Error(strs.club_icon_url_format).SendAsync(); + return; + } + + var result = await _service.SetClubIconAsync(ctx.User.Id, url); + if (result == SetClubIconResult.Success) + await Response().Confirm(strs.club_icon_set).SendAsync(); + else if (result == SetClubIconResult.NotOwner) + await Response().Error(strs.club_owner_only).SendAsync(); + else if (result == SetClubIconResult.TooLarge) + await Response().Error(strs.club_icon_too_large).SendAsync(); + else if (result == SetClubIconResult.InvalidFileType) + await Response().Error(strs.club_icon_invalid_filetype).SendAsync(); + } + + private async Task InternalClubInfoAsync(ClubInfo club) + { + var lvl = new LevelStats(club.Xp); + var allUsers = club.Members.OrderByDescending(x => + { + var l = new LevelStats(x.TotalXp).Level; + if (club.OwnerId == x.Id) + return int.MaxValue; + if (x.IsClubAdmin) + return (int.MaxValue / 2) + l; + return l; + }) + .ToList(); + + await Response() + .Paginated() + .Items(allUsers) + .PageSize(10) + .Page((users, _) => + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{club}") + .WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)"))) + .AddField(GetText(strs.desc), + string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description) + .AddField(GetText(strs.owner), club.Owner.ToString(), true) + // .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true) + .AddField(GetText(strs.members), + string.Join("\n", + users + .Select(x => + { + var l = new LevelStats(x.TotalXp); + var lvlStr = Format.Bold($" ⟪{l.Level}⟫"); + if (club.OwnerId == x.Id) + return x + "🌟" + lvlStr; + if (x.IsClubAdmin) + return x + "⭐" + lvlStr; + return x + lvlStr; + }))); + + if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute)) + return embed.WithThumbnailUrl(club.ImageUrl); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [Priority(1)] + public async Task ClubInformation(IUser user = null) + { + user ??= ctx.User; + var club = _service.GetClubByMember(user); + if (club is null) + { + await Response().Error(strs.club_user_not_in_club(Format.Bold(user.ToString()))).SendAsync(); + return; + } + + await InternalClubInfoAsync(club); + } + + [Cmd] + [Priority(0)] + public async Task ClubInformation([Leftover] string clubName = null) + { + if (string.IsNullOrWhiteSpace(clubName)) + { + await ClubInformation(ctx.User); + return; + } + + if (!_service.GetClubByName(clubName, out var club)) + { + await Response().Error(strs.club_not_exists).SendAsync(); + return; + } + + await InternalClubInfoAsync(club); + } + + [Cmd] + public Task ClubBans(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var club = _service.GetClubWithBansAndApplications(ctx.User.Id); + if (club is null) + return Response().Error(strs.club_admin_perms).SendAsync(); + + var bans = club.Bans.Select(x => x.User).ToArray(); + + return Response() + .Paginated() + .Items(bans) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var toShow = string.Join("\n", items.Select(x => x.ToString())); + + return _sender.CreateEmbed() + .WithTitle(GetText(strs.club_bans_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }) + .SendAsync(); + } + + [Cmd] + public Task ClubApps(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var club = _service.GetClubWithBansAndApplications(ctx.User.Id); + if (club is null) + return Response().Error(strs.club_admin_perms).SendAsync(); + + var apps = club.Applicants.Select(x => x.User).ToArray(); + + return Response() + .Paginated() + .Items(apps) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var toShow = string.Join("\n", items.Select(x => x.ToString())); + + return _sender.CreateEmbed() + .WithTitle(GetText(strs.club_apps_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }) + .SendAsync(); + } + + [Cmd] + public async Task ClubApply([Leftover] string clubName) + { + if (string.IsNullOrWhiteSpace(clubName)) + return; + + if (!_service.GetClubByName(clubName, out var club)) + { + await Response().Error(strs.club_not_exists).SendAsync(); + return; + } + + var result = _service.ApplyToClub(ctx.User, club); + if (result == ClubApplyResult.Success) + await Response().Confirm(strs.club_applied(Format.Bold(club.ToString()))).SendAsync(); + else if (result == ClubApplyResult.Banned) + await Response().Error(strs.club_join_banned).SendAsync(); + else if (result == ClubApplyResult.AlreadyApplied) + await Response().Error(strs.club_already_applied).SendAsync(); + else if (result == ClubApplyResult.AlreadyInAClub) + await Response().Error(strs.club_already_in).SendAsync(); + } + + [Cmd] + [Priority(1)] + public Task ClubAccept(IUser user) + => ClubAccept(user.ToString()); + + [Cmd] + [Priority(0)] + public async Task ClubAccept([Leftover] string userName) + { + var result = _service.AcceptApplication(ctx.User.Id, userName, out var discordUser); + if (result == ClubAcceptResult.Accepted) + await Response().Confirm(strs.club_accepted(Format.Bold(discordUser.ToString()))).SendAsync(); + else if (result == ClubAcceptResult.NoSuchApplicant) + await Response().Error(strs.club_accept_invalid_applicant).SendAsync(); + else if (result == ClubAcceptResult.NotOwnerOrAdmin) + await Response().Error(strs.club_admin_perms).SendAsync(); + } + + [Cmd] + [Priority(1)] + public Task ClubReject(IUser user) + => ClubReject(user.ToString()); + + [Cmd] + [Priority(0)] + public async Task ClubReject([Leftover] string userName) + { + var result = _service.RejectApplication(ctx.User.Id, userName, out var discordUser); + if (result == ClubDenyResult.Rejected) + await Response().Confirm(strs.club_rejected(Format.Bold(discordUser.ToString()))).SendAsync(); + else if (result == ClubDenyResult.NoSuchApplicant) + await Response().Error(strs.club_accept_invalid_applicant).SendAsync(); + else if (result == ClubDenyResult.NotOwnerOrAdmin) + await Response().Error(strs.club_admin_perms).SendAsync(); + } + + [Cmd] + public async Task ClubLeave() + { + var res = _service.LeaveClub(ctx.User); + + if (res == ClubLeaveResult.Success) + await Response().Confirm(strs.club_left).SendAsync(); + else if (res == ClubLeaveResult.NotInAClub) + await Response().Error(strs.club_not_in_a_club).SendAsync(); + else + await Response().Error(strs.club_owner_cant_leave).SendAsync(); + } + + [Cmd] + [Priority(1)] + public Task ClubKick([Leftover] IUser user) + => ClubKick(user.ToString()); + + [Cmd] + [Priority(0)] + public Task ClubKick([Leftover] string userName) + { + var result = _service.Kick(ctx.User.Id, userName, out var club); + if (result == ClubKickResult.Success) + { + return Response() + .Confirm(strs.club_user_kick(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); + } + + if (result == ClubKickResult.Hierarchy) + return Response().Error(strs.club_kick_hierarchy).SendAsync(); + + if (result == ClubKickResult.NotOwnerOrAdmin) + return Response().Error(strs.club_admin_perms).SendAsync(); + + return Response().Error(strs.club_target_not_member).SendAsync(); + } + + [Cmd] + [Priority(1)] + public Task ClubBan([Leftover] IUser user) + => ClubBan(user.ToString()); + + [Cmd] + [Priority(0)] + public Task ClubBan([Leftover] string userName) + { + var result = _service.Ban(ctx.User.Id, userName, out var club); + if (result == ClubBanResult.Success) + { + return Response() + .Confirm(strs.club_user_banned(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); + } + + if (result == ClubBanResult.Unbannable) + return Response().Error(strs.club_ban_fail_unbannable).SendAsync(); + + if (result == ClubBanResult.WrongUser) + return Response().Error(strs.club_ban_fail_user_not_found).SendAsync(); + + return Response().Error(strs.club_admin_perms).SendAsync(); + } + + [Cmd] + [Priority(1)] + public Task ClubUnBan([Leftover] IUser user) + => ClubUnBan(user.ToString()); + + [Cmd] + [Priority(0)] + public Task ClubUnBan([Leftover] string userName) + { + var result = _service.UnBan(ctx.User.Id, userName, out var club); + + if (result == ClubUnbanResult.Success) + { + return Response() + .Confirm(strs.club_user_unbanned(Format.Bold(userName), + Format.Bold(club.ToString()))) + .SendAsync(); + } + + if (result == ClubUnbanResult.WrongUser) + { + return Response().Error(strs.club_unban_fail_user_not_found).SendAsync(); + } + + return Response().Error(strs.club_admin_perms).SendAsync(); + } + + [Cmd] + public async Task ClubDescription([Leftover] string desc = null) + { + if (_service.SetDescription(ctx.User.Id, desc)) + { + desc = string.IsNullOrWhiteSpace(desc) + ? "-" + : desc; + + var eb = _sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithTitle(GetText(strs.club_desc_update)) + .WithOkColor() + .WithDescription(desc); + + await Response().Embed(eb).SendAsync(); + } + else + { + await Response().Error(strs.club_desc_update_failed).SendAsync(); + } + } + + [Cmd] + public async Task ClubDisband() + { + if (_service.Disband(ctx.User.Id, out var club)) + await Response().Confirm(strs.club_disbanded(Format.Bold(club.Name))).SendAsync(); + else + await Response().Error(strs.club_disband_error).SendAsync(); + } + + [Cmd] + public Task ClubLeaderboard(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var clubs = _service.GetClubLeaderboardPage(page); + + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.club_leaderboard(page + 1))).WithOkColor(); + + var i = page * 9; + foreach (var club in clubs) + embed.AddField($"#{++i} " + club, club.Xp + " xp"); + + return Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task ClubRename([Leftover] string clubName) + { + var res = await _service.RenameClubAsync(ctx.User.Id, clubName); + + switch (res) + { + case ClubRenameResult.NameTooLong: + await Response().Error(strs.club_name_too_long).SendAsync(); + return; + case ClubRenameResult.Success: + { + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.club_renamed(clubName))).WithOkColor(); + await Response().Embed(embed).SendAsync(); + return; + } + case ClubRenameResult.NameTaken: + await Response().Error(strs.club_name_taken).SendAsync(); + return; + case ClubRenameResult.NotOwnerOrAdmin: + await Response().Error(strs.club_admin_perms).SendAsync(); + return; + default: + return; + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/ClubService.cs b/src/EllieBot/Modules/Xp/Club/ClubService.cs new file mode 100644 index 0000000..be89659 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/ClubService.cs @@ -0,0 +1,367 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using OneOf; + +namespace EllieBot.Modules.Xp.Services; + +public class ClubService : IEService, IClubService +{ + private readonly DbService _db; + private readonly IHttpClientFactory _httpFactory; + + public ClubService(DbService db, IHttpClientFactory httpFactory) + { + _db = db; + _httpFactory = httpFactory; + } + + public async Task CreateClubAsync(IUser user, string clubName) + { + if (!CheckClubName(clubName)) + return ClubCreateResult.NameTooLong; + + //must be lvl 5 and must not be in a club already + + await using var uow = _db.GetDbContext(); + var du = uow.GetOrCreateUser(user); + var xp = new LevelStats(du.TotalXp); + + if (xp.Level < 5) + return ClubCreateResult.InsufficientLevel; + + if (du.ClubId is not null) + return ClubCreateResult.AlreadyInAClub; + + if (await uow.Set().AnyAsyncEF(x => x.Name == clubName)) + return ClubCreateResult.NameTaken; + + du.IsClubAdmin = true; + du.Club = new() + { + Name = clubName, + Owner = du + }; + uow.Set().Add(du.Club); + await uow.SaveChangesAsync(); + + await uow.GetTable() + .DeleteAsync(x => x.UserId == du.Id); + + return ClubCreateResult.Success; + } + + public OneOf TransferClub(IUser from, IUser newOwner) + { + using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwner(from.Id); + var newOwnerUser = uow.GetOrCreateUser(newOwner); + + if (club is null || club.Owner.UserId != from.Id) + return ClubTransferError.NotOwner; + + if (!club.Members.Contains(newOwnerUser)) + return ClubTransferError.TargetNotMember; + + club.Owner.IsClubAdmin = true; // old owner will stay as admin + newOwnerUser.IsClubAdmin = true; + club.Owner = newOwnerUser; + uow.SaveChanges(); + return club; + } + + public async Task ToggleAdminAsync(IUser owner, IUser toAdmin) + { + if (owner.Id == toAdmin.Id) + return ToggleAdminResult.CantTargetThyself; + + await using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwner(owner.Id); + var adminUser = uow.GetOrCreateUser(toAdmin); + + if (club is null) + return ToggleAdminResult.NotOwner; + + if(!club.Members.Contains(adminUser)) + return ToggleAdminResult.TargetNotMember; + + var newState = adminUser.IsClubAdmin = !adminUser.IsClubAdmin; + await uow.SaveChangesAsync(); + return newState ? ToggleAdminResult.AddedAdmin : ToggleAdminResult.RemovedAdmin; + } + + public ClubInfo GetClubByMember(IUser user) + { + using var uow = _db.GetDbContext(); + var member = uow.Set().GetByMember(user.Id); + return member; + } + + public async Task SetClubIconAsync(ulong ownerUserId, string url) + { + if (url is not null) + { + using var http = _httpFactory.CreateClient(); + using var temp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + + if (!temp.IsImage()) + return SetClubIconResult.InvalidFileType; + + if (temp.GetContentLength() > 5.Megabytes()) + return SetClubIconResult.TooLarge; + } + + await using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwner(ownerUserId); + + if (club is null) + return SetClubIconResult.NotOwner; + + club.ImageUrl = url; + await uow.SaveChangesAsync(); + + return SetClubIconResult.Success; + } + + public bool GetClubByName(string clubName, out ClubInfo club) + { + using var uow = _db.GetDbContext(); + club = uow.Set().GetByName(clubName); + + return club is not null; + } + + public ClubApplyResult ApplyToClub(IUser user, ClubInfo club) + { + using var uow = _db.GetDbContext(); + var du = uow.GetOrCreateUser(user); + uow.SaveChanges(); + + //user banned or a member of a club, or already applied, + // or doesn't min minumum level requirement, can't apply + if (du.ClubId is not null) + return ClubApplyResult.AlreadyInAClub; + + if (club.Bans.Any(x => x.UserId == du.Id)) + return ClubApplyResult.Banned; + + if (club.Applicants.Any(x => x.UserId == du.Id)) + return ClubApplyResult.AlreadyApplied; + + var app = new ClubApplicants + { + ClubId = club.Id, + UserId = du.Id + }; + + uow.Set().Add(app); + uow.SaveChanges(); + return ClubApplyResult.Success; + } + + + public ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser) + { + discordUser = null; + using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwnerOrAdmin(clubOwnerUserId); + if (club is null) + return ClubAcceptResult.NotOwnerOrAdmin; + + var applicant = + club.Applicants.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant()); + if (applicant is null) + return ClubAcceptResult.NoSuchApplicant; + + applicant.User.Club = club; + applicant.User.IsClubAdmin = false; + club.Applicants.Remove(applicant); + + //remove that user's all other applications + uow.Set() + .RemoveRange(uow.Set().AsQueryable().Where(x => x.UserId == applicant.User.Id)); + + discordUser = applicant.User; + uow.SaveChanges(); + return ClubAcceptResult.Accepted; + } + + public ClubDenyResult RejectApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser) + { + discordUser = null; + using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwnerOrAdmin(clubOwnerUserId); + if (club is null) + return ClubDenyResult.NotOwnerOrAdmin; + + var applicant = + club.Applicants.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant()); + if (applicant is null) + return ClubDenyResult.NoSuchApplicant; + + club.Applicants.Remove(applicant); + + discordUser = applicant.User; + uow.SaveChanges(); + return ClubDenyResult.Rejected; + } + + public ClubInfo GetClubWithBansAndApplications(ulong ownerUserId) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetByOwnerOrAdmin(ownerUserId); + } + + public ClubLeaveResult LeaveClub(IUser user) + { + using var uow = _db.GetDbContext(); + var du = uow.GetOrCreateUser(user, x => x.Include(u => u.Club)); + if (du.Club is null) + return ClubLeaveResult.NotInAClub; + if (du.Club.OwnerId == du.Id) + return ClubLeaveResult.OwnerCantLeave; + + du.Club = null; + du.IsClubAdmin = false; + uow.SaveChanges(); + return ClubLeaveResult.Success; + } + + public bool SetDescription(ulong userId, string desc) + { + using var uow = _db.GetDbContext(); + var club = uow.Set().GetByOwner(userId); + if (club is null) + return false; + + club.Description = desc?.TrimTo(150, true); + uow.SaveChanges(); + + return true; + } + + public bool Disband(ulong userId, out ClubInfo club) + { + using var uow = _db.GetDbContext(); + club = uow.Set().GetByOwner(userId); + if (club is null) + return false; + + uow.Set().Remove(club); + uow.SaveChanges(); + return true; + } + + public ClubBanResult Ban(ulong bannerId, string userName, out ClubInfo club) + { + using var uow = _db.GetDbContext(); + club = uow.Set().GetByOwnerOrAdmin(bannerId); + if (club is null) + return ClubBanResult.NotOwnerOrAdmin; + + var usr = club.Members.FirstOrDefault(x => x.ToString().ToUpperInvariant() == userName.ToUpperInvariant()) + ?? club.Applicants + .FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant()) + ?.User; + if (usr is null) + return ClubBanResult.WrongUser; + + if (club.OwnerId == usr.Id + || (usr.IsClubAdmin && club.Owner.UserId != bannerId)) // can't ban the owner kek, whew + return ClubBanResult.Unbannable; + + club.Bans.Add(new() + { + Club = club, + User = usr + }); + club.Members.Remove(usr); + + var app = club.Applicants.FirstOrDefault(x => x.UserId == usr.Id); + if (app is not null) + club.Applicants.Remove(app); + + uow.SaveChanges(); + + return ClubBanResult.Success; + } + + public ClubUnbanResult UnBan(ulong ownerUserId, string userName, out ClubInfo club) + { + using var uow = _db.GetDbContext(); + club = uow.Set().GetByOwnerOrAdmin(ownerUserId); + if (club is null) + return ClubUnbanResult.NotOwnerOrAdmin; + + var ban = club.Bans.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant()); + if (ban is null) + return ClubUnbanResult.WrongUser; + + club.Bans.Remove(ban); + uow.SaveChanges(); + + return ClubUnbanResult.Success; + } + + + public ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club) + { + using var uow = _db.GetDbContext(); + club = uow.Set().GetByOwnerOrAdmin(kickerId); + if (club is null) + return ClubKickResult.NotOwnerOrAdmin; + + var usr = club.Members.FirstOrDefault(x => x.ToString().ToUpperInvariant() == userName.ToUpperInvariant()); + if (usr is null) + return ClubKickResult.TargetNotAMember; + + if (club.OwnerId == usr.Id || (usr.IsClubAdmin && club.Owner.UserId != kickerId)) + return ClubKickResult.Hierarchy; + + club.Members.Remove(usr); + var app = club.Applicants.FirstOrDefault(x => x.UserId == usr.Id); + if (app is not null) + club.Applicants.Remove(app); + uow.SaveChanges(); + + return ClubKickResult.Success; + } + + public List GetClubLeaderboardPage(int page) + { + ArgumentOutOfRangeException.ThrowIfNegative(page); + + using var uow = _db.GetDbContext(); + return uow.Set().GetClubLeaderboardPage(page); + } + + public async Task RenameClubAsync(ulong userId, string clubName) + { + if (!CheckClubName(clubName)) + return ClubRenameResult.NameTooLong; + + await using var uow = _db.GetDbContext(); + + var club = uow.Set().GetByOwnerOrAdmin(userId); + + if (club is null) + return ClubRenameResult.NotOwnerOrAdmin; + + if (await uow.Set().AnyAsyncEF(x => x.Name == clubName)) + return ClubRenameResult.NameTaken; + + club.Name = clubName; + + await uow.SaveChangesAsync(); + + return ClubRenameResult.Success; + } + + private static bool CheckClubName(string clubName) + { + return !(string.IsNullOrWhiteSpace(clubName) || clubName.Length > 20); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/IClubService.cs b/src/EllieBot/Modules/Xp/Club/IClubService.cs new file mode 100644 index 0000000..7e9bbf0 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/IClubService.cs @@ -0,0 +1,34 @@ +using EllieBot.Db.Models; +using OneOf; + +namespace EllieBot.Modules.Xp.Services; + +public interface IClubService +{ + Task CreateClubAsync(IUser user, string clubName); + OneOf TransferClub(IUser from, IUser newOwner); + Task ToggleAdminAsync(IUser owner, IUser toAdmin); + ClubInfo? GetClubByMember(IUser user); + Task SetClubIconAsync(ulong ownerUserId, string? url); + bool GetClubByName(string clubName, out ClubInfo club); + ClubApplyResult ApplyToClub(IUser user, ClubInfo club); + ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser); + ClubDenyResult RejectApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser); + ClubInfo? GetClubWithBansAndApplications(ulong ownerUserId); + ClubLeaveResult LeaveClub(IUser user); + bool SetDescription(ulong userId, string? desc); + bool Disband(ulong userId, out ClubInfo club); + ClubBanResult Ban(ulong bannerId, string userName, out ClubInfo club); + ClubUnbanResult UnBan(ulong ownerUserId, string userName, out ClubInfo club); + ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club); + List GetClubLeaderboardPage(int page); + Task RenameClubAsync(ulong userId, string clubName); +} + +public enum ClubApplyResult +{ + Success, + AlreadyInAClub, + Banned, + AlreadyApplied +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubAcceptResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubAcceptResult.cs new file mode 100644 index 0000000..1dadec6 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubAcceptResult.cs @@ -0,0 +1,15 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubAcceptResult +{ + Accepted, + NotOwnerOrAdmin, + NoSuchApplicant, +} + +public enum ClubDenyResult +{ + Rejected, + NoSuchApplicant, + NotOwnerOrAdmin +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubBanResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubBanResult.cs new file mode 100644 index 0000000..a2a1553 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubBanResult.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubBanResult +{ + Success, + NotOwnerOrAdmin, + WrongUser, + Unbannable, + +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubCreateResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubCreateResult.cs new file mode 100644 index 0000000..50f7b42 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubCreateResult.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubCreateResult +{ + Success, + AlreadyInAClub, + NameTaken, + InsufficientLevel, + NameTooLong +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubKickResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubKickResult.cs new file mode 100644 index 0000000..533d570 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubKickResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubKickResult +{ + Success, + NotOwnerOrAdmin, + TargetNotAMember, + Hierarchy +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubLeaveResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubLeaveResult.cs new file mode 100644 index 0000000..b63398e --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubLeaveResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubLeaveResult +{ + Success, + OwnerCantLeave, + NotInAClub +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubRenameResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubRenameResult.cs new file mode 100644 index 0000000..8bb8a83 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubRenameResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubRenameResult +{ + NotOwnerOrAdmin, + Success, + NameTaken, + NameTooLong +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubTransferError.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubTransferError.cs new file mode 100644 index 0000000..043b784 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubTransferError.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubTransferError +{ + NotOwner, + TargetNotMember +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ClubUnbanResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ClubUnbanResult.cs new file mode 100644 index 0000000..68b247c --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ClubUnbanResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ClubUnbanResult +{ + Success, + NotOwnerOrAdmin, + WrongUser +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/SetClubIconResult.cs b/src/EllieBot/Modules/Xp/Club/Results/SetClubIconResult.cs new file mode 100644 index 0000000..f2ae6eb --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/SetClubIconResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum SetClubIconResult +{ + Success, + InvalidFileType, + TooLarge, + NotOwner, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Club/Results/ToggleAdminResult.cs b/src/EllieBot/Modules/Xp/Club/Results/ToggleAdminResult.cs new file mode 100644 index 0000000..897d04a --- /dev/null +++ b/src/EllieBot/Modules/Xp/Club/Results/ToggleAdminResult.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.Xp.Services; + +public enum ToggleAdminResult +{ + AddedAdmin, + RemovedAdmin, + NotOwner, + TargetNotMember, + CantTargetThyself, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/Xp.cs b/src/EllieBot/Modules/Xp/Xp.cs new file mode 100644 index 0000000..2bf8fcb --- /dev/null +++ b/src/EllieBot/Modules/Xp/Xp.cs @@ -0,0 +1,591 @@ +#nullable disable warnings +using EllieBot.Modules.Xp.Services; +using EllieBot.Db.Models; +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Xp; + +public partial class Xp : EllieModule +{ + public enum Channel + { + Channel + } + + public enum NotifyPlace + { + Server = 0, + Guild = 0, + Global = 1 + } + + public enum Role + { + Role + } + + public enum Server + { + Server + } + + private readonly DownloadTracker _tracker; + private readonly ICurrencyProvider _gss; + + public Xp(DownloadTracker tracker, ICurrencyProvider gss) + { + _tracker = tracker; + _gss = gss; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Experience([Leftover] IUser user = null) + { + user ??= ctx.User; + await ctx.Channel.TriggerTypingAsync(); + var (img, fmt) = await _service.GenerateXpImageAsync((IGuildUser)user); + await using (img) + { + await ctx.Channel.SendFileAsync(img, $"{ctx.Guild.Id}_{user.Id}_xp.{fmt.FileExtensions.FirstOrDefault()}"); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task XpNotify() + { + var globalSetting = _service.GetNotificationType(ctx.User); + var serverSetting = _service.GetNotificationType(ctx.User.Id, ctx.Guild.Id); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.xpn_setting_global), GetNotifLocationString(globalSetting)) + .AddField(GetText(strs.xpn_setting_server), GetNotifLocationString(serverSetting)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task XpNotify(NotifyPlace place, XpNotificationLocation type) + { + if (place == NotifyPlace.Guild) + await _service.ChangeNotificationType(ctx.User.Id, ctx.Guild.Id, type); + else + await _service.ChangeNotificationType(ctx.User, type); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpExclude(Server _) + { + var ex = _service.ToggleExcludeServer(ctx.Guild.Id); + + if (ex) + await Response().Confirm(strs.excluded(Format.Bold(ctx.Guild.ToString()))).SendAsync(); + else + await Response().Confirm(strs.not_excluded(Format.Bold(ctx.Guild.ToString()))).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task XpExclude(Role _, [Leftover] IRole role) + { + var ex = _service.ToggleExcludeRole(ctx.Guild.Id, role.Id); + + if (ex) + await Response().Confirm(strs.excluded(Format.Bold(role.ToString()))).SendAsync(); + else + await Response().Confirm(strs.not_excluded(Format.Bold(role.ToString()))).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.ManageChannels)] + [RequireContext(ContextType.Guild)] + public async Task XpExclude(Channel _, [Leftover] IChannel channel = null) + { + if (channel is null) + channel = ctx.Channel; + + var ex = _service.ToggleExcludeChannel(ctx.Guild.Id, channel.Id); + + if (ex) + await Response().Confirm(strs.excluded(Format.Bold(channel.ToString()))).SendAsync(); + else + await Response().Confirm(strs.not_excluded(Format.Bold(channel.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task XpExclusionList() + { + var serverExcluded = _service.IsServerExcluded(ctx.Guild.Id); + var roles = _service.GetExcludedRoles(ctx.Guild.Id) + .Select(x => ctx.Guild.GetRole(x)) + .Where(x => x is not null) + .Select(x => $"`role` {x.Mention}") + .ToList(); + + var chans = (await _service.GetExcludedChannels(ctx.Guild.Id) + .Select(x => ctx.Guild.GetChannelAsync(x)) + .WhenAll()).Where(x => x is not null) + .Select(x => $"`channel` <#{x.Id}>") + .ToList(); + + var rolesStr = roles.Any() ? string.Join("\n", roles) + "\n" : string.Empty; + var chansStr = chans.Count > 0 ? string.Join("\n", chans) + "\n" : string.Empty; + var desc = Format.Code(serverExcluded + ? GetText(strs.server_is_excluded) + : GetText(strs.server_is_not_excluded)); + + desc += "\n\n" + rolesStr + chansStr; + + var lines = desc.Split('\n'); + await Response() + .Paginated() + .Items(lines) + .PageSize(15) + .CurrentPage(0) + .Page((items, _) => + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.exclusion_list)) + .WithDescription(string.Join('\n', items)) + .WithOkColor(); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [EllieOptions] + [Priority(0)] + [RequireContext(ContextType.Guild)] + public Task XpLeaderboard(params string[] args) + => XpLeaderboard(1, args); + + [Cmd] + [EllieOptions] + [Priority(1)] + [RequireContext(ContextType.Guild)] + public async Task XpLeaderboard(int page = 1, params string[] args) + { + if (--page < 0 || page > 100) + return; + + var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args); + + await ctx.Channel.TriggerTypingAsync(); + + var socketGuild = (SocketGuild)ctx.Guild; + var allCleanUsers = new List(); + if (opts.Clean) + { + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + allCleanUsers = (await _service.GetTopUserXps(ctx.Guild.Id, 1000)) + .Where(user => socketGuild.GetUser(user.UserId) is not null) + .ToList(); + } + + var res = opts.Clean + ? Response() + .Paginated() + .Items(allCleanUsers) + : Response() + .Paginated() + .PageItems((curPage) => _service.GetUserXps(ctx.Guild.Id, curPage)); + + await res + .PageSize(9) + .CurrentPage(page) + .Page((users, curPage) => + { + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.server_leaderboard)).WithOkColor(); + + if (!users.Any()) + return embed.WithDescription("-"); + + for (var i = 0; i < users.Count; i++) + { + var levelStats = new LevelStats(users[i].Xp + users[i].AwardedXp); + var user = ((SocketGuild)ctx.Guild).GetUser(users[i].UserId); + + var userXpData = users[i]; + + var awardStr = string.Empty; + if (userXpData.AwardedXp > 0) + awardStr = $"(+{userXpData.AwardedXp})"; + else if (userXpData.AwardedXp < 0) + awardStr = $"({userXpData.AwardedXp})"; + + embed.AddField($"#{i + 1 + (curPage * 9)} {user?.ToString() ?? users[i].UserId.ToString()}", + $"{GetText(strs.level_x(levelStats.Level))} - {levelStats.TotalXp}xp {awardStr}"); + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task XpGlobalLeaderboard(int page = 1) + { + if (--page < 0 || page > 99) + return; + + await Response() + .Paginated() + .PageItems(async curPage => await _service.GetUserXps(curPage)) + .PageSize(9) + .Page((users, curPage) => + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.global_leaderboard)); + + if (!users.Any()) + { + embed.WithDescription("-"); + return embed; + } + + for (var i = 0; i < users.Count; i++) + { + var user = users[i]; + embed.AddField($"#{i + 1 + (curPage * 9)} {user}", + $"{GetText(strs.level_x(new LevelStats(users[i].TotalXp).Level))} - {users[i].TotalXp}xp"); + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public async Task XpAdd(long amount, [Remainder] SocketRole role) + { + if (amount == 0) + return; + + if (role.IsManaged) + return; + + var count = await _service.AddXpToUsersAsync(ctx.Guild.Id, amount, role.Members.Select(x => x.Id).ToArray()); + await Response() + .Confirm( + strs.xpadd_users(Format.Bold(amount.ToString()), Format.Bold(count.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(3)] + public async Task XpAdd(int amount, ulong userId) + { + if (amount == 0) + return; + + _service.AddXp(userId, ctx.Guild.Id, amount); + var usr = ((SocketGuild)ctx.Guild).GetUser(userId)?.ToString() ?? userId.ToString(); + await Response().Confirm(strs.modified(Format.Bold(usr), Format.Bold(amount.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(4)] + public Task XpAdd(int amount, [Leftover] IGuildUser user) + => XpAdd(amount, user.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task XpTemplateReload() + { + _service.ReloadXpTemplate(); + await Task.Delay(1000); + await Response().Confirm(strs.template_reloaded).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task XpReset(IGuildUser user) + => XpReset(user.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpReset(ulong userId) + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.reset)) + .WithDescription(GetText(strs.reset_user_confirm)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + _service.XpReset(ctx.Guild.Id, userId); + + await Response().Confirm(strs.reset_user(userId)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpReset() + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.reset)) + .WithDescription(GetText(strs.reset_server_confirm)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + _service.XpReset(ctx.Guild.Id); + + await Response().Confirm(strs.reset_server).SendAsync(); + } + + public enum XpShopInputType + { + Backgrounds = 0, + B = 0, + Bg = 0, + Bgs = 0, + Frames = 1, + F = 1, + Fr = 1, + Frs = 1, + Fs = 1, + } + + [Cmd] + public async Task XpShop() + { + if (!_service.IsShopEnabled()) + { + await Response().Error(strs.xp_shop_disabled).SendAsync(); + return; + } + + await Response() + .Confirm(GetText(strs.available_commands), + $""" + `{prefix}xpshop bgs` + `{prefix}xpshop frames` + + *{GetText(strs.xpshop_website)}* + """) + .SendAsync(); + } + + [Cmd] + public async Task XpShop(XpShopInputType type, int page = 1) + { + --page; + + if (page < 0) + return; + + var allItems = type == XpShopInputType.Backgrounds + ? await _service.GetShopBgs() + : await _service.GetShopFrames(); + + if (allItems is null) + { + await Response().Error(strs.xp_shop_disabled).SendAsync(); + return; + } + + if (allItems.Count == 0) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(allItems) + .PageSize(1) + .CurrentPage(page) + .AddFooter(false) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed() + .WithDescription(GetText(strs.not_found)) + .WithErrorColor(); + + var (key, item) = items.FirstOrDefault(); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(item.Name) + .AddField(GetText(strs.price), + CurrencyHelper.N(item.Price, Culture, _gss.GetCurrencySign()), + true) + .WithImageUrl(string.IsNullOrWhiteSpace(item.Preview) + ? item.Url + : item.Preview); + + if (!string.IsNullOrWhiteSpace(item.Desc)) + eb.AddField(GetText(strs.desc), item.Desc); + + if (key == "default") + eb.WithDescription(GetText(strs.xpshop_website)); + + + var tier = _service.GetXpShopTierRequirement(type); + if (tier != PatronTier.None) + { + eb.WithFooter(GetText(strs.xp_shop_buy_required_tier(tier.ToString()))); + } + + return eb; + }) + .Interaction(async current => + { + var (key, _) = allItems.Skip(current).First(); + + var itemType = type == XpShopInputType.Backgrounds + ? XpShopItemType.Background + : XpShopItemType.Frame; + + var ownedItem = await _service.GetUserItemAsync(ctx.User.Id, itemType, key); + if (ownedItem is not null) + { + var button = new ButtonBuilder(ownedItem.IsUsing + ? GetText(strs.in_use) + : GetText(strs.use), + "xpshop:use", + emote: Emoji.Parse("👐"), + isDisabled: ownedItem.IsUsing); + + var inter = _inter.Create( + ctx.User.Id, + button, + OnShopUse, + (key, itemType)); + + return inter; + } + else + { + var button = new ButtonBuilder(GetText(strs.buy), + "xpshop:buy", + emote: Emoji.Parse("💰")); + + var inter = _inter.Create( + ctx.User.Id, + button, + OnShopBuy, + (key, itemType)); + + return inter; + } + }) + .SendAsync(); + } + + [Cmd] + public async Task XpShopBuy(XpShopInputType type, string key) + { + var result = await _service.BuyShopItemAsync(ctx.User.Id, (XpShopItemType)type, key); + + EllieInteractionBase GetUseInteraction() + { + return _inter.Create(ctx.User.Id, + new(label: "Use", customId: "xpshop:use_item", emote: Emoji.Parse("👐")), + async (_, state) => await XpShopUse(state.type, state.key), + (type, key) + ); + } + + if (result != BuyResult.Success) + { + var _ = result switch + { + BuyResult.XpShopDisabled => await Response().Error(strs.xp_shop_disabled).SendAsync(), + BuyResult.InsufficientFunds => await Response() + .Error(strs.not_enough(_gss.GetCurrencySign())) + .SendAsync(), + BuyResult.AlreadyOwned => + await Response().Error(strs.xpshop_already_owned).Interaction(GetUseInteraction()).SendAsync(), + BuyResult.UnknownItem => await Response().Error(strs.xpshop_item_not_found).SendAsync(), + BuyResult.InsufficientPatronTier => await Response().Error(strs.patron_insuff_tier).SendAsync(), + _ => throw new ArgumentOutOfRangeException() + }; + return; + } + + await Response() + .Confirm(strs.xpshop_buy_success(type.ToString().ToLowerInvariant(), + key.ToLowerInvariant())) + .Interaction(GetUseInteraction()) + .SendAsync(); + } + + [Cmd] + public async Task XpShopUse(XpShopInputType type, string key) + { + var result = await _service.UseShopItemAsync(ctx.User.Id, (XpShopItemType)type, key); + + if (!result) + { + await Response().Confirm(strs.xp_shop_item_cant_use).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + private async Task OnShopUse(SocketMessageComponent smc, (string key, XpShopItemType type) state) + { + var (key, type) = state; + + var result = await _service.UseShopItemAsync(ctx.User.Id, type, key); + + + if (!result) + { + await Response().Confirm(strs.xp_shop_item_cant_use).SendAsync(); + } + } + + private async Task OnShopBuy(SocketMessageComponent smc, (string key, XpShopItemType type) state) + { + var (key, type) = state; + + var result = await _service.BuyShopItemAsync(ctx.User.Id, type, key); + + if (result == BuyResult.InsufficientFunds) + { + await Response().Error(strs.not_enough(_gss.GetCurrencySign())).SendAsync(); + } + } + + private string GetNotifLocationString(XpNotificationLocation loc) + { + if (loc == XpNotificationLocation.Channel) + return GetText(strs.xpn_notif_channel); + + if (loc == XpNotificationLocation.Dm) + return GetText(strs.xpn_notif_dm); + + return GetText(strs.xpn_notif_disabled); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpConfig.cs b/src/EllieBot/Modules/Xp/XpConfig.cs new file mode 100644 index 0000000..665df23 --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpConfig.cs @@ -0,0 +1,109 @@ +#nullable disable warnings +using Cloneable; +using EllieBot.Common.Yml; +using EllieBot.Db.Models; +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Xp; + +[Cloneable] +public sealed partial class XpConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 5; + + [Comment("""How much XP will the users receive per message""")] + public int XpPerMessage { get; set; } = 3; + + [Comment("""How often can the users receive XP in minutes""")] + public int MessageXpCooldown { get; set; } = 5; + + [Comment("""Amount of xp users gain from posting an image""")] + public int XpFromImage { get; set; } = 0; + + [Comment("""Average amount of xp earned per minute in VC""")] + public double VoiceXpPerMinute { get; set; } = 0; + + [Comment("""The maximum amount of minutes the bot will keep track of a user in a voice channel""")] + public int VoiceMaxMinutes { get; set; } = 720; + + [Comment("""The amount of currency users will receive for each point of global xp that they earn""")] + public float CurrencyPerXp { get; set; } = 0; + + [Comment("""Xp Shop config""")] + public ShopConfig Shop { get; set; } = new(); + + public sealed class ShopConfig + { + [Comment(""" + Whether the xp shop is enabled + True -> Users can access the xp shop using .xpshop command + False -> Users can't access the xp shop + """)] + public bool IsEnabled { get; set; } = false; + + [Comment(""" + Which patron tier do users need in order to use the .xpshop bgs command + Leave at 'None' if patron system is disabled or you don't want any restrictions + """)] + public PatronTier BgsTierRequirement { get; set; } = PatronTier.None; + + [Comment(""" + Which patron tier do users need in order to use the .xpshop frames command + Leave at 'None' if patron system is disabled or you don't want any restrictions + """)] + public PatronTier FramesTierRequirement { get; set; } = PatronTier.None; + + [Comment(""" + Frames available for sale. Keys are unique IDs. + Do not change keys as they are not publicly visible. Only change properties (name, price, id) + Removing a key which previously existed means that all previous purchases will also be unusable. + To remove an item from the shop, but keep previous purchases, set the price to -1 + """)] + public Dictionary? Frames { get; set; } = new() + { + {"default", new() {Name = "No frame", Price = 0, Url = string.Empty}} + }; + + [Comment(""" + Backgrounds available for sale. Keys are unique IDs. + Do not change keys as they are not publicly visible. Only change properties (name, price, id) + Removing a key which previously existed means that all previous purchases will also be unusable. + To remove an item from the shop, but keep previous purchases, set the price to -1 + """)] + public Dictionary? Bgs { get; set; } = new() + { + {"default", new() {Name = "Default Background", Price = 0, Url = string.Empty}} + }; + } + + public sealed class ShopItemInfo + { + [Comment("""Visible name of the item""")] + public string Name { get; set; } + + [Comment("""Price of the item. Set to -1 if you no longer want to sell the item but want the users to be able to keep their old purchase""")] + public int Price { get; set; } + + [Comment("""Direct url to the .png image which will be applied to the user's XP card""")] + public string Url { get; set; } + + [Comment("""Optional preview url which will show instead of the real URL in the shop """)] + public string Preview { get; set; } + + [Comment("""Optional description of the item""")] + public string Desc { get; set; } + } +} + +public static class XpShopConfigExtensions +{ + public static string? GetItemUrl(this XpConfig.ShopConfig sc, XpShopItemType type, string key) + => (type switch + { + XpShopItemType.Background => sc.Bgs, + _ => sc.Frames + })?.TryGetValue(key, out var item) ?? false + ? item.Url + : null; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpConfigService.cs b/src/EllieBot/Modules/Xp/XpConfigService.cs new file mode 100644 index 0000000..b269379 --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpConfigService.cs @@ -0,0 +1,63 @@ +#nullable disable +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Xp.Services; + +public sealed class XpConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/xp.yml"; + private static readonly TypedKey _changeKey = new("config.xp.updated"); + + public override string Name + => "xp"; + + public XpConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("txt.cooldown", + conf => conf.MessageXpCooldown, + int.TryParse, + ConfigPrinters.ToString, + x => x > 0); + AddParsedProp("txt.per_msg", conf => conf.XpPerMessage, int.TryParse, ConfigPrinters.ToString, x => x >= 0); + AddParsedProp("txt.per_image", conf => conf.XpFromImage, int.TryParse, ConfigPrinters.ToString, x => x > 0); + + AddParsedProp("voice.per_minute", + conf => conf.VoiceXpPerMinute, + double.TryParse, + ConfigPrinters.ToString, + x => x >= 0); + AddParsedProp("voice.max_minutes", + conf => conf.VoiceMaxMinutes, + int.TryParse, + ConfigPrinters.ToString, + x => x > 0); + + AddParsedProp("shop.is_enabled", + conf => conf.Shop.IsEnabled, + bool.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + c.XpFromImage = 0; + }); + } + + if (data.Version < 6) + { + ModifyConfig(c => + { + c.Version = 6; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpRewards.cs b/src/EllieBot/Modules/Xp/XpRewards.cs new file mode 100644 index 0000000..5adc746 --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpRewards.cs @@ -0,0 +1,141 @@ +using EllieBot.Modules.Xp.Services; + +namespace EllieBot.Modules.Xp; + +public partial class Xp +{ + public partial class XpRewards : EllieModule + { + private readonly ICurrencyProvider _cp; + + public XpRewards(ICurrencyProvider cp) + => _cp = cp; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpRewsReset() + { + var promptEmbed = _sender.CreateEmbed() + .WithPendingColor() + .WithDescription(GetText(strs.xprewsreset_confirm)); + + var reply = await PromptUserConfirmAsync(promptEmbed); + + if (!reply) + return; + + await _service.ResetXpRewards(ctx.Guild.Id); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task XpLevelUpRewards(int page = 1) + { + page--; + + if (page is < 0 or > 100) + return Task.CompletedTask; + + var allRewards = _service.GetRoleRewards(ctx.Guild.Id) + .OrderBy(x => x.Level) + .Select(x => + { + var sign = !x.Remove ? "✅ " : "❌ "; + + var str = ctx.Guild.GetRole(x.RoleId)?.ToString(); + + if (str is null) + str = GetText(strs.role_not_found(Format.Code(x.RoleId.ToString()))); + else + { + if (!x.Remove) + str = GetText(strs.xp_receive_role(Format.Bold(str))); + else + str = GetText(strs.xp_lose_role(Format.Bold(str))); + } + + return (x.Level, Text: sign + str); + }) + .Concat(_service.GetCurrencyRewards(ctx.Guild.Id) + .OrderBy(x => x.Level) + .Select(x => (x.Level, + Format.Bold(x.Amount + _cp.GetCurrencySign())))) + .GroupBy(x => x.Level) + .OrderBy(x => x.Key) + .ToList(); + + return Response() + .Paginated() + .Items(allRewards) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.level_up_rewards)).WithOkColor(); + + if (!items.Any()) + return embed.WithDescription(GetText(strs.no_level_up_rewards)); + + foreach (var reward in items) + embed.AddField(GetText(strs.level_x(reward.Key)), + string.Join("\n", reward.Select(y => y.Item2))); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + [Priority(2)] + public async Task XpRoleReward(int level) + { + _service.ResetRoleReward(ctx.Guild.Id, level); + await Response().Confirm(strs.xp_role_reward_cleared(level)).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task XpRoleReward(int level, AddRemove action, [Leftover] IRole role) + { + if (level < 1) + return; + + _service.SetRoleReward(ctx.Guild.Id, level, role.Id, action == AddRemove.Remove); + if (action == AddRemove.Add) + await Response().Confirm(strs.xp_role_reward_add_role(level, Format.Bold(role.ToString()))).SendAsync(); + else + { + await Response() + .Confirm(strs.xp_role_reward_remove_role(Format.Bold(level.ToString()), + Format.Bold(role.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task XpCurrencyReward(int level, int amount = 0) + { + if (level < 1 || amount < 0) + return; + + _service.SetCurrencyReward(ctx.Guild.Id, level, amount); + if (amount == 0) + await Response().Confirm(strs.cur_reward_cleared(level, _cp.GetCurrencySign())).SendAsync(); + else + await Response() + .Confirm(strs.cur_reward_added(level, + Format.Bold(amount + _cp.GetCurrencySign()))) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/XpService.cs b/src/EllieBot/Modules/Xp/XpService.cs new file mode 100644 index 0000000..97d349a --- /dev/null +++ b/src/EllieBot/Modules/Xp/XpService.cs @@ -0,0 +1,1617 @@ +#nullable disable warnings +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using Newtonsoft.Json; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Threading.Channels; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Patronage; +using Color = SixLabors.ImageSharp.Color; +using Exception = System.Exception; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Xp.Services; + +public class XpService : IEService, IReadyExecutor, IExecNoCommand +{ + private readonly DbService _db; + private readonly IImageCache _images; + private readonly IBotStrings _strings; + private readonly FontProvider _fonts; + private readonly IBotCredentials _creds; + private readonly ICurrencyService _cs; + private readonly IHttpClientFactory _httpFactory; + private readonly XpConfigService _xpConfig; + private readonly IPubSub _pubSub; + + private readonly ConcurrentDictionary> _excludedRoles; + private readonly ConcurrentDictionary> _excludedChannels; + private readonly ConcurrentHashSet _excludedServers; + + private XpTemplate template; + private readonly DiscordSocketClient _client; + + private readonly TypedKey _xpTemplateReloadKey; + private readonly IPatronageService _ps; + private readonly IBotCache _c; + + + private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 50); + private readonly Channel _xpGainQueue = Channel.CreateUnbounded(); + private readonly IMessageSenderService _sender; + + public XpService( + DiscordSocketClient client, + IBot bot, + DbService db, + IBotStrings strings, + IImageCache images, + IBotCache c, + FontProvider fonts, + IBotCredentials creds, + ICurrencyService cs, + IHttpClientFactory http, + XpConfigService xpConfig, + IPubSub pubSub, + IPatronageService ps, + IMessageSenderService sender) + { + _db = db; + _images = images; + _strings = strings; + _fonts = fonts; + _creds = creds; + _cs = cs; + _httpFactory = http; + _xpConfig = xpConfig; + _pubSub = pubSub; + _sender = sender; + _excludedServers = new(); + _excludedChannels = new(); + _client = client; + _xpTemplateReloadKey = new("xp.template.reload"); + _ps = ps; + _c = c; + + InternalReloadXpTemplate(); + + if (client.ShardId == 0) + { + _pubSub.Sub(_xpTemplateReloadKey, + _ => + { + InternalReloadXpTemplate(); + return default; + }); + } + + //load settings + var allGuildConfigs = bot.AllGuildConfigs.Where(x => x.XpSettings is not null).ToList(); + + _excludedChannels = allGuildConfigs.ToDictionary(x => x.GuildId, + x => new ConcurrentHashSet(x.XpSettings.ExclusionList + .Where(ex => ex.ItemType == ExcludedItemType.Channel) + .Select(ex => ex.ItemId) + .Distinct())) + .ToConcurrent(); + + _excludedRoles = allGuildConfigs.ToDictionary(x => x.GuildId, + x => new ConcurrentHashSet(x.XpSettings.ExclusionList + .Where(ex => ex.ItemType + == ExcludedItemType.Role) + .Select(ex => ex.ItemId) + .Distinct())) + .ToConcurrent(); + + _excludedServers = new(allGuildConfigs.Where(x => x.XpSettings.ServerExcluded).Select(x => x.GuildId)); + +#if !GLOBAL_ELLIE + _client.UserVoiceStateUpdated += Client_OnUserVoiceStateUpdated; + + // Scan guilds on startup. + _client.GuildAvailable += Client_OnGuildAvailable; + foreach (var guild in _client.Guilds) + Client_OnGuildAvailable(guild); +#endif + } + + public async Task OnReadyAsync() + { + _ = Task.Run(() => _levelUpQueue.RunAsync()); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + while (await timer.WaitForNextTickAsync()) + { + await UpdateXp(); + } + } + + public sealed class MiniGuildXpStats + { + public long Xp { get; set; } + public XpNotificationLocation NotifyOnLevelUp { get; set; } + public ulong GuildId { get; set; } + public ulong UserId { get; set; } + } + + private async Task UpdateXp() + { + try + { + var reader = _xpGainQueue.Reader; + + // sum up all gains into a single UserCacheItem + var globalToAdd = new Dictionary(); + var guildToAdd = new Dictionary>(); + while (reader.TryRead(out var item)) + { + // add global xp to these users + if (!globalToAdd.TryGetValue(item.User.Id, out var ci)) + globalToAdd[item.User.Id] = item.Clone(); + else + ci.XpAmount += item.XpAmount; + + + // ad guild xp in these guilds to these users + if (!guildToAdd.TryGetValue(item.Guild.Id, out var users)) + users = guildToAdd[item.Guild.Id] = new(); + + if (!users.TryGetValue(item.User.Id, out ci)) + users[item.User.Id] = item.Clone(); + else + ci.XpAmount += item.XpAmount; + } + + var dus = new List(globalToAdd.Count); + var gxps = new List(globalToAdd.Count); + await using (var ctx = _db.GetDbContext()) + { + var conf = _xpConfig.Data; + if (conf.CurrencyPerXp > 0) + { + foreach (var user in globalToAdd) + { + var amount = user.Value.XpAmount * conf.CurrencyPerXp; + await _cs.AddAsync(user.Key, (long)(amount), null); + } + } + + // update global user xp in batches + // group by xp amount and update the same amounts at the same time + foreach (var group in globalToAdd.GroupBy(x => x.Value.XpAmount, x => x.Key)) + { + var items = await ctx.Set() + .Where(x => group.Contains(x.UserId)) + .UpdateWithOutputAsync(old => new() + { + TotalXp = old.TotalXp + group.Key + }, + (_, n) => n); + + await ctx.Set() + .Where(x => x.Members.Any(m => group.Contains(m.UserId))) + .UpdateAsync(old => new() + { + Xp = old.Xp + (group.Key * old.Members.Count(m => group.Contains(m.UserId))) + }); + + dus.AddRange(items); + } + + // update guild user xp in batches + foreach (var (guildId, toAdd) in guildToAdd) + { + foreach (var group in toAdd.GroupBy(x => x.Value.XpAmount, x => x.Key)) + { + var items = await ctx + .Set() + .Where(x => x.GuildId == guildId) + .Where(x => group.Contains(x.UserId)) + .UpdateWithOutputAsync(old => new() + { + Xp = old.Xp + group.Key + }, + (_, n) => n); + + gxps.AddRange(items); + + var missingUserIds = group.Where(userId => !items.Any(x => x.UserId == userId)).ToArray(); + foreach (var userId in missingUserIds) + { + await ctx + .Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new UserXpStats() + { + UserId = userId, + GuildId = guildId, + Xp = group.Key, + DateAdded = DateTime.UtcNow, + AwardedXp = 0, + NotifyOnLevelUp = XpNotificationLocation.None + }, + _ => new() + { + }, + () => new() + { + UserId = userId + }); + } + + if (missingUserIds.Length > 0) + { + var missingItems = await ctx.Set() + .ToLinqToDBTable() + .Where(x => missingUserIds.Contains(x.UserId)) + .ToArrayAsyncLinqToDB(); + + gxps.AddRange(missingItems); + } + } + } + } + + foreach (var du in dus) + { + var oldLevel = new LevelStats(du.TotalXp - globalToAdd[du.UserId].XpAmount); + var newLevel = new LevelStats(du.TotalXp); + + if (oldLevel.Level != newLevel.Level) + { + var item = globalToAdd[du.UserId]; + await _levelUpQueue.EnqueueAsync( + NotifyUser(item.Guild.Id, + item.Channel.Id, + du.UserId, + false, + oldLevel.Level, + newLevel.Level, + du.NotifyOnLevelUp)); + } + } + + foreach (var du in gxps) + { + if (guildToAdd.TryGetValue(du.GuildId, out var users) + && users.TryGetValue(du.UserId, out var xpGainData)) + { + var oldLevel = new LevelStats(du.Xp - xpGainData.XpAmount + du.AwardedXp); + var newLevel = new LevelStats(du.Xp + du.AwardedXp); + + if (oldLevel.Level < newLevel.Level) + { + await _levelUpQueue.EnqueueAsync( + NotifyUser(xpGainData.Guild.Id, + xpGainData.Channel.Id, + du.UserId, + true, + oldLevel.Level, + newLevel.Level, + du.NotifyOnLevelUp)); + } + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Error In the XP update loop"); + } + } + + private Func NotifyUser( + ulong guildId, + ulong channelId, + ulong userId, + bool isServer, + long oldLevel, + long newLevel, + XpNotificationLocation notifyLoc) + => async () => + { + if (isServer) + { + await HandleRewardsInternalAsync(guildId, userId, oldLevel, newLevel); + } + + await HandleNotifyInternalAsync(guildId, channelId, userId, isServer, newLevel, notifyLoc); + }; + + private async Task HandleRewardsInternalAsync( + ulong guildId, + ulong userId, + long oldLevel, + long newLevel) + { + List rrews; + List crews; + await using (var ctx = _db.GetDbContext()) + { + rrews = ctx.XpSettingsFor(guildId).RoleRewards.ToList(); + crews = ctx.XpSettingsFor(guildId).CurrencyRewards.ToList(); + } + + //loop through levels since last level up, so if a high amount of xp is gained, reward are still applied. + for (var i = oldLevel + 1; i <= newLevel; i++) + { + var rrew = rrews.FirstOrDefault(x => x.Level == i); + if (rrew is not null) + { + var guild = _client.GetGuild(guildId); + var role = guild?.GetRole(rrew.RoleId); + var user = guild?.GetUser(userId); + + if (role is not null && user is not null) + { + if (rrew.Remove) + _ = user.RemoveRoleAsync(role); + else + _ = user.AddRoleAsync(role); + } + } + + //get currency reward for this level + var crew = crews.FirstOrDefault(x => x.Level == i); + if (crew is not null) + { + //give the user the reward if it exists + await _cs.AddAsync(userId, crew.Amount, new("xp", "level-up")); + } + } + } + + private async Task HandleNotifyInternalAsync( + ulong guildId, + ulong channelId, + ulong userId, + bool isServer, + long newLevel, + XpNotificationLocation notifyLoc) + { + if (notifyLoc == XpNotificationLocation.None) + return; + + var guild = _client.GetGuild(guildId); + var user = guild?.GetUser(userId); + var ch = guild?.GetTextChannel(channelId); + + if (guild is null || user is null) + return; + + if (isServer) + { + if (notifyLoc == XpNotificationLocation.Dm) + { + await _sender.Response(user) + .Confirm(_strings.GetText(strs.level_up_dm(user.Mention, + Format.Bold(newLevel.ToString()), + Format.Bold(guild.ToString() ?? "-")), + guild.Id)) + .SendAsync(); + } + else // channel + { + if (ch is not null) + { + await _sender.Response(ch) + .Confirm(_strings.GetText(strs.level_up_channel(user.Mention, + Format.Bold(newLevel.ToString())), + guild.Id)) + .SendAsync(); + } + } + } + else // global level + { + var chan = notifyLoc switch + { + XpNotificationLocation.Dm => (IMessageChannel)await user.CreateDMChannelAsync(), + XpNotificationLocation.Channel => ch, + _ => null + }; + + if (chan is null) + return; + + await _sender.Response(chan) + .Confirm(_strings.GetText(strs.level_up_global(user.Mention, + Format.Bold(newLevel.ToString())), + guild.Id)) + .SendAsync(); + } + } + + private const string XP_TEMPLATE_PATH = "./data/xp_template.json"; + + private void InternalReloadXpTemplate() + { + try + { + var settings = new JsonSerializerSettings + { + ContractResolver = new RequireObjectPropertiesContractResolver() + }; + + if (!File.Exists(XP_TEMPLATE_PATH)) + { + var newTemp = new XpTemplate(); + newTemp.Version = 1; + File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(newTemp, Formatting.Indented)); + } + + template = JsonConvert.DeserializeObject( + File.ReadAllText(XP_TEMPLATE_PATH), + settings)!; + + if (template.Version < 1) + { + Log.Warning("Loaded default xp_template.json values as the old one was version 0. " + + "Old one was renamed to xp_template.json.old"); + File.WriteAllText("./data/xp_template.json.old", + JsonConvert.SerializeObject(template, Formatting.Indented)); + template = new(); + template.Version = 1; + File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(template, Formatting.Indented)); + } + } + catch (Exception ex) + { + Log.Error(ex, "xp_template.json is invalid. Loaded default values"); + template = new(); + template.Version = 1; + } + } + + public void ReloadXpTemplate() + => _pubSub.Pub(_xpTemplateReloadKey, true); + + public void SetCurrencyReward(ulong guildId, int level, int amount) + { + using var uow = _db.GetDbContext(); + var settings = uow.XpSettingsFor(guildId); + + if (amount <= 0) + { + var toRemove = settings.CurrencyRewards.FirstOrDefault(x => x.Level == level); + if (toRemove is not null) + { + uow.Remove(toRemove); + settings.CurrencyRewards.Remove(toRemove); + } + } + else + { + var rew = settings.CurrencyRewards.FirstOrDefault(x => x.Level == level); + + if (rew is not null) + rew.Amount = amount; + else + { + settings.CurrencyRewards.Add(new() + { + Level = level, + Amount = amount + }); + } + } + + uow.SaveChanges(); + } + + public IEnumerable GetCurrencyRewards(ulong id) + { + using var uow = _db.GetDbContext(); + return uow.XpSettingsFor(id).CurrencyRewards.ToArray(); + } + + public IEnumerable GetRoleRewards(ulong id) + { + using var uow = _db.GetDbContext(); + return uow.XpSettingsFor(id).RoleRewards.ToArray(); + } + + public void ResetRoleReward(ulong guildId, int level) + { + using var uow = _db.GetDbContext(); + var settings = uow.XpSettingsFor(guildId); + + var toRemove = settings.RoleRewards.FirstOrDefault(x => x.Level == level); + if (toRemove is not null) + { + uow.Remove(toRemove); + settings.RoleRewards.Remove(toRemove); + } + + uow.SaveChanges(); + } + + public void SetRoleReward( + ulong guildId, + int level, + ulong roleId, + bool remove) + { + using var uow = _db.GetDbContext(); + var settings = uow.XpSettingsFor(guildId); + + + var rew = settings.RoleRewards.FirstOrDefault(x => x.Level == level); + + if (rew is not null) + { + rew.RoleId = roleId; + rew.Remove = remove; + } + else + { + settings.RoleRewards.Add(new() + { + Level = level, + RoleId = roleId, + Remove = remove, + }); + } + + uow.SaveChanges(); + } + + public async Task> GetUserXps(ulong guildId, int page) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetUsersFor(guildId, page); + } + + public async Task> GetTopUserXps(ulong guildId, int count) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetTopUserXps(guildId, count); + } + + public Task> GetUserXps(int page, int perPage = 9) + { + using var uow = _db.GetDbContext(); + return uow.Set() + .GetUsersXpLeaderboardFor(page, perPage); + } + + public async Task ChangeNotificationType(ulong userId, ulong guildId, XpNotificationLocation type) + { + await using var uow = _db.GetDbContext(); + var user = uow.GetOrCreateUserXpStats(guildId, userId); + user.NotifyOnLevelUp = type; + await uow.SaveChangesAsync(); + } + + public XpNotificationLocation GetNotificationType(ulong userId, ulong guildId) + { + using var uow = _db.GetDbContext(); + var user = uow.GetOrCreateUserXpStats(guildId, userId); + return user.NotifyOnLevelUp; + } + + public XpNotificationLocation GetNotificationType(IUser user) + { + using var uow = _db.GetDbContext(); + return uow.GetOrCreateUser(user).NotifyOnLevelUp; + } + + public async Task ChangeNotificationType(IUser user, XpNotificationLocation type) + { + await using var uow = _db.GetDbContext(); + var du = uow.GetOrCreateUser(user); + du.NotifyOnLevelUp = type; + await uow.SaveChangesAsync(); + } + + private Task Client_OnGuildAvailable(SocketGuild guild) + { + Task.Run(async () => + { + foreach (var channel in guild.VoiceChannels) + await ScanChannelForVoiceXp(channel); + }); + + return Task.CompletedTask; + } + + private Task Client_OnUserVoiceStateUpdated(SocketUser socketUser, SocketVoiceState before, SocketVoiceState after) + { + if (socketUser is not SocketGuildUser user || user.IsBot) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + if (before.VoiceChannel is not null) + await ScanChannelForVoiceXp(before.VoiceChannel); + + if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel) + { + await ScanChannelForVoiceXp(after.VoiceChannel); + } + else if (after.VoiceChannel is null && before.VoiceChannel is not null) + { + // In this case, the user left the channel and the previous for loops didn't catch + // it because it wasn't in any new channel. So we need to get rid of it. + await UserLeftVoiceChannel(user, before.VoiceChannel); + } + }); + + return Task.CompletedTask; + } + + private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel) + { + if (ShouldTrackVoiceChannel(channel)) + { + foreach (var user in channel.ConnectedUsers) + await ScanUserForVoiceXp(user, channel); + } + else + { + foreach (var user in channel.ConnectedUsers) + await UserLeftVoiceChannel(user, channel); + } + } + + /// + /// Assumes that the channel itself is valid and adding xp. + /// + /// + /// + private async Task ScanUserForVoiceXp(SocketGuildUser user, SocketVoiceChannel channel) + { + if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel.Id)) + await UserJoinedVoiceChannel(user); + else + await UserLeftVoiceChannel(user, channel); + } + + private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel) + => channel.ConnectedUsers.Where(UserParticipatingInVoiceChannel).Take(2).Count() >= 2; + + private bool UserParticipatingInVoiceChannel(SocketGuildUser user) + => !user.IsDeafened && !user.IsMuted && !user.IsSelfDeafened && !user.IsSelfMuted; + + private TypedKey GetVoiceXpKey(ulong userId) + => new($"xp:{_client.CurrentUser.Id}:vc_join:{userId}"); + + private async Task UserJoinedVoiceChannel(SocketGuildUser user) + { + var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + await _c.AddAsync(GetVoiceXpKey(user.Id), + value, + TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes), + overwrite: false); + } + + // private void UserJoinedVoiceChannel(SocketGuildUser user) + // { + // var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}"; + // var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + // + // _cache.Redis.GetDatabase() + // .StringSet(key, + // value, + // TimeSpan.FromMinutes(_xpConfig.Data.VoiceMaxMinutes), + // when: When.NotExists); + // } + + private async Task UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel) + { + var key = GetVoiceXpKey(user.Id); + var result = await _c.GetAsync(key); + if (!await _c.RemoveAsync(key)) + return; + + // Allow for if this function gets called multiple times when a user leaves a channel. + if (!result.TryGetValue(out var unixTime)) + return; + + var dateStart = DateTimeOffset.FromUnixTimeSeconds(unixTime); + var dateEnd = DateTimeOffset.UtcNow; + var minutes = (dateEnd - dateStart).TotalMinutes; + var xp = _xpConfig.Data.VoiceXpPerMinute * minutes; + var actualXp = (int)Math.Floor(xp); + + if (actualXp > 0) + { + Log.Information("Adding {Amount} voice xp to {User}", actualXp, user.ToString()); + await _xpGainQueue.Writer.WriteAsync(new() + { + Guild = channel.Guild, + User = user, + XpAmount = actualXp, + Channel = channel + }); + } + } + + /* + * private void UserLeftVoiceChannel(SocketGuildUser user, SocketVoiceChannel channel) + { + var key = $"{_creds.RedisKey()}_user_xp_vc_join_{user.Id}"; + var value = _cache.Redis.GetDatabase().StringGet(key); + _cache.Redis.GetDatabase().KeyDelete(key); + + // Allow for if this function gets called multiple times when a user leaves a channel. + if (value.IsNull) + return; + + if (!value.TryParse(out long startUnixTime)) + return; + + var dateStart = DateTimeOffset.FromUnixTimeSeconds(startUnixTime); + var dateEnd = DateTimeOffset.UtcNow; + var minutes = (dateEnd - dateStart).TotalMinutes; + var xp = _xpConfig.Data.VoiceXpPerMinute * minutes; + var actualXp = (int)Math.Floor(xp); + + if (actualXp > 0) + { + _addMessageXp.Enqueue(new() + { + Guild = channel.Guild, + User = user, + XpAmount = actualXp + }); + } + } + */ + + private bool ShouldTrackXp(SocketGuildUser user, ulong channelId) + { + if (_excludedChannels.TryGetValue(user.Guild.Id, out var chans) && chans.Contains(channelId)) + return false; + + if (_excludedServers.Contains(user.Guild.Id)) + return false; + + if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id))) + return false; + + return true; + } + + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg) + { + if (arg.Author is not SocketGuildUser user || user.IsBot) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + if (!ShouldTrackXp(user, arg.Channel.Id)) + return; + + var xpConf = _xpConfig.Data; + var xp = 0; + if (arg.Attachments.Any(a => a.Height >= 128 && a.Width >= 128)) + xp = xpConf.XpFromImage; + + if (arg.Content.Contains(' ') || arg.Content.Length >= 5) + xp = Math.Max(xp, xpConf.XpPerMessage); + + if (xp <= 0) + return; + + if (!await SetUserRewardedAsync(user.Id)) + return; + + await _xpGainQueue.Writer.WriteAsync(new() + { + Guild = user.Guild, + Channel = arg.Channel, + User = user, + XpAmount = xp + }); + }); + return Task.CompletedTask; + } + + // public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount) + // { + // if (amount <= 0) + // throw new ArgumentOutOfRangeException(nameof(amount)); + // + // _xpGainQueue.Writer.WriteAsync(new() + // { + // Guild = user.Guild, + // Channel = channel, + // User = user, + // XpAmount = amount + // }); + // } + + public async Task AddXpToUsersAsync(ulong guildId, long amount, params ulong[] userIds) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.GuildId == guildId && userIds.Contains(x.UserId)) + .UpdateAsync(old => new() + { + Xp = old.Xp + amount + }); + } + + public void AddXp(ulong userId, ulong guildId, int amount) + { + using var uow = _db.GetDbContext(); + var usr = uow.GetOrCreateUserXpStats(guildId, userId); + + usr.AwardedXp += amount; + + uow.SaveChanges(); + } + + public bool IsServerExcluded(ulong id) + => _excludedServers.Contains(id); + + public IEnumerable GetExcludedRoles(ulong id) + { + if (_excludedRoles.TryGetValue(id, out var val)) + return val.ToArray(); + + return Enumerable.Empty(); + } + + public IEnumerable GetExcludedChannels(ulong id) + { + if (_excludedChannels.TryGetValue(id, out var val)) + return val.ToArray(); + + return Enumerable.Empty(); + } + + private TypedKey GetUserRewKey(ulong userId) + => new($"xp:{_client.CurrentUser.Id}:user_gain:{userId}"); + + private async Task SetUserRewardedAsync(ulong userId) + => await _c.AddAsync(GetUserRewKey(userId), + true, + expiry: TimeSpan.FromMinutes(_xpConfig.Data.MessageXpCooldown), + overwrite: false); + + public async Task GetUserStatsAsync(IGuildUser user) + { + await using var uow = _db.GetDbContext(); + var du = uow.GetOrCreateUser(user, set => set.Include(x => x.Club)); + var totalXp = du.TotalXp; + var globalRank = uow.Set().GetUserGlobalRank(user.Id); + var guildRank = await uow.Set().GetUserGuildRanking(user.Id, user.GuildId); + var stats = uow.GetOrCreateUserXpStats(user.GuildId, user.Id); + await uow.SaveChangesAsync(); + + return new(du, + stats, + new(totalXp), + new(stats.Xp + stats.AwardedXp), + globalRank, + guildRank); + } + + public bool ToggleExcludeServer(ulong id) + { + using var uow = _db.GetDbContext(); + var xpSetting = uow.XpSettingsFor(id); + if (_excludedServers.Add(id)) + { + xpSetting.ServerExcluded = true; + uow.SaveChanges(); + return true; + } + + _excludedServers.TryRemove(id); + xpSetting.ServerExcluded = false; + uow.SaveChanges(); + return false; + } + + public bool ToggleExcludeRole(ulong guildId, ulong rId) + { + var roles = _excludedRoles.GetOrAdd(guildId, _ => new()); + using var uow = _db.GetDbContext(); + var xpSetting = uow.XpSettingsFor(guildId); + var excludeObj = new ExcludedItem + { + ItemId = rId, + ItemType = ExcludedItemType.Role + }; + + if (roles.Add(rId)) + { + if (xpSetting.ExclusionList.Add(excludeObj)) + uow.SaveChanges(); + + return true; + } + + roles.TryRemove(rId); + + var toDelete = xpSetting.ExclusionList.FirstOrDefault(x => x.Equals(excludeObj)); + if (toDelete is not null) + { + uow.Remove(toDelete); + uow.SaveChanges(); + } + + return false; + } + + public bool ToggleExcludeChannel(ulong guildId, ulong chId) + { + var channels = _excludedChannels.GetOrAdd(guildId, _ => new()); + using var uow = _db.GetDbContext(); + var xpSetting = uow.XpSettingsFor(guildId); + var excludeObj = new ExcludedItem + { + ItemId = chId, + ItemType = ExcludedItemType.Channel + }; + + if (channels.Add(chId)) + { + if (xpSetting.ExclusionList.Add(excludeObj)) + uow.SaveChanges(); + + return true; + } + + channels.TryRemove(chId); + + if (xpSetting.ExclusionList.Remove(excludeObj)) + uow.SaveChanges(); + + return false; + } + + public async Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(IGuildUser user) + { + var stats = await GetUserStatsAsync(user); + return await GenerateXpImageAsync(stats); + } + + + public Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(FullUserStats stats) + => Task.Run(async () => + { + var bgBytes = await GetXpBackgroundAsync(stats.User.UserId); + + if (bgBytes is null) + { + Log.Warning("Xp background image could not be loaded"); + throw new ArgumentNullException(nameof(bgBytes)); + } + + var outlinePen = new Pen(Color.Black, 1f); + + using var img = Image.Load(bgBytes, out var imageFormat); + if (template.User.Name.Show) + { + var fontSize = (int)(template.User.Name.FontSize * 0.9); + var username = stats.User.ToString(); + var usernameFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold); + + var size = TextMeasurer.Measure($"@{username}", new(usernameFont)); + var scale = 400f / size.Width; + if (scale < 1) + usernameFont = _fonts.NotoSans.CreateFont(template.User.Name.FontSize * scale, FontStyle.Bold); + + img.Mutate(x => + { + x.DrawText(new TextOptions(usernameFont) + { + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + FallbackFontFamilies = _fonts.FallBackFonts, + Origin = new(template.User.Name.Pos.X, template.User.Name.Pos.Y + 8) + }, + "@" + username, + Brushes.Solid(template.User.Name.Color), + outlinePen); + }); + } + + //club name + + if (template.Club.Name.Show) + { + var clubName = stats.User.Club?.ToString() ?? "-"; + + var clubFont = _fonts.NotoSans.CreateFont(template.Club.Name.FontSize, FontStyle.Regular); + + img.Mutate(x => x.DrawText(new TextOptions(clubFont) + { + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Top, + FallbackFontFamilies = _fonts.FallBackFonts, + Origin = new(template.Club.Name.Pos.X + 50, template.Club.Name.Pos.Y - 8) + }, + clubName, + Brushes.Solid(template.Club.Name.Color), + outlinePen)); + } + + Font GetTruncatedFont( + FontFamily fontFamily, + int fontSize, + FontStyle style, + string text, + int maxSize) + { + var font = fontFamily.CreateFont(fontSize, style); + var size = TextMeasurer.Measure(text, new(font)); + var scale = maxSize / size.Width; + if (scale < 1) + font = fontFamily.CreateFont(fontSize * scale, style); + + return font; + } + + + if (template.User.GlobalLevel.Show) + { + // up to 83 width + + var globalLevelFont = GetTruncatedFont( + _fonts.NotoSans, + template.User.GlobalLevel.FontSize, + FontStyle.Bold, + stats.Global.Level.ToString(), + 75); + + img.Mutate(x => + { + x.DrawText(stats.Global.Level.ToString(), + globalLevelFont, + template.User.GlobalLevel.Color, + new(template.User.GlobalLevel.Pos.X, template.User.GlobalLevel.Pos.Y)); //level + }); + } + + if (template.User.GuildLevel.Show) + { + var guildLevelFont = GetTruncatedFont( + _fonts.NotoSans, + template.User.GuildLevel.FontSize, + FontStyle.Bold, + stats.Guild.Level.ToString(), + 75); + + img.Mutate(x => + { + x.DrawText(stats.Guild.Level.ToString(), + guildLevelFont, + template.User.GuildLevel.Color, + new(template.User.GuildLevel.Pos.X, template.User.GuildLevel.Pos.Y)); + }); + } + + + var global = stats.Global; + var guild = stats.Guild; + + //xp bar + if (template.User.Xp.Bar.Show) + { + var xpPercent = global.LevelXp / (float)global.RequiredXp; + DrawXpBar(xpPercent, template.User.Xp.Bar.Global, img); + xpPercent = guild.LevelXp / (float)guild.RequiredXp; + DrawXpBar(xpPercent, template.User.Xp.Bar.Guild, img); + } + + if (template.User.Xp.Global.Show) + { + img.Mutate(x => x.DrawText( + new TextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Global.FontSize, FontStyle.Bold)) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Origin = new(template.User.Xp.Global.Pos.X, template.User.Xp.Global.Pos.Y), + }, + $"{global.LevelXp}/{global.RequiredXp}", + Brushes.Solid(template.User.Xp.Global.Color), + outlinePen)); + } + + if (template.User.Xp.Guild.Show) + { + img.Mutate(x => x.DrawText( + new TextOptions(_fonts.NotoSans.CreateFont(template.User.Xp.Guild.FontSize, FontStyle.Bold)) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Origin = new(template.User.Xp.Guild.Pos.X, template.User.Xp.Guild.Pos.Y) + }, + $"{guild.LevelXp}/{guild.RequiredXp}", + Brushes.Solid(template.User.Xp.Guild.Color), + outlinePen)); + } + + if (stats.FullGuildStats.AwardedXp != 0 && template.User.Xp.Awarded.Show) + { + var sign = stats.FullGuildStats.AwardedXp > 0 ? "+ " : ""; + var awX = template.User.Xp.Awarded.Pos.X + - (Math.Max(0, stats.FullGuildStats.AwardedXp.ToString().Length - 2) * 5); + var awY = template.User.Xp.Awarded.Pos.Y; + img.Mutate(x => x.DrawText($"({sign}{stats.FullGuildStats.AwardedXp})", + _fonts.NotoSans.CreateFont(template.User.Xp.Awarded.FontSize, FontStyle.Bold), + Brushes.Solid(template.User.Xp.Awarded.Color), + outlinePen, + new(awX, awY))); + } + + var rankPen = new Pen(Color.White, 1); + //ranking + if (template.User.GlobalRank.Show) + { + var globalRankStr = stats.GlobalRanking.ToString(); + + var globalRankFont = GetTruncatedFont( + _fonts.UniSans, + template.User.GlobalRank.FontSize, + FontStyle.Bold, + globalRankStr, + 68); + + img.Mutate(x => x.DrawText( + new TextOptions(globalRankFont) + { + Origin = new(template.User.GlobalRank.Pos.X, template.User.GlobalRank.Pos.Y) + }, + globalRankStr, + Brushes.Solid(template.User.GlobalRank.Color), + rankPen + )); + } + + if (template.User.GuildRank.Show) + { + var guildRankStr = stats.GuildRanking.ToString(); + + var guildRankFont = GetTruncatedFont( + _fonts.UniSans, + template.User.GuildRank.FontSize, + FontStyle.Bold, + guildRankStr, + 43); + + img.Mutate(x => x.DrawText( + new TextOptions(guildRankFont) + { + Origin = new(template.User.GuildRank.Pos.X, template.User.GuildRank.Pos.Y) + }, + guildRankStr, + Brushes.Solid(template.User.GuildRank.Color), + rankPen + )); + } + + //avatar + if (template.User.Icon.Show) + { + try + { + var avatarUrl = stats.User.RealAvatarUrl(); + + if (avatarUrl is not null) + { + var result = await _c.GetImageDataAsync(avatarUrl); + if (!result.TryPickT0(out var data, out _)) + { + using (var http = _httpFactory.CreateClient()) + { + var avatarData = await http.GetByteArrayAsync(avatarUrl); + using (var tempDraw = Image.Load(avatarData)) + { + tempDraw.Mutate(x => x + .Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y) + .ApplyRoundedCorners(Math.Max(template.User.Icon.Size.X, + template.User.Icon.Size.Y) + / 2.0f)); + await using (var stream = await tempDraw.ToStreamAsync()) + { + data = stream.ToArray(); + } + } + } + + await _c.SetImageDataAsync(avatarUrl, data); + } + + using var toDraw = Image.Load(data); + if (toDraw.Size() != new Size(template.User.Icon.Size.X, template.User.Icon.Size.Y)) + toDraw.Mutate(x => x.Resize(template.User.Icon.Size.X, template.User.Icon.Size.Y)); + + img.Mutate(x => x.DrawImage(toDraw, + new Point(template.User.Icon.Pos.X, template.User.Icon.Pos.Y), + 1)); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error drawing avatar image"); + } + } + + //club image + if (template.Club.Icon.Show) + await DrawClubImage(img, stats); + +// #if GLOBAL_ELLIE + await DrawFrame(img, stats.User.UserId); +// #endif + + var outputSize = template.OutputSize; + if (outputSize.X != img.Width || outputSize.Y != img.Height) + img.Mutate(x => x.Resize(template.OutputSize.X, template.OutputSize.Y)); + + var output = ((Stream)await img.ToStreamAsync(imageFormat), imageFormat); + + return output; + }); + + private async Task GetXpBackgroundAsync(ulong userId) + { + var item = await GetItemInUse(userId, XpShopItemType.Background); + if (item is null) + { + return await _images.GetXpBackgroundImageAsync(); + } + + var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Background, item.ItemKey); + if (!string.IsNullOrWhiteSpace(url)) + { + var data = await _images.GetImageDataAsync(new Uri(url)); + return data; + } + + return await _images.GetXpBackgroundImageAsync(); + } + + // #if GLOBAL_ELLIE + private async Task DrawFrame(Image img, ulong userId) + { + var patron = await _ps.GetPatronAsync(userId); + + var item = await GetItemInUse(userId, XpShopItemType.Frame); + + Image? frame = null; + if (item is null) + { + if (patron.Tier == PatronTier.V) + frame = Image.Load(File.OpenRead("data/images/frame_silver.png")); + else if (patron.Tier >= PatronTier.X || _creds.IsOwner(userId)) + frame = Image.Load(File.OpenRead("data/images/frame_gold.png")); + } + else + { + var url = _xpConfig.Data.Shop.GetItemUrl(XpShopItemType.Frame, item.ItemKey); + if (!string.IsNullOrWhiteSpace(url)) + { + var data = await _images.GetImageDataAsync(new Uri(url)); + frame = Image.Load(data); + } + } + + if (frame is not null) + img.Mutate(x => x.DrawImage(frame, new Point(0, 0), new GraphicsOptions())); + } +// #endif + + private void DrawXpBar(float percent, XpBar info, Image img) + { + var x1 = info.PointA.X; + var y1 = info.PointA.Y; + + var x2 = info.PointB.X; + var y2 = info.PointB.Y; + + var length = info.Length * percent; + + float x3, x4, y3, y4; + + if (info.Direction == XpTemplateDirection.Down) + { + x3 = x1; + x4 = x2; + y3 = y1 + length; + y4 = y2 + length; + } + else if (info.Direction == XpTemplateDirection.Up) + { + x3 = x1; + x4 = x2; + y3 = y1 - length; + y4 = y2 - length; + } + else if (info.Direction == XpTemplateDirection.Left) + { + x3 = x1 - length; + x4 = x2 - length; + y3 = y1; + y4 = y2; + } + else + { + x3 = x1 + length; + x4 = x2 + length; + y3 = y1; + y4 = y2; + } + + img.Mutate(x => x.FillPolygon(info.Color, + new PointF(x1, y1), + new PointF(x3, y3), + new PointF(x4, y4), + new PointF(x2, y2))); + } + + private async Task DrawClubImage(Image img, FullUserStats stats) + { + if (!string.IsNullOrWhiteSpace(stats.User.Club?.ImageUrl)) + { + try + { + var imgUrl = new Uri(stats.User.Club.ImageUrl); + var result = await _c.GetImageDataAsync(imgUrl); + if (!result.TryPickT0(out var data, out _)) + { + using (var http = _httpFactory.CreateClient()) + using (var temp = await http.GetAsync(imgUrl, HttpCompletionOption.ResponseHeadersRead)) + { + if (!temp.IsImage() || temp.GetContentLength() > 11 * 1024 * 1024) + return; + + var imgData = await temp.Content.ReadAsByteArrayAsync(); + using (var tempDraw = Image.Load(imgData)) + { + tempDraw.Mutate(x => x + .Resize(template.Club.Icon.Size.X, template.Club.Icon.Size.Y) + .ApplyRoundedCorners(Math.Max(template.Club.Icon.Size.X, + template.Club.Icon.Size.Y) + / 2.0f)); + await using (var tds = await tempDraw.ToStreamAsync()) + { + data = tds.ToArray(); + } + } + } + + await _c.SetImageDataAsync(imgUrl, data); + } + + using var toDraw = Image.Load(data); + if (toDraw.Size() != new Size(template.Club.Icon.Size.X, template.Club.Icon.Size.Y)) + toDraw.Mutate(x => x.Resize(template.Club.Icon.Size.X, template.Club.Icon.Size.Y)); + + img.Mutate(x => x.DrawImage( + toDraw, + new Point(template.Club.Icon.Pos.X, template.Club.Icon.Pos.Y), + 1)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error drawing club image"); + } + } + } + + public void XpReset(ulong guildId, ulong userId) + { + using var uow = _db.GetDbContext(); + uow.Set().ResetGuildUserXp(userId, guildId); + uow.SaveChanges(); + } + + public void XpReset(ulong guildId) + { + using var uow = _db.GetDbContext(); + uow.Set().ResetGuildXp(guildId); + uow.SaveChanges(); + } + + public async Task ResetXpRewards(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var guildConfig = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.XpSettings) + .ThenInclude(x => x.CurrencyRewards) + .Include(x => x.XpSettings) + .ThenInclude(x => x.RoleRewards)); + + uow.RemoveRange(guildConfig.XpSettings.RoleRewards); + uow.RemoveRange(guildConfig.XpSettings.CurrencyRewards); + await uow.SaveChangesAsync(); + } + + public ValueTask?> GetShopBgs() + { + var data = _xpConfig.Data; + if (!data.Shop.IsEnabled) + return new(default(Dictionary)); + + return new(_xpConfig.Data.Shop.Bgs?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value)); + } + + public ValueTask?> GetShopFrames() + { + var data = _xpConfig.Data; + if (!data.Shop.IsEnabled) + return new(default(Dictionary)); + + return new(_xpConfig.Data.Shop.Frames?.Where(x => x.Value.Price >= 0).ToDictionary(x => x.Key, x => x.Value)); + } + + public async Task BuyShopItemAsync(ulong userId, XpShopItemType type, string key) + { + var conf = _xpConfig.Data; + + if (!conf.Shop.IsEnabled) + return BuyResult.XpShopDisabled; + + var req = type == XpShopItemType.Background + ? conf.Shop.BgsTierRequirement + : conf.Shop.FramesTierRequirement; + + if (req != PatronTier.None && !_creds.IsOwner(userId)) + { + var patron = await _ps.GetPatronAsync(userId); + + if ((int)patron.Tier < (int)req) + return BuyResult.InsufficientPatronTier; + } + + await using var ctx = _db.GetDbContext(); + try + { + if (await ctx.GetTable() + .AnyAsyncLinqToDB(x => x.UserId == userId && x.ItemKey == key && x.ItemType == type)) + return BuyResult.AlreadyOwned; + + var item = GetShopItem(type, key); + + if (item is null || item.Price < 0) + return BuyResult.UnknownItem; + + if (item.Price > 0 && !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}"))) + return BuyResult.InsufficientFunds; + + + await ctx.GetTable() + .InsertAsync(() => new XpShopOwnedItem() + { + UserId = userId, + IsUsing = false, + ItemKey = key, + ItemType = type, + DateAdded = DateTime.UtcNow, + }); + + return BuyResult.Success; + } + catch (Exception ex) + { + Log.Error(ex, "Error buying shop item: {ErrorMessage}", ex.Message); + return BuyResult.UnknownItem; + } + } + + private XpConfig.ShopItemInfo? GetShopItem(XpShopItemType type, string key) + { + var data = _xpConfig.Data; + if (type == XpShopItemType.Background) + { + if (data.Shop.Bgs is { } bgs && bgs.TryGetValue(key, out var item)) + return item; + + return null; + } + + if (type == XpShopItemType.Frame) + { + if (data.Shop.Frames is { } fs && fs.TryGetValue(key, out var item)) + return item; + + return null; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task OwnsItemAsync( + ulong userId, + XpShopItemType itemType, + string key) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .AnyAsyncLinqToDB(x => x.UserId == userId + && x.ItemType == itemType + && x.ItemKey == key); + } + + + public async Task GetUserItemAsync( + ulong userId, + XpShopItemType itemType, + string key) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId + && x.ItemType == itemType + && x.ItemKey == key); + } + + public async Task GetItemInUse( + ulong userId, + XpShopItemType itemType) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId + && x.ItemType == itemType + && x.IsUsing); + } + + public async Task UseShopItemAsync(ulong userId, XpShopItemType itemType, string key) + { + var data = _xpConfig.Data; + XpConfig.ShopItemInfo? item = null; + if (itemType == XpShopItemType.Background) + { + data.Shop.Bgs?.TryGetValue(key, out item); + } + else + { + data.Shop.Frames?.TryGetValue(key, out item); + } + + if (item is null) + return false; + + await using var ctx = _db.GetDbContext(); + + if (await OwnsItemAsync(userId, itemType, key)) + { + await ctx.GetTable() + .Where(x => x.UserId == userId && x.ItemType == itemType) + .UpdateAsync(old => new() + { + IsUsing = key == old.ItemKey + }); + + return true; + } + + return false; + } + + public PatronTier GetXpShopTierRequirement(Xp.XpShopInputType type) + => type switch + { + Xp.XpShopInputType.F => _xpConfig.Data.Shop.FramesTierRequirement, + _ => _xpConfig.Data.Shop.BgsTierRequirement, + }; + + public bool IsShopEnabled() + => _xpConfig.Data.Shop.IsEnabled; +} + +public enum BuyResult +{ + Success, + XpShopDisabled, + AlreadyOwned, + InsufficientFunds, + UnknownItem, + InsufficientPatronTier, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/FullUserStats.cs b/src/EllieBot/Modules/Xp/_common/FullUserStats.cs new file mode 100644 index 0000000..de8b8da --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/FullUserStats.cs @@ -0,0 +1,31 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Xp; + +public class FullUserStats +{ + public DiscordUser User { get; } + public UserXpStats FullGuildStats { get; } + public LevelStats Global { get; } + public LevelStats Guild { get; } + public int GlobalRanking { get; } + public int GuildRanking { get; } + + public FullUserStats( + DiscordUser usr, + UserXpStats fullGuildStats, + LevelStats global, + LevelStats guild, + int globalRanking, + int guildRanking) + { + User = usr; + Global = global; + Guild = guild; + GlobalRanking = globalRanking; + GuildRanking = guildRanking; + FullGuildStats = fullGuildStats; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs b/src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs new file mode 100644 index 0000000..33e0b35 --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Xp; + +public interface IXpCleanupService +{ + Task DeleteXp(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/UserCacheItem.cs b/src/EllieBot/Modules/Xp/_common/UserCacheItem.cs new file mode 100644 index 0000000..36f0796 --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/UserCacheItem.cs @@ -0,0 +1,13 @@ +#nullable disable warnings +using Cloneable; + +namespace EllieBot.Modules.Xp.Services; + +[Cloneable] +public sealed partial class UserXpGainData : ICloneable +{ + public IGuildUser User { get; init; } + public IGuild Guild { get; init; } + public IMessageChannel Channel { get; init; } + public int XpAmount { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/XpCleanupService.cs b/src/EllieBot/Modules/Xp/_common/XpCleanupService.cs new file mode 100644 index 0000000..30baf35 --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/XpCleanupService.cs @@ -0,0 +1,31 @@ +using LinqToDB; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Xp; + +public sealed class XpCleanupService : IXpCleanupService, IEService +{ + private readonly DbService _db; + + public XpCleanupService(DbService db) + { + _db = db; + } + + public async Task DeleteXp() + { + await using var uow = _db.GetDbContext(); + await uow.Set().UpdateAsync(_ => new DiscordUser() + { + ClubId = null, + // IsClubAdmin = false, + TotalXp = 0 + }); + + await uow.Set().DeleteAsync(); + await uow.Set().DeleteAsync(); + await uow.Set().DeleteAsync(); + await uow.Set().DeleteAsync(); + await uow.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/XpTemplate.cs b/src/EllieBot/Modules/Xp/_common/XpTemplate.cs new file mode 100644 index 0000000..020ad7f --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/XpTemplate.cs @@ -0,0 +1,271 @@ +#nullable disable +using Newtonsoft.Json; +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Xp; + +public class XpTemplate +{ + public int Version { get; set; } = 0; + + [JsonProperty("output_size")] + public XpTemplatePos OutputSize { get; set; } = new() + { + X = 800, + Y = 392 + }; + + public XpTemplateUser User { get; set; } = new() + { + Name = new() + { + FontSize = 50, + Show = true, + Pos = new() + { + X = 130, + Y = 17 + } + }, + Icon = new() + { + Show = true, + Pos = new() + { + X = 14, + Y = 14 + }, + Size = new() + { + X = 72, + Y = 71 + } + }, + GuildLevel = new() + { + Show = true, + FontSize = 45, + Pos = new() + { + X = 47, + Y = 308 + } + }, + GlobalLevel = new() + { + Show = true, + FontSize = 45, + Pos = new() + { + X = 47, + Y = 160 + } + }, + GuildRank = new() + { + Show = true, + FontSize = 30, + Pos = new() + { + X = 148, + Y = 326 + } + }, + GlobalRank = new() + { + Show = true, + FontSize = 30, + Pos = new() + { + X = 148, + Y = 179 + } + }, + Xp = new() + { + Bar = new() + { + Show = true, + Global = new() + { + Direction = XpTemplateDirection.Right, + Length = 450, + Color = new(0, 0, 0, 0.4f), + PointA = new() + { + X = 321, + Y = 104 + }, + PointB = new() + { + X = 286, + Y = 235 + } + }, + Guild = new() + { + Direction = XpTemplateDirection.Right, + Length = 450, + Color = new(0, 0, 0, 0.4f), + PointA = new() + { + X = 282, + Y = 248 + }, + PointB = new() + { + X = 247, + Y = 379 + } + } + }, + Global = new() + { + Show = true, + FontSize = 50, + Pos = new() + { + X = 528, + Y = 170 + } + }, + Guild = new() + { + Show = true, + FontSize = 50, + Pos = new() + { + X = 490, + Y = 313 + } + }, + Awarded = new() + { + Show = true, + FontSize = 25, + Pos = new() + { + X = 490, + Y = 345 + } + } + } + }; + + public XpTemplateClub Club { get; set; } = new() + { + Icon = new() + { + Show = true, + Pos = new() + { + X = 722, + Y = 25 + }, + Size = new() + { + X = 45, + Y = 45 + } + }, + Name = new() + { + FontSize = 35, + Pos = new() + { + X = 650, + Y = 49 + }, + Show = true + } + }; +} + +public class XpTemplateIcon +{ + public bool Show { get; set; } + public XpTemplatePos Pos { get; set; } + public XpTemplatePos Size { get; set; } +} + +public class XpTemplatePos +{ + public int X { get; set; } + public int Y { get; set; } +} + +public class XpTemplateUser +{ + public XpTemplateText Name { get; set; } + public XpTemplateIcon Icon { get; set; } + public XpTemplateText GlobalLevel { get; set; } + public XpTemplateText GuildLevel { get; set; } + public XpTemplateText GlobalRank { get; set; } + public XpTemplateText GuildRank { get; set; } + public XpTemplateXp Xp { get; set; } +} + +public class XpTemplateClub +{ + public XpTemplateIcon Icon { get; set; } + public XpTemplateText Name { get; set; } +} + +public class XpTemplateText +{ + [JsonConverter(typeof(XpRgba32Converter))] + public Rgba32 Color { get; set; } = SixLabors.ImageSharp.Color.White; + + public bool Show { get; set; } + public int FontSize { get; set; } + public XpTemplatePos Pos { get; set; } +} + +public class XpTemplateXp +{ + public XpTemplateXpBar Bar { get; set; } + public XpTemplateText Global { get; set; } + public XpTemplateText Guild { get; set; } + public XpTemplateText Awarded { get; set; } +} + +public class XpTemplateXpBar +{ + public bool Show { get; set; } + public XpBar Global { get; set; } + public XpBar Guild { get; set; } +} + +public class XpBar +{ + [JsonConverter(typeof(XpRgba32Converter))] + public Rgba32 Color { get; set; } + + public XpTemplatePos PointA { get; set; } + public XpTemplatePos PointB { get; set; } + public int Length { get; set; } + public XpTemplateDirection Direction { get; set; } +} + +public enum XpTemplateDirection +{ + Up, + Down, + Left, + Right +} + +public class XpRgba32Converter : JsonConverter +{ + public override Rgba32 ReadJson( + JsonReader reader, + Type objectType, + Rgba32 existingValue, + bool hasExistingValue, + JsonSerializer serializer) + => Color.ParseHex(reader.Value?.ToString()); + + public override void WriteJson(JsonWriter writer, Rgba32 value, JsonSerializer serializer) + => writer.WriteValue(value.ToHex().ToLowerInvariant()); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs b/src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs new file mode 100644 index 0000000..29b491e --- /dev/null +++ b/src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs @@ -0,0 +1,16 @@ +#nullable disable warnings +namespace EllieBot.Db.Models; + +public class XpShopOwnedItem : DbEntity +{ + public ulong UserId { get; set; } + public XpShopItemType ItemType { get; set; } + public bool IsUsing { get; set; } + public string ItemKey { get; set; } +} + +public enum XpShopItemType +{ + Background = 0, + Frame = 1, +} \ No newline at end of file