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