diff --git a/Ellie.sln b/Ellie.sln index d7bbfb6..d8b414f 100644 --- a/Ellie.sln +++ b/Ellie.sln @@ -41,8 +41,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Common", "src\Ell EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Modules.Gambling", "src\Ellie.Bot.Modules.Gambling\Ellie.Bot.Modules.Gambling.csproj", "{11910E7D-E373-482F-8207-678DE0A88112}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Generators.Cloneable", "src\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj", "{AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Modules.Searches", "src\Ellie.Bot.Modules.Searches\Ellie.Bot.Modules.Searches.csproj", "{3168DB6A-574F-4ECA-9203-7F54AC3311DE}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Music", "src\Ellie.Bot.Modules.Music\Ellie.Bot.Modules.Music.csproj", "{07579F42-E7DD-4FFC-B375-A4EC61EAF012}" @@ -57,6 +55,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Permisssi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Modules.Xp", "src\Ellie.Bot.Modules.Xp\Ellie.Bot.Modules.Xp.csproj", "{A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Generators.Cloneable", "src\Ellie.Bot.Generators.Cloneable\Ellie.Bot.Generators.Cloneable.csproj", "{D98BF713-F286-4B7A-895B-C9FE8842F3A9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,10 +115,6 @@ Global {11910E7D-E373-482F-8207-678DE0A88112}.Debug|Any CPU.Build.0 = Debug|Any CPU {11910E7D-E373-482F-8207-678DE0A88112}.Release|Any CPU.ActiveCfg = Release|Any CPU {11910E7D-E373-482F-8207-678DE0A88112}.Release|Any CPU.Build.0 = Release|Any CPU - {AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90}.Release|Any CPU.Build.0 = Release|Any CPU {3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {3168DB6A-574F-4ECA-9203-7F54AC3311DE}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -147,6 +143,10 @@ Global {A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Debug|Any CPU.Build.0 = Debug|Any CPU {A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Release|Any CPU.ActiveCfg = Release|Any CPU {A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73}.Release|Any CPU.Build.0 = Release|Any CPU + {D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D98BF713-F286-4B7A-895B-C9FE8842F3A9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -166,7 +166,6 @@ Global {D3411F6C-320C-456D-BA86-24481EB000EA} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {3EC0F005-560F-4E90-88CF-199520133BBA} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {11910E7D-E373-482F-8207-678DE0A88112} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} - {AFA3DD12-0F98-4754-ADD7-9FF3C1A37C90} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {3168DB6A-574F-4ECA-9203-7F54AC3311DE} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {07579F42-E7DD-4FFC-B375-A4EC61EAF012} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {01168E4B-B34A-4C90-83E4-BEC6F03452D6} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} @@ -174,6 +173,7 @@ Global {6F8102FD-C78E-4AE5-8019-DC6BB4F6D1A8} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {C4269D96-1699-46C8-8461-3263B5668BA5} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {A8F7CBAE-93EC-44D2-9CDF-EEC7CEDBDC73} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} + {D98BF713-F286-4B7A-895B-C9FE8842F3A9} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA} diff --git a/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj b/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj new file mode 100644 index 0000000..f02677b --- /dev/null +++ b/src/Ellie.Bot.Modules.Administration/Ellie.Bot.Modules.Administration.csproj @@ -0,0 +1,10 @@ + + + + Exe + net7.0 + enable + enable + + + diff --git a/src/Ellie.Bot.Modules.Patronage/GlobalUsings.cs b/src/Ellie.Bot.Modules.Patronage/GlobalUsings.cs new file mode 100644 index 0000000..9a69c08 --- /dev/null +++ b/src/Ellie.Bot.Modules.Patronage/GlobalUsings.cs @@ -0,0 +1,31 @@ +// global using System.Collections.Concurrent; +global using NonBlocking; + +// packages +global using Serilog; +global using Humanizer; + +// ellie +global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; +global using Ellie.Marmalade; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult; + +// non-essential +global using JetBrains.Annotations; \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/CurrencyRewardService.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/CurrencyRewardService.cs new file mode 100644 index 0000000..6b4c784 --- /dev/null +++ b/src/Ellie.Bot.Modules.Patronage/Patronage/CurrencyRewardService.cs @@ -0,0 +1,195 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Ellie.Modules.Gambling.Services; +using Ellie.Modules.Patronage; +using Ellie.Services.Currency; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Utility; + +public sealed class CurrencyRewardService : IEService, IDisposable +{ + private readonly ICurrencyService _cs; + private readonly IPatronageService _ps; + private readonly DbService _db; + private readonly IEmbedBuilderService _eb; + private readonly GamblingConfigService _config; + private readonly DiscordSocketClient _client; + + public CurrencyRewardService( + ICurrencyService cs, + IPatronageService ps, + DbService db, + IEmbedBuilderService eb, + GamblingConfigService config, + DiscordSocketClient client) + { + _cs = cs; + _ps = ps; + _db = db; + _eb = eb; + _config = config; + _client = client; + + _ps.OnNewPatronPayment += OnNewPayment; + _ps.OnPatronRefunded += OnPatronRefund; + _ps.OnPatronUpdated += OnPatronUpdate; + } + + public void Dispose() + { + _ps.OnNewPatronPayment -= OnNewPayment; + _ps.OnPatronRefunded -= OnPatronRefund; + _ps.OnPatronUpdated -= OnPatronUpdate; + } + + private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) + { + // if pledge was increased + if (oldPatron.Amount < newPatron.Amount) + { + var conf = _config.Data; + var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent); + + RewardedUser old; + await using (var ctx = _db.GetDbContext()) + { + old = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .FirstOrDefaultAsync(); + + if (old is null) + { + await OnNewPayment(newPatron); + return; + } + + // no action as the amount is the same or lower + if (old.AmountRewardedThisMonth >= newAmount) + return; + + var count = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .UpdateAsync(_ => new() + { + PlatformUserId = newPatron.UniquePlatformUserId, + UserId = newPatron.UserId, + // amount before bonuses + AmountRewardedThisMonth = newAmount, + LastReward = newPatron.PaidAt + }); + + // shouldn't ever happen + if (count == 0) + return; + } + + var oldAmount = old.AmountRewardedThisMonth; + + var realNewAmount = GetRealCurrencyReward( + (int)(newAmount / conf.PatreonCurrencyPerCent), + newAmount, + out var percentBonus); + + var realOldAmount = GetRealCurrencyReward( + (int)(oldAmount / conf.PatreonCurrencyPerCent), + oldAmount, + out _); + + var diff = realNewAmount - realOldAmount; + if (diff <= 0) + return; // no action if new is lower + + // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, + // up to 100% + + await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron","update")); + + _ = SendMessageToUser(newPatron.UserId, + $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); + } + } + + private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus) + { + // needs at least 5$ to be eligible for a bonus + if (pledgeCents < 500) + { + percentBonus = 0; + return modifiedAmount; + } + + var dollarValue = pledgeCents / 100; + percentBonus = dollarValue switch + { + >= 100 => 100, + >= 50 => 50, + >= 20 => 20, + >= 10 => 10, + >= 5 => 5, + _ => 0 + }; + return (long)(modifiedAmount * (1 + (percentBonus / 100.0f))); + } + + // on a new payment, always give the full amount. + private async Task OnNewPayment(Patron patron) + { + var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent); + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + PlatformUserId = patron.UniquePlatformUserId, + UserId = patron.UserId, + AmountRewardedThisMonth = amount, + LastReward = patron.PaidAt, + }, + old => new() + { + AmountRewardedThisMonth = amount, + UserId = patron.UserId, + LastReward = patron.PaidAt + }, + () => new() + { + PlatformUserId = patron.UniquePlatformUserId + }); + + var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); + await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new")); + _ = SendMessageToUser(patron.UserId, + $"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!"); + } + + private async Task SendMessageToUser(ulong userId, string message) + { + try + { + var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId); + if (user is null) + return; + + var eb = _eb.Create() + .WithOkColor() + .WithDescription(message); + + await user.EmbedAsync(eb); + } + catch + { + Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId); + } + } + + private async Task OnPatronRefund(Patron patron) + { + await using var ctx = _db.GetDbContext(); + _ = await ctx.GetTable() + .UpdateAsync(old => new() + { + AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2 + }); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Patronage/Patronage/InsufficientTier.cs b/src/Ellie.Bot.Modules.Patronage/Patronage/InsufficientTier.cs new file mode 100644 index 0000000..8d49522 --- /dev/null +++ b/src/Ellie.Bot.Modules.Patronage/Patronage/InsufficientTier.cs @@ -0,0 +1,11 @@ +using Ellie.Db.Models; + +namespace Ellie.Modules.Patronage; + +public readonly struct InsufficientTier +{ + public FeatureType FeatureType { get; init; } + public string Feature { get; init; } + public PatronTier RequiredTier { get; init; } + public PatronTier UserTier { get; init; } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/CleanupCommands.cs b/src/Ellie.Bot.Modules.Xp/CleanupCommands.cs new file mode 100644 index 0000000..887fa01 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/CleanupCommands.cs @@ -0,0 +1,19 @@ +namespace Ellie.Modules.Xp; + +public sealed partial class Xp +{ + public class CleanupCommands : CleanupModuleBase + { + private readonly IXpCleanupService _service; + + public CleanupCommands(IXpCleanupService service) + { + _service = service; + } + + [Cmd] + [OwnerOnly] + public Task DeleteXp() + => ConfirmActionInternalAsync("Delete Xp", () => _service.DeleteXp()); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Club.cs b/src/Ellie.Bot.Modules.Xp/Club/Club.cs new file mode 100644 index 0000000..d7b5321 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Club.cs @@ -0,0 +1,424 @@ +#nullable disable +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Xp.Services; + +namespace Ellie.Modules.Xp.Club; + +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 ReplyErrorLocalizedAsync(strs.club_owner_only); + else + await ReplyErrorLocalizedAsync(strs.club_target_not_member); + } + else + { + await ReplyConfirmLocalizedAsync( + strs.club_transfered( + Format.Bold(club.Name), + Format.Bold(newOwner.ToString()) + ) + ); + } + } + + [Cmd] + public async Task ClubAdmin([Leftover] IUser toAdmin) + { + var result = await _service.ToggleAdminAsync(ctx.User, toAdmin); + + if (result == ToggleAdminResult.AddedAdmin) + await ReplyConfirmLocalizedAsync(strs.club_admin_add(Format.Bold(toAdmin.ToString()))); + else if (result == ToggleAdminResult.RemovedAdmin) + await ReplyConfirmLocalizedAsync(strs.club_admin_remove(Format.Bold(toAdmin.ToString()))); + else if (result == ToggleAdminResult.NotOwner) + await ReplyErrorLocalizedAsync(strs.club_owner_only); + else if (result == ToggleAdminResult.CantTargetThyself) + await ReplyErrorLocalizedAsync(strs.club_admin_invalid_target); + else if (result == ToggleAdminResult.TargetNotMember) + await ReplyErrorLocalizedAsync(strs.club_target_not_member); + } + + [Cmd] + public async Task ClubCreate([Leftover] string clubName) + { + if (string.IsNullOrWhiteSpace(clubName) || clubName.Length > 20) + { + await ReplyErrorLocalizedAsync(strs.club_name_too_long); + return; + } + + var result = await _service.CreateClubAsync(ctx.User, clubName); + + if (result == ClubCreateResult.NameTaken) + { + await ReplyErrorLocalizedAsync(strs.club_create_error_name); + return; + } + + if (result == ClubCreateResult.InsufficientLevel) + { + await ReplyErrorLocalizedAsync(strs.club_create_insuff_lvl); + return; + } + + if (result == ClubCreateResult.AlreadyInAClub) + { + await ReplyErrorLocalizedAsync(strs.club_already_in); + return; + } + + await ReplyConfirmLocalizedAsync(strs.club_created(Format.Bold(clubName))); + } + + [Cmd] + public async Task ClubIcon([Leftover] string url = null) + { + if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null)) + { + await ReplyErrorLocalizedAsync(strs.club_icon_url_format); + return; + } + + var result = await _service.SetClubIconAsync(ctx.User.Id, url); + if (result == SetClubIconResult.Success) + await ReplyConfirmLocalizedAsync(strs.club_icon_set); + else if (result == SetClubIconResult.NotOwner) + await ReplyErrorLocalizedAsync(strs.club_owner_only); + else if (result == SetClubIconResult.TooLarge) + await ReplyErrorLocalizedAsync(strs.club_icon_too_large); + else if (result == SetClubIconResult.InvalidFileType) + await ReplyErrorLocalizedAsync(strs.club_icon_invalid_filetype); + } + + private async Task InternalClubInfoAsync(ClubInfo club) + { + var lvl = new LevelStats(club.Xp); + var users = 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; + }); + + await ctx.SendPaginatedConfirmAsync(0, + page => + { + var embed = _eb.Create() + .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.Skip(page * 10) + .Take(10) + .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; + }, + club.Members.Count, + 10); + } + + [Cmd] + [Priority(1)] + public async Task ClubInformation(IUser user = null) + { + user ??= ctx.User; + var club = _service.GetClubByMember(user); + if (club is null) + { + await ErrorLocalizedAsync(strs.club_user_not_in_club(Format.Bold(user.ToString()))); + 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 ReplyErrorLocalizedAsync(strs.club_not_exists); + 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 ReplyErrorLocalizedAsync(strs.club_admin_perms); + + var bans = club.Bans.Select(x => x.User).ToArray(); + + return ctx.SendPaginatedConfirmAsync(page, + _ => + { + var toShow = string.Join("\n", bans + .Skip(page * 10).Take(10) + .Select(x => x.ToString())); + + return _eb.Create() + .WithTitle(GetText(strs.club_bans_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }, + bans.Length, + 10); + } + + [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 ReplyErrorLocalizedAsync(strs.club_admin_perms); + + var apps = club.Applicants.Select(x => x.User).ToArray(); + + return ctx.SendPaginatedConfirmAsync(page, + _ => + { + var toShow = string.Join("\n", apps.Skip(page * 10).Take(10).Select(x => x.ToString())); + + return _eb.Create() + .WithTitle(GetText(strs.club_apps_for(club.ToString()))) + .WithDescription(toShow) + .WithOkColor(); + }, + apps.Length, + 10); + } + + [Cmd] + public async Task ClubApply([Leftover] string clubName) + { + if (string.IsNullOrWhiteSpace(clubName)) + return; + + if (!_service.GetClubByName(clubName, out var club)) + { + await ReplyErrorLocalizedAsync(strs.club_not_exists); + return; + } + + var result = _service.ApplyToClub(ctx.User, club); + if (result == ClubApplyResult.Success) + await ReplyConfirmLocalizedAsync(strs.club_applied(Format.Bold(club.ToString()))); + else if (result == ClubApplyResult.Banned) + await ReplyErrorLocalizedAsync(strs.club_join_banned); + else if (result == ClubApplyResult.InsufficientLevel) + await ReplyErrorLocalizedAsync(strs.club_insuff_lvl); + else if (result == ClubApplyResult.AlreadyInAClub) + await ReplyErrorLocalizedAsync(strs.club_already_in); + } + + [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 ReplyConfirmLocalizedAsync(strs.club_accepted(Format.Bold(discordUser.ToString()))); + else if (result == ClubAcceptResult.NoSuchApplicant) + await ReplyErrorLocalizedAsync(strs.club_accept_invalid_applicant); + else if (result == ClubAcceptResult.NotOwnerOrAdmin) + await ReplyErrorLocalizedAsync(strs.club_admin_perms); + } + + [Cmd] + public async Task ClubLeave() + { + var res = _service.LeaveClub(ctx.User); + + if (res == ClubLeaveResult.Success) + await ReplyConfirmLocalizedAsync(strs.club_left); + else if (res == ClubLeaveResult.NotInAClub) + await ReplyErrorLocalizedAsync(strs.club_not_in_a_club); + else + await ReplyErrorLocalizedAsync(strs.club_owner_cant_leave); + } + + [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 ReplyConfirmLocalizedAsync(strs.club_user_kick(Format.Bold(userName), + Format.Bold(club.ToString()))); + } + + if (result == ClubKickResult.Hierarchy) + return ReplyErrorLocalizedAsync(strs.club_kick_hierarchy); + + if (result == ClubKickResult.NotOwnerOrAdmin) + return ReplyErrorLocalizedAsync(strs.club_admin_perms); + + return ReplyErrorLocalizedAsync(strs.club_target_not_member); + } + + [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 ReplyConfirmLocalizedAsync(strs.club_user_banned(Format.Bold(userName), + Format.Bold(club.ToString()))); + } + + if (result == ClubBanResult.Unbannable) + return ReplyErrorLocalizedAsync(strs.club_ban_fail_unbannable); + + if (result == ClubBanResult.WrongUser) + return ReplyErrorLocalizedAsync(strs.club_ban_fail_user_not_found); + + return ReplyErrorLocalizedAsync(strs.club_admin_perms); + } + + [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 ReplyConfirmLocalizedAsync(strs.club_user_unbanned(Format.Bold(userName), + Format.Bold(club.ToString()))); + } + + if (result == ClubUnbanResult.WrongUser) + { + return ReplyErrorLocalizedAsync(strs.club_unban_fail_user_not_found); + } + + return ReplyErrorLocalizedAsync(strs.club_admin_perms); + } + + [Cmd] + public async Task ClubDescription([Leftover] string desc = null) + { + if (_service.SetDescription(ctx.User.Id, desc)) + { + desc = string.IsNullOrWhiteSpace(desc) + ? "-" + : desc; + + var eb = _eb.Create(ctx) + .WithAuthor(ctx.User) + .WithTitle(GetText(strs.club_desc_update)) + .WithOkColor() + .WithDescription(desc); + + await ctx.Channel.EmbedAsync(eb); + } + else + { + await ReplyErrorLocalizedAsync(strs.club_desc_update_failed); + } + } + + [Cmd] + public async Task ClubDisband() + { + if (_service.Disband(ctx.User.Id, out var club)) + await ReplyConfirmLocalizedAsync(strs.club_disbanded(Format.Bold(club.Name))); + else + await ReplyErrorLocalizedAsync(strs.club_disband_error); + } + + [Cmd] + public Task ClubLeaderboard(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var clubs = _service.GetClubLeaderboardPage(page); + + var embed = _eb.Create().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 ctx.Channel.EmbedAsync(embed); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/ClubService.cs b/src/Ellie.Bot.Modules.Xp/Club/ClubService.cs new file mode 100644 index 0000000..acc1a64 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/ClubService.cs @@ -0,0 +1,319 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Ellie.Db; +using Ellie.Db.Models; +using OneOf; + +namespace Ellie.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) + { + //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().Bytes) + 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.Club 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.InsufficientLevel; + + 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 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) + { + if (page < 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + using var uow = _db.GetDbContext(); + return uow.Set().GetClubLeaderboardPage(page); + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/IClubService.cs b/src/Ellie.Bot.Modules.Xp/Club/IClubService.cs new file mode 100644 index 0000000..98ac734 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/IClubService.cs @@ -0,0 +1,33 @@ +using Ellie.Db.Models; +using OneOf; + +namespace Ellie.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); + 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); +} + +public enum ClubApplyResult +{ + Success, + + AlreadyInAClub, + Banned, + InsufficientLevel +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubAcceptResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubAcceptResult.cs new file mode 100644 index 0000000..0151bbd --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubAcceptResult.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubAcceptResult +{ + Accepted, + NotOwnerOrAdmin, + NoSuchApplicant, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubBanResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubBanResult.cs new file mode 100644 index 0000000..8e4bee9 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubBanResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubBanResult +{ + Success, + NotOwnerOrAdmin, + WrongUser, + Unbannable +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubCreateResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubCreateResult.cs new file mode 100644 index 0000000..fdc0cf4 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubCreateResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubCreateResult +{ + Success, + AlreadyInAClub, + NameTaken, + InsufficientLevel, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubKickResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubKickResult.cs new file mode 100644 index 0000000..cecdd8c --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubKickResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubKickResult +{ + Success, + NotOwnerOrAdmin, + TargetNotAMember, + Hierarchy +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubLeaveResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubLeaveResult.cs new file mode 100644 index 0000000..af05d4e --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubLeaveResult.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubLeaveResult +{ + Success, + OwnerCantLeave, + NotInAClub +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubTransferError.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubTransferError.cs new file mode 100644 index 0000000..7181f75 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubTransferError.cs @@ -0,0 +1,7 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubTransferError +{ + NotOwner, + TargetNotMember +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ClubUnbanResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubUnbanResult.cs new file mode 100644 index 0000000..82fd628 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ClubUnbanResult.cs @@ -0,0 +1,8 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ClubUnbanResult +{ + Success, + NotOwnerOrAdmin, + WrongUser +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/SetClubIconResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/SetClubIconResult.cs new file mode 100644 index 0000000..77caeb0 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/SetClubIconResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Modules.Xp.Services; + +public enum SetClubIconResult +{ + Success, + InvalidFileType, + TooLarge, + NotOwner, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Club/Results/ToggleAdminResult.cs b/src/Ellie.Bot.Modules.Xp/Club/Results/ToggleAdminResult.cs new file mode 100644 index 0000000..4a16c66 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Club/Results/ToggleAdminResult.cs @@ -0,0 +1,10 @@ +namespace Ellie.Modules.Xp.Services; + +public enum ToggleAdminResult +{ + AddedAdmin, + RemovedAdmin, + NotOwner, + TargetNotMember, + CantTargetThyself, +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Ellie.Bot.Modules.Xp.csproj b/src/Ellie.Bot.Modules.Xp/Ellie.Bot.Modules.Xp.csproj new file mode 100644 index 0000000..50f3f36 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Ellie.Bot.Modules.Xp.csproj @@ -0,0 +1,21 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/Ellie.Bot.Modules.Xp/GlobalUsings.cs b/src/Ellie.Bot.Modules.Xp/GlobalUsings.cs new file mode 100644 index 0000000..6d28094 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/GlobalUsings.cs @@ -0,0 +1,32 @@ +// // global using System.Collections.Concurrent; +global using NonBlocking; +// +// // packages +global using Serilog; +global using Humanizer; +global using Newtonsoft; +// +// // ellie +// global using Ellie; +global using Ellie.Services; +global using Ellise.Common; // new project +global using Ellie.Common; // old + ellie specific things +global using Ellie.Common.Attributes; +global using Ellie.Extensions; +// global using Nadeko.Snake; + +// discord +global using Discord; +global using Discord.Commands; +global using Discord.Net; +global using Discord.WebSocket; + +// aliases +global using GuildPerm = Discord.GuildPermission; +global using ChannelPerm = Discord.ChannelPermission; +global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute; +global using LeftoverAttribute = Discord.Commands.RemainderAttribute; +global using TypeReaderResult = Ellie.Common.TypeReaders.TypeReaderResult; + +// non-essential +// global using JetBrains.Annotations; \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/Xp.cs b/src/Ellie.Bot.Modules.Xp/Xp.cs new file mode 100644 index 0000000..5745a9c --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/Xp.cs @@ -0,0 +1,559 @@ +#nullable disable warnings +using Ellie.Modules.Xp.Services; +using Ellie.Services.Database.Models; +using Ellie.Bot.Common; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Modules.Patronage; + +namespace Ellie.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 = _eb.Create() + .WithOkColor() + .AddField(GetText(strs.xpn_setting_global), GetNotifLocationString(globalSetting)) + .AddField(GetText(strs.xpn_setting_server), GetNotifLocationString(serverSetting)); + + await ctx.Channel.EmbedAsync(embed); + } + + [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 ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(ctx.Guild.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(ctx.Guild.ToString()))); + } + + [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 ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(role.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(role.ToString()))); + } + + [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 ReplyConfirmLocalizedAsync(strs.excluded(Format.Bold(channel.ToString()))); + else + await ReplyConfirmLocalizedAsync(strs.not_excluded(Format.Bold(channel.ToString()))); + } + + [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 ctx.SendPaginatedConfirmAsync(0, + curpage => + { + var embed = _eb.Create() + .WithTitle(GetText(strs.exclusion_list)) + .WithDescription(string.Join('\n', lines.Skip(15 * curpage).Take(15))) + .WithOkColor(); + + return embed; + }, + lines.Length, + 15); + } + + [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 allUsers = new List(); + if (opts.Clean) + { + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + allUsers = _service.GetTopUserXps(ctx.Guild.Id, 1000) + .Where(user => socketGuild.GetUser(user.UserId) is not null) + .ToList(); + } + + await ctx.SendPaginatedConfirmAsync(page, + curPage => + { + var embed = _eb.Create().WithTitle(GetText(strs.server_leaderboard)).WithOkColor(); + + List users; + if (opts.Clean) + users = allUsers.Skip(curPage * 9).Take(9).ToList(); + else + users = _service.GetUserXps(ctx.Guild.Id, curPage); + + 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; + }, + 900, + 9, + false); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task XpGlobalLeaderboard(int page = 1) + { + if (--page < 0 || page > 99) + return; + var users = _service.GetUserXps(page); + + var embed = _eb.Create().WithTitle(GetText(strs.global_leaderboard)).WithOkColor(); + + if (!users.Any()) + embed.WithDescription("-"); + else + { + for (var i = 0; i < users.Length; i++) + { + var user = users[i]; + embed.AddField($"#{i + 1 + (page * 9)} {user.ToString()}", + $"{GetText(strs.level_x(new LevelStats(users[i].TotalXp).Level))} - {users[i].TotalXp}xp"); + } + } + + await ctx.Channel.EmbedAsync(embed); + } + + [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 ReplyConfirmLocalizedAsync( + strs.xpadd_users(Format.Bold(amount.ToString()), Format.Bold(count.ToString()))); + } + + [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 ReplyConfirmLocalizedAsync(strs.modified(Format.Bold(usr), Format.Bold(amount.ToString()))); + } + + [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 ReplyConfirmLocalizedAsync(strs.template_reloaded); + } + + [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 = _eb.Create().WithTitle(GetText(strs.reset)).WithDescription(GetText(strs.reset_user_confirm)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + _service.XpReset(ctx.Guild.Id, userId); + + await ReplyConfirmLocalizedAsync(strs.reset_user(userId)); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task XpReset() + { + var embed = _eb.Create().WithTitle(GetText(strs.reset)).WithDescription(GetText(strs.reset_server_confirm)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + _service.XpReset(ctx.Guild.Id); + + await ReplyConfirmLocalizedAsync(strs.reset_server); + } + + 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 ReplyErrorLocalizedAsync(strs.xp_shop_disabled); + return; + } + + await SendConfirmAsync(GetText(strs.available_commands), + $@"`{prefix}xpshop bgs` +`{prefix}xpshop frames` + +*{GetText(strs.xpshop_website)}*"); + } + + [Cmd] + public async Task XpShop(XpShopInputType type, int page = 1) + { + --page; + + if (page < 0) + return; + + var items = type == XpShopInputType.Backgrounds + ? await _service.GetShopBgs() + : await _service.GetShopFrames(); + + if (items is null) + { + await ReplyErrorLocalizedAsync(strs.xp_shop_disabled); + return; + } + + if (items.Count == 0) + { + await ReplyErrorLocalizedAsync(strs.not_found); + return; + } + + await ctx.SendPaginatedConfirmAsync<(string, XpShopItemType)?>(page, + current => + { + var (key, item) = items.Skip(current).First(); + + var eb = _eb.Create(ctx) + .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 Task.FromResult(eb); + }, + async current => + { + var (key, _) = items.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 = new SimpleInteraction<(string key, XpShopItemType type)?>( + button, + OnShopUse, + (key, itemType)); + + return inter; + } + else + { + var button = new ButtonBuilder(GetText(strs.buy), + "xpshop:buy", + emote: Emoji.Parse("💰")); + + var inter = new SimpleInteraction<(string key, XpShopItemType type)?>( + button, + OnShopBuy, + (key, itemType)); + + return inter; + } + }, + items.Count, + 1, + addPaginatedFooter: false); + } + + [Cmd] + public async Task XpShopBuy(XpShopInputType type, string key) + { + var result = await _service.BuyShopItemAsync(ctx.User.Id, (XpShopItemType)type, key); + + EllieInteraction GetUseInteraction() + { + return _inter.Create(ctx.User.Id, + new SimpleInteraction( + new ButtonBuilder(label: "Use", customId: "xpshop:use_item", emote: Emoji.Parse("👐")), + async (smc, _) => await XpShopUse(type, key) + )); + } + + if (result != BuyResult.Success) + { + var _ = result switch + { + BuyResult.XpShopDisabled => await ReplyErrorLocalizedAsync(strs.xp_shop_disabled), + BuyResult.InsufficientFunds => await ReplyErrorLocalizedAsync(strs.not_enough(_gss.GetCurrencySign())), + BuyResult.AlreadyOwned => + await ReplyErrorLocalizedAsync(strs.xpshop_already_owned, GetUseInteraction()), + BuyResult.UnknownItem => await ReplyErrorLocalizedAsync(strs.xpshop_item_not_found), + BuyResult.InsufficientPatronTier => await ReplyErrorLocalizedAsync(strs.patron_insuff_tier), + _ => throw new ArgumentOutOfRangeException() + }; + return; + } + + await ReplyConfirmLocalizedAsync(strs.xpshop_buy_success(type.ToString().ToLowerInvariant(), + key.ToLowerInvariant()), + GetUseInteraction()); + } + + [Cmd] + public async Task XpShopUse(XpShopInputType type, string key) + { + var result = await _service.UseShopItemAsync(ctx.User.Id, (XpShopItemType)type, key); + + if (!result) + { + await ReplyConfirmLocalizedAsync(strs.xp_shop_item_cant_use); + return; + } + + await ctx.OkAsync(); + } + + private async Task OnShopUse(SocketMessageComponent smc, (string? key, XpShopItemType type)? maybeState) + { + if (maybeState is not { } state) + return; + + var (key, type) = state; + + var result = await _service.UseShopItemAsync(ctx.User.Id, type, key); + + + if (!result) + { + await ReplyConfirmLocalizedAsync(strs.xp_shop_item_cant_use); + } + } + + private async Task OnShopBuy(SocketMessageComponent smc, (string? key, XpShopItemType type)? maybeState) + { + if (maybeState is not { } state) + return; + + var (key, type) = state; + + var result = await _service.BuyShopItemAsync(ctx.User.Id, type, key); + + if (result == BuyResult.InsufficientFunds) + { + await ReplyErrorLocalizedAsync(strs.not_enough(_gss.GetCurrencySign())); + } + } + + 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/Ellie.Bot.Modules.Xp/XpConfig.cs b/src/Ellie.Bot.Modules.Xp/XpConfig.cs new file mode 100644 index 0000000..e65d13a --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/XpConfig.cs @@ -0,0 +1,109 @@ +#nullable disable +using Cloneable; +using Ellie.Common.Yml; +using Ellie.Db.Models; +using Ellie.Modules.Patronage; + +namespace Ellie.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/Ellie.Bot.Modules.Xp/XpConfigService.cs b/src/Ellie.Bot.Modules.Xp/XpConfigService.cs new file mode 100644 index 0000000..f4c5bdf --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/XpConfigService.cs @@ -0,0 +1,63 @@ +#nullable disable +using Ellie.Common.Configs; + +namespace Ellie.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/Ellie.Bot.Modules.Xp/XpRewards.cs b/src/Ellie.Bot.Modules.Xp/XpRewards.cs new file mode 100644 index 0000000..5c344e7 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/XpRewards.cs @@ -0,0 +1,138 @@ +using Ellie.Bot.Common; +using Ellie.Modules.Xp.Services; + +namespace Ellie.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 = _eb.Create() + .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 Context.SendPaginatedConfirmAsync(page, + cur => + { + var embed = _eb.Create().WithTitle(GetText(strs.level_up_rewards)).WithOkColor(); + + var localRewards = allRewards.Skip(cur * 9).Take(9).ToList(); + + if (!localRewards.Any()) + return embed.WithDescription(GetText(strs.no_level_up_rewards)); + + foreach (var reward in localRewards) + embed.AddField(GetText(strs.level_x(reward.Key)), + string.Join("\n", reward.Select(y => y.Item2))); + + return embed; + }, + allRewards.Count, + 9); + } + + [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 ReplyConfirmLocalizedAsync(strs.xp_role_reward_cleared(level)); + } + + [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 ReplyConfirmLocalizedAsync(strs.xp_role_reward_add_role(level, Format.Bold(role.ToString()))); + else + { + await ReplyConfirmLocalizedAsync(strs.xp_role_reward_remove_role(Format.Bold(level.ToString()), + Format.Bold(role.ToString()))); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task XpCurrencyReward(int level, int amount = 0) + { + if (level < 1 || amount < 0) + return; + + // todo, shouldn't all amount + sign be N(amount) + _service.SetCurrencyReward(ctx.Guild.Id, level, amount); + if (amount == 0) + await ReplyConfirmLocalizedAsync(strs.cur_reward_cleared(level, _cp.GetCurrencySign())); + else + await ReplyConfirmLocalizedAsync(strs.cur_reward_added(level, + Format.Bold(amount + _cp.GetCurrencySign()))); + } + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/XpService.cs b/src/Ellie.Bot.Modules.Xp/XpService.cs new file mode 100644 index 0000000..976747c --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/XpService.cs @@ -0,0 +1,1252 @@ +#nullable disable warnings +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using Ellie.Common.ModuleBehaviors; +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Services.Database.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 Ellie.Modules.Patronage; +using Color = SixLabors.ImageSharp.Color; +using Exception = System.Exception; +using Image = SixLabors.ImageSharp.Image; + +namespace Ellie.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 IEmbedBuilderService _eb; + + 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(); + + 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, + IEmbedBuilderService eb, + IPatronageService ps) + { + _db = db; + _images = images; + _strings = strings; + _fonts = fonts; + _creds = creds; + _cs = cs; + _httpFactory = http; + _xpConfig = xpConfig; + _pubSub = pubSub; + _eb = eb; + _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(5.Seconds()); + 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 user.SendConfirmAsync(_eb, + _strings.GetText(strs.level_up_dm(user.Mention, + Format.Bold(newLevel.ToString()), + Format.Bold(guild.ToString() ?? "-")), + guild.Id)); + } + else // channel + { + if (ch is not null) + { + await ch.SendConfirmAsync(_eb, + _strings.GetText(strs.level_up_channel(user.Mention, + Format.Bold(newLevel.ToString())), + guild.Id)); + } + } + } + else // global level + { + var chan = notifyLoc switch + { + XpNotificationLocation.Dm => (IMessageChannel)await user.CreateDMChannelAsync(), + XpNotificationLocation.Channel => ch, + _ => null + }; + + if (chan is null) + return; + + await chan.SendConfirmAsync(_eb, + _strings.GetText(strs.level_up_global(user.Mention, + Format.Bold(newLevel.ToString())), + guild.Id)); + } + } + + 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 + { + using var uow = _db.GetDbContext(); + return uow.Set().GetUsersFor(guildId, page); + } + + public List GetTopUserXps(ulong guildId, int count) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetTopUserXps(guildId, count); + } + + public DiscordUser[] GetUserXps(int page) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetUsersXpLeaderboardFor(page); + } + + 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 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 (stats.User.AvatarId is not null && template.User.Icon.Show) + 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.Megabytes().Bytes) + 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(); + // await using var tran = await ctx.Database.BeginTransactionAsync(); + 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/Ellie.Bot.Modules.Xp/_common/Extensions.cs b/src/Ellie.Bot.Modules.Xp/_common/Extensions.cs new file mode 100644 index 0000000..5a60033 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/Extensions.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Modules.Xp.Extensions; + +public static class Extensions +{ + +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/_common/FullUserStats.cs b/src/Ellie.Bot.Modules.Xp/_common/FullUserStats.cs new file mode 100644 index 0000000..0cf4f4f --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/FullUserStats.cs @@ -0,0 +1,32 @@ +#nullable disable +using Ellie.Db; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.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 user, + UserXpStats fullGuildStats, + LevelStats global, + LevelStats guild, + int globalRanking, + int guildRanking) + { + User = user; + Global = global; + Guild = guild; + GlobalRanking = globalRanking; + GuildRanking = guildRanking; + FullGuildStats = fullGuildStats; + } +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/_common/IXpCleanupService.cs b/src/Ellie.Bot.Modules.Xp/_common/IXpCleanupService.cs new file mode 100644 index 0000000..f01c0a5 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/IXpCleanupService.cs @@ -0,0 +1,6 @@ +namespace Ellie.Modules.Xp; + +public interface IXpCleanupService +{ + Task DeleteXp(); +} \ No newline at end of file diff --git a/src/Ellie.Bot.Modules.Xp/_common/UserCacheItem.cs b/src/Ellie.Bot.Modules.Xp/_common/UserCacheItem.cs new file mode 100644 index 0000000..4fe0598 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/UserCacheItem.cs @@ -0,0 +1,13 @@ +#nullable disable warnings +using Cloneable; + +namespace Ellie.Modules.Xp.Services; + +[Cloneable] +public sealed partial class UserCacheItem : 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/Ellie.Bot.Modules.Xp/_common/XpCleanupService.cs b/src/Ellie.Bot.Modules.Xp/_common/XpCleanupService.cs new file mode 100644 index 0000000..0d99d4c --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/XpCleanupService.cs @@ -0,0 +1,32 @@ +using LinqToDB; +using Ellie.Db.Models; +using Ellie.Services.Database.Models; + +namespace Ellie.Modules.Xp; + +public sealed class XpCleanupService : IXpCleanupService +{ + 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/Ellie.Bot.Modules.Xp/_common/XpTemplate.cs b/src/Ellie.Bot.Modules.Xp/_common/XpTemplate.cs new file mode 100644 index 0000000..be0cfb3 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/XpTemplate.cs @@ -0,0 +1,271 @@ +#nullable disable +using Newtonsoft.Json; +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace Ellie.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/Ellie.Bot.Modules.Xp/_common/db/XpShopOwnedItem.cs b/src/Ellie.Bot.Modules.Xp/_common/db/XpShopOwnedItem.cs new file mode 100644 index 0000000..e71bf80 --- /dev/null +++ b/src/Ellie.Bot.Modules.Xp/_common/db/XpShopOwnedItem.cs @@ -0,0 +1,18 @@ +#nullable disable +using Ellie.Services.Database.Models; + +namespace Ellie.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