From eb9ed5754748bc74546ec99367fb3cab073db526 Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Sat, 21 Sep 2024 00:46:59 +1200
Subject: [PATCH] Added Xp module

---
 src/EllieBot/Modules/Xp/Club/Club.cs          |  485 +++++
 src/EllieBot/Modules/Xp/Club/ClubService.cs   |  377 ++++
 src/EllieBot/Modules/Xp/Club/IClubService.cs  |   35 +
 .../Xp/Club/Results/ClubAcceptResult.cs       |   15 +
 .../Modules/Xp/Club/Results/ClubBanResult.cs  |   10 +
 .../Xp/Club/Results/ClubCreateResult.cs       |   10 +
 .../Modules/Xp/Club/Results/ClubKickResult.cs |    9 +
 .../Xp/Club/Results/ClubLeaveResult.cs        |    8 +
 .../Xp/Club/Results/ClubRenameResult.cs       |    9 +
 .../Xp/Club/Results/ClubTransferError.cs      |    7 +
 .../Xp/Club/Results/ClubUnbanResult.cs        |    8 +
 .../Xp/Club/Results/SetClubIconResult.cs      |    9 +
 .../Xp/Club/Results/ToggleAdminResult.cs      |   10 +
 src/EllieBot/Modules/Xp/Xp.cs                 |  599 ++++++
 src/EllieBot/Modules/Xp/XpConfig.cs           |  109 ++
 src/EllieBot/Modules/Xp/XpConfigService.cs    |   63 +
 src/EllieBot/Modules/Xp/XpRewards.cs          |  141 ++
 src/EllieBot/Modules/Xp/XpService.cs          | 1622 +++++++++++++++++
 .../Modules/Xp/_common/FullUserStats.cs       |   30 +
 .../Modules/Xp/_common/IXpCleanupService.cs   |    6 +
 .../Modules/Xp/_common/UserCacheItem.cs       |   13 +
 .../Modules/Xp/_common/XpCleanupService.cs    |   31 +
 src/EllieBot/Modules/Xp/_common/XpTemplate.cs |  271 +++
 .../Modules/Xp/_common/db/XpShopOwnedItem.cs  |   16 +
 24 files changed, 3893 insertions(+)
 create mode 100644 src/EllieBot/Modules/Xp/Club/Club.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/ClubService.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/IClubService.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubAcceptResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubBanResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubCreateResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubKickResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubLeaveResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubRenameResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubTransferError.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ClubUnbanResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/SetClubIconResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Club/Results/ToggleAdminResult.cs
 create mode 100644 src/EllieBot/Modules/Xp/Xp.cs
 create mode 100644 src/EllieBot/Modules/Xp/XpConfig.cs
 create mode 100644 src/EllieBot/Modules/Xp/XpConfigService.cs
 create mode 100644 src/EllieBot/Modules/Xp/XpRewards.cs
 create mode 100644 src/EllieBot/Modules/Xp/XpService.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/FullUserStats.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/UserCacheItem.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/XpCleanupService.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/XpTemplate.cs
 create mode 100644 src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs

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