#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 LinqToDB.Tools; 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 IBotCreds _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, IBotCreds 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_ELLIE _client.UserVoiceStateUpdated += Client_OnUserVoiceStateUpdated; // Scan guilds on startup. _client.GuildAvailable += Client_OnGuildAvailable; foreach (var guild in _client.Guilds) Client_OnGuildAvailable(guild); #endif } public async Task OnReadyAsync() { _ = Task.Run(() => _levelUpQueue.RunAsync()); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); while (await timer.WaitForNextTickAsync()) { await UpdateXp(); } } public sealed class MiniGuildXpStats { public long Xp { get; set; } public XpNotificationLocation NotifyOnLevelUp { get; set; } public ulong GuildId { get; set; } public ulong UserId { get; set; } } private async Task UpdateXp() { try { var reader = _xpGainQueue.Reader; // sum up all gains into a single UserCacheItem var globalToAdd = new Dictionary<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 .UserXpStats .Where(x => x.GuildId == guildId) .OrderByDescending(x => x.Xp + x.AwardedXp) .Skip(page * 9) .Take(9) .ToArrayAsyncLinqToDB(); } public async Task<IReadOnlyCollection<UserXpStats>> GetTopUserXps(ulong guildId, List<ulong> users, int curPage) { await using var uow = _db.GetDbContext(); return await uow.Set<UserXpStats>() .Where(x => x.GuildId == guildId && x.UserId.In(users)) .OrderByDescending(x => x.Xp + x.AwardedXp) .Skip(curPage * 9) .Take(9) .ToArrayAsyncLinqToDB(); } 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_ELLIE await DrawFrame(img, stats.User.UserId); // #endif var outputSize = template.OutputSize; if (outputSize.X != img.Width || outputSize.Y != img.Height) img.Mutate(x => x.Resize(template.OutputSize.X, template.OutputSize.Y)); var 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_ELLIE 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, }