diff --git a/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs b/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs new file mode 100644 index 0000000..4ecffe7 --- /dev/null +++ b/src/EllieBot/Modules/Xp/Db/UserXpBatch.cs @@ -0,0 +1,14 @@ +#nullable disable warnings +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Modules.Xp.Services; + +public sealed class UserXpBatch +{ + [Key] + public ulong UserId { get; set; } + + public ulong GuildId { get; set; } + public string Username { get; set; } = string.Empty; + public string AvatarId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs b/src/EllieBot/Modules/Xp/Db/XpShopOwnedItem.cs similarity index 100% rename from src/EllieBot/Modules/Xp/_common/db/XpShopOwnedItem.cs rename to src/EllieBot/Modules/Xp/Db/XpShopOwnedItem.cs diff --git a/src/EllieBot/Modules/Xp/Xp.cs b/src/EllieBot/Modules/Xp/Xp.cs index eee259c..3e6801a 100644 --- a/src/EllieBot/Modules/Xp/Xp.cs +++ b/src/EllieBot/Modules/Xp/Xp.cs @@ -31,11 +31,13 @@ public partial class Xp : EllieModule<XpService> private readonly DownloadTracker _tracker; private readonly ICurrencyProvider _gss; + private readonly XpTemplateService _templateService; - public Xp(DownloadTracker tracker, ICurrencyProvider gss) + public Xp(DownloadTracker tracker, ICurrencyProvider gss, XpTemplateService templateService) { _tracker = tracker; _gss = gss; + _templateService = templateService; } [Cmd] @@ -325,7 +327,7 @@ public partial class Xp : EllieModule<XpService> [OwnerOnly] public async Task XpTemplateReload() { - _service.ReloadXpTemplate(); + _templateService.ReloadXpTemplate(); await Task.Delay(1000); await Response().Confirm(strs.template_reloaded).SendAsync(); } diff --git a/src/EllieBot/Modules/Xp/XpConfig.cs b/src/EllieBot/Modules/Xp/XpConfig.cs index 7b973e4..cb0e120 100644 --- a/src/EllieBot/Modules/Xp/XpConfig.cs +++ b/src/EllieBot/Modules/Xp/XpConfig.cs @@ -10,25 +10,19 @@ namespace EllieBot.Modules.Xp; public sealed partial class XpConfig : ICloneable<XpConfig> { [Comment("""DO NOT CHANGE""")] - public int Version { get; set; } = 7; + public int Version { get; set; } = 10; [Comment("""How much XP will the users receive per message""")] - public int XpPerMessage { get; set; } = 3; + public int TextXpPerMessage { get; set; } = 3; [Comment("""How often can the users receive XP, in seconds""")] - public float MessageXpCooldown { get; set; } = 300; + public int TextXpCooldown { get; set; } = 300; [Comment("""Amount of xp users gain from posting an image""")] - public int XpFromImage { get; set; } = 0; + public int TextXpFromImage { get; set; } = 3; [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; + public int VoiceXpPerMinute { get; set; } = 3; [Comment("""Xp Shop config""")] public ShopConfig Shop { get; set; } = new(); @@ -36,44 +30,44 @@ public sealed partial class XpConfig : ICloneable<XpConfig> 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 - """)] + 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 - """)] + 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 - """)] + 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 - """)] + 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}} + { "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 - """)] + 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}} + { "default", new() { Name = "Default Background", Price = 0, Url = string.Empty } } }; } @@ -81,16 +75,17 @@ public sealed partial class XpConfig : ICloneable<XpConfig> { [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""")] + + [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; } } diff --git a/src/EllieBot/Modules/Xp/XpConfigService.cs b/src/EllieBot/Modules/Xp/XpConfigService.cs index bfb46c7..1f1eb3d 100644 --- a/src/EllieBot/Modules/Xp/XpConfigService.cs +++ b/src/EllieBot/Modules/Xp/XpConfigService.cs @@ -15,24 +15,29 @@ public sealed class XpConfigService : ConfigServiceBase<XpConfig> : base(FILE_PATH, serializer, pubSub, _changeKey) { AddParsedProp("txt.cooldown", - conf => conf.MessageXpCooldown, - float.TryParse, + conf => conf.TextXpCooldown, + int.TryParse, (f) => f.ToString("F2"), 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, + AddParsedProp("txt.permsg", + conf => conf.TextXpPerMessage, + int.TryParse, ConfigPrinters.ToString, x => x >= 0); - AddParsedProp("voice.max_minutes", - conf => conf.VoiceMaxMinutes, + + AddParsedProp("txt.perimage", + conf => conf.TextXpFromImage, int.TryParse, ConfigPrinters.ToString, x => x > 0); + AddParsedProp("voice.perminute", + conf => conf.VoiceXpPerMinute, + int.TryParse, + ConfigPrinters.ToString, + x => x >= 0); + AddParsedProp("shop.is_enabled", conf => conf.Shop.IsEnabled, bool.TryParse, @@ -43,21 +48,11 @@ public sealed class XpConfigService : ConfigServiceBase<XpConfig> private void Migrate() { - if (data.Version < 2) + if (data.Version < 10) { ModifyConfig(c => { - c.Version = 2; - c.XpFromImage = 0; - }); - } - - if (data.Version < 7) - { - ModifyConfig(c => - { - c.Version = 7; - c.MessageXpCooldown *= 60; + c.Version = 10; }); } } diff --git a/src/EllieBot/Modules/Xp/XpService.cs b/src/EllieBot/Modules/Xp/XpService.cs index 54884c3..17072ad 100644 --- a/src/EllieBot/Modules/Xp/XpService.cs +++ b/src/EllieBot/Modules/Xp/XpService.cs @@ -1,5 +1,4 @@ #nullable disable warnings -using System.ComponentModel.DataAnnotations; using LinqToDB; using Microsoft.EntityFrameworkCore; using EllieBot.Common.ModuleBehaviors; @@ -11,12 +10,13 @@ using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -using System.Threading.Channels; using LinqToDB.Data; using LinqToDB.EntityFrameworkCore; using LinqToDB.Tools; +using Microsoft.Extensions.Caching.Memory; using EllieBot.Modules.Administration; using EllieBot.Modules.Patronage; +using SixLabors.ImageSharp.Formats.Png; using ArgumentOutOfRangeException = System.ArgumentOutOfRangeException; using Color = SixLabors.ImageSharp.Color; using Exception = System.Exception; @@ -42,12 +42,11 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand private XpTemplate _template = new(); private readonly DiscordSocketClient _client; - private readonly TypedKey<bool> _xpTemplateReloadKey; private readonly IPatronageService _ps; private readonly IBotCache _c; - private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>(); private readonly INotifySubscriber _notifySub; + private readonly IMemoryCache _memCache; private readonly ShardData _shardData; private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 100); @@ -65,6 +64,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand IPubSub pubSub, IPatronageService ps, INotifySubscriber notifySub, + IMemoryCache memCache, ShardData shardData) { _db = db; @@ -76,44 +76,22 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand _xpConfig = xpConfig; _pubSub = pubSub; _notifySub = notifySub; + _memCache = memCache; _shardData = shardData; - _excludedServers = new(); + _excludedServers = []; _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 -#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() { // initialize ignored - ArgumentOutOfRangeException.ThrowIfLessThan(_xpConfig.Data.MessageXpCooldown, - 1, - nameof(_xpConfig.Data.MessageXpCooldown)); - await using (var ctx = _db.GetDbContext()) { var xps = await ctx.GetTable<XpSettings>() @@ -136,7 +114,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } } - await Task.WhenAll(UpdateTimer(), _levelUpQueue.RunAsync()); + await Task.WhenAll(UpdateTimer(), VoiceUpdateTimer(), _levelUpQueue.RunAsync()); return; @@ -158,7 +136,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand async Task UpdateTimer() { - // todo a bigger loop that runs once every XpTimer using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); while (await timer.WaitForNextTickAsync()) { @@ -178,37 +155,58 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand /// <summary> /// The current batch of users that will gain xp /// </summary> - private readonly ConcurrentHashSet<IGuildUser> _usersBatch = new(); + private readonly ConcurrentHashSet<IGuildUser> _usersBatch = []; - private readonly ConcurrentHashSet<IGuildUser> _voiceXPBatch = new(); + /// <summary> + /// The current batch of users that will gain voice xp + /// </summary> + private readonly ConcurrentHashSet<IGuildUser> _voiceXpBatch = []; private async Task UpdateVoiceXp() { - var xpAmount = (int)_xpConfig.Data.VoiceXpPerMinute; - var oldBatch = _voiceXPBatch.ToArray(); - _voiceXPBatch.Clear(); + var xpAmount = _xpConfig.Data.VoiceXpPerMinute; + + if (xpAmount <= 0) + return; + + var oldBatch = _voiceXpBatch.ToArray(); + _voiceXpBatch.Clear(); var validUsers = new HashSet<IGuildUser>(); var guilds = _client.Guilds; foreach (var g in guilds) - foreach (var vc in g.VoiceChannels) - foreach (var u in vc.ConnectedUsers) - if (!u.IsMuted && !u.IsDeafened - && vc.ConnectedUsers.Count(x => !x.IsBot) > 1) - { - if (oldBatch.Contains(u)) - validUsers.Add(u); + { + if (IsServerExcluded(g.Id)) + continue; - _voiceXPBatch.Add(u); - } + foreach (var vc in g.VoiceChannels) + { + if (!IsVoiceChannelActive(vc)) + continue; + + if (IsChannelExcluded(vc)) + continue; + + foreach (var u in vc.ConnectedUsers) + { + if (IsServerOrRoleExcluded(u) || !UserParticipatingInVoiceChannel(u)) + continue; + + if (oldBatch.Contains(u)) + validUsers.Add(u); + + _voiceXpBatch.Add(u); + } + } + } await UpdateXpInternalAsync(validUsers.DistinctBy(x => x.Id).ToArray(), xpAmount); } private async Task UpdateXp() { - var xpAmount = _xpConfig.Data.XpPerMessage; + var xpAmount = _xpConfig.Data.TextXpPerMessage; var currentBatch = _usersBatch.ToArray(); _usersBatch.Clear(); @@ -223,7 +221,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand await using var ctx = _db.GetDbContext(); await using var lctx = ctx.CreateLinqToDBConnection(); - var tempTableName = "xp_batch_" + _shardData.ShardId; + var tempTableName = "xptemp_" + Guid.NewGuid().ToString().Replace("-", string.Empty); await using var batchTable = await lctx.CreateTempTableAsync<UserXpBatch>(tempTableName); await batchTable.BulkCopyAsync(currentBatch.Select(x => new UserXpBatch() @@ -245,48 +243,31 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand Xp = UserXpStats.Xp + EXCLUDED.Xp; """); - await lctx.ExecuteAsync( - $""" - INSERT INTO DiscordUser (UserId, AvatarId, Username, TotalXp) - SELECT "{tempTableName}"."UserId", "{tempTableName}"."AvatarId", "{tempTableName}"."Username", {xpAmount} - FROM {tempTableName} - WHERE TRUE - ON CONFLICT (UserId) DO UPDATE - SET - Username = EXCLUDED.Username, - AvatarId = EXCLUDED.AvatarId, - TotalXp = DiscordUser.TotalXp + {xpAmount}; - """); - foreach (var (guildId, users) in currentBatch.GroupBy(x => x.GuildId) - .ToDictionary(x => x.Key, x => x.AsEnumerable())) + var updated = await batchTable + .InnerJoin(lctx.GetTable<UserXpStats>(), + (u, s) => u.GuildId == s.GuildId && u.UserId == s.UserId, + (batch, stats) => stats) + .ToListAsyncLinqToDB(); + + foreach (var u in updated) { - var userIds = users.Select(x => x.Id).ToArray(); + var oldStats = new LevelStats(u.Xp - xpAmount); + var newStats = new LevelStats(u.Xp); - var dbStats = await ctx.GetTable<UserXpStats>() - .Where(x => x.GuildId == guildId && userIds.Contains(x.UserId)) - .OrderByDescending(x => x.Xp) - .ToArrayAsyncLinqToDB(); + Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}", + u.UserId, + oldStats.TotalXp, + newStats.TotalXp); - for (var i = 0; i < dbStats.Length; i++) + if (oldStats.Level < newStats.Level) { - var oldStats = new LevelStats(dbStats[i].Xp - xpAmount); - var newStats = new LevelStats(dbStats[i].Xp); - - Log.Information("User {User} xp updated from {OldLevel} to {NewLevel}", - dbStats[i].UserId, - oldStats.TotalXp, - newStats.TotalXp); - - if (oldStats.Level < newStats.Level) - { - await _levelUpQueue.EnqueueAsync(NotifyUser(guildId, - 0, - dbStats[i].UserId, - true, - oldStats.Level, - newStats.Level)); - } + await _levelUpQueue.EnqueueAsync(NotifyUser(u.GuildId, + 0, + u.UserId, + true, + oldStats.Level, + newStats.Level)); } } } @@ -410,50 +391,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } } - 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 async Task SetCurrencyReward(ulong guildId, int level, int amount) { await using var uow = _db.GetDbContext(); @@ -484,7 +421,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } } - uow.SaveChanges(); + await uow.SaveChangesAsync(); } public async Task<XpSettings> GetFullXpSettingsFor(ulong guildId) @@ -586,77 +523,44 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand .ToArrayAsyncLinqToDB(); } - private Task Client_OnGuildAvailable(SocketGuild guild) + private bool IsVoiceChannelActive(SocketVoiceChannel channel) { - Task.Run(async () => + var count = 0; + foreach (var user in channel.ConnectedUsers) { - foreach (var channel in guild.VoiceChannels) - await ScanChannelForVoiceXp(channel); - }); - - return Task.CompletedTask; - } - - private async Task Client_OnUserVoiceStateUpdated(SocketUser socketUser, SocketVoiceState before, - SocketVoiceState after) - { - if (socketUser is not SocketGuildUser user || user.IsBot) - return; - - if (after.VoiceChannel is not null) - { - await ScanChannelForVoiceXp(after.VoiceChannel); - } - } - - private async Task ScanChannelForVoiceXp(SocketVoiceChannel channel) - { - if (ShouldTrackVoiceChannel(channel)) - { - foreach (var user in channel.ConnectedUsers) + if (UserParticipatingInVoiceChannel(user)) { - if (UserParticipatingInVoiceChannel(user) && ShouldTrackXp(user, channel)) - await UserJoinedVoiceChannel(user); + count++; + if (count >= 2) + return true; } } + + return false; } - - private bool ShouldTrackVoiceChannel(SocketVoiceChannel channel) - => channel.ConnectedUsers.Where(UserParticipatingInVoiceChannel).Take(2).Count() >= 2; - - private bool UserParticipatingInVoiceChannel(SocketGuildUser user) + private static 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) + private bool IsServerOrRoleExcluded(SocketGuildUser user) { - var value = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - - await _c.AddAsync(GetVoiceXpKey(user.Id), - value, - TimeSpan.FromMinutes(1), - overwrite: true); - } - - 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; + return true; if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id))) - return false; + return true; - return true; + return false; + } + + private bool IsChannelExcluded(IGuildChannel channel) + { + if (_excludedChannels.TryGetValue(channel.Guild.Id, out var chans) + && (chans.Contains(channel.Id) + || (channel is SocketThreadChannel tc && chans.Contains(tc.ParentChannel.Id)))) + return true; + + return false; } public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage arg) @@ -664,27 +568,34 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand if (arg.Author is not SocketGuildUser user || user.IsBot) return Task.CompletedTask; + if (arg.Channel is not IGuildChannel gc) + return Task.CompletedTask; + _ = Task.Run(async () => { - if (!ShouldTrackXp(user, arg.Channel)) + if (IsChannelExcluded(gc)) + return; + + if (IsServerOrRoleExcluded(user)) return; var xpConf = _xpConfig.Data; var xp = 0; if (arg.Attachments.Any(a => a.Height >= 128 && a.Width >= 128)) - xp = xpConf.XpFromImage; + xp = xpConf.TextXpFromImage; if (arg.Content.Contains(' ') || arg.Content.Length >= 5) - xp = Math.Max(xp, xpConf.XpPerMessage); + xp = Math.Max(xp, xpConf.TextXpPerMessage); if (xp <= 0) return; - if (!await SetUserRewardedAsync(user.Id)) + if (!await TryAddUserGainedXpAsync(user.Id, xpConf.TextXpCooldown)) return; _usersBatch.Add(user); }); + return Task.CompletedTask; } @@ -706,7 +617,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand usr.Xp += amount; - uow.SaveChanges(); + await uow.SaveChangesAsync(); } public bool IsServerExcluded(ulong id) @@ -717,7 +628,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand if (_excludedRoles.TryGetValue(id, out var val)) return val.ToArray(); - return Enumerable.Empty<ulong>(); + return []; } public IEnumerable<ulong> GetExcludedChannels(ulong id) @@ -725,17 +636,24 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand if (_excludedChannels.TryGetValue(id, out var val)) return val.ToArray(); - return Enumerable.Empty<ulong>(); + return []; } - private TypedKey<bool> GetUserRewKey(ulong userId) - => new($"xp:{_client.CurrentUser.Id}:user_gain:{userId}"); + private async Task<bool> TryAddUserGainedXpAsync(ulong userId, int cdInSeconds) + { + if (cdInSeconds <= 0) + return true; - private async Task<bool> SetUserRewardedAsync(ulong userId) - => await _c.AddAsync(GetUserRewKey(userId), - true, - expiry: TimeSpan.FromSeconds(_xpConfig.Data.MessageXpCooldown), - overwrite: false); + if (_memCache.TryGetValue(userId, out _)) + return false; + + using var entry = _memCache.CreateEntry(userId); + entry.Value = true; + + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(cdInSeconds); + + return true; + } public async Task<FullUserStats> GetUserStatsAsync(IGuildUser user) { @@ -774,7 +692,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public async Task<bool> ToggleExcludeRoleAsync(ulong guildId, ulong rId) { - var roles = _excludedRoles.GetOrAdd(guildId, _ => new()); + var roles = _excludedRoles.GetOrAdd(guildId, _ => []); await using var uow = _db.GetDbContext(); var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList)); var excludeObj = new ExcludedItem @@ -805,7 +723,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public async Task<bool> ToggleExcludeChannelAsync(ulong guildId, ulong chId) { - var channels = _excludedChannels.GetOrAdd(guildId, _ => new()); + var channels = _excludedChannels.GetOrAdd(guildId, _ => []); await using var uow = _db.GetDbContext(); var xpSetting = await uow.XpSettingsFor(guildId, set => set.LoadWith(x => x.ExclusionList)); var excludeObj = new ExcludedItem @@ -837,6 +755,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } + private int _lastKnownTemplateHashCode; + public Task<(Stream Image, IImageFormat Format)> GenerateXpImageAsync(FullUserStats stats) => Task.Run(async () => { @@ -848,9 +768,39 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand throw new ArgumentNullException(nameof(bgBytes)); } + var avatarUrl = stats.User.RealAvatarUrl(); + byte[] avatarImageData = null; + + if (avatarUrl is not null) + { + var result = await _c.GetImageDataAsync(avatarUrl); + if (!result.TryPickT0(out avatarImageData, 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()) + { + avatarImageData = stream.ToArray(); + } + } + } + + await _c.SetImageDataAsync(avatarUrl, avatarImageData); + } + } + 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); @@ -874,225 +824,194 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand "@" + 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 + //club name - 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)) + if (_template.Club.Name.Show) { - 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)); - } + var clubName = stats.User.Club?.ToString() ?? "-"; - 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)); - } + var clubFont = _fonts.NotoSans.CreateFont(_template.Club.Name.FontSize, FontStyle.Regular); - 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 _)) + x.DrawText(new RichTextOptions(clubFont) { - 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)); + 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); } - } - catch (Exception ex) - { - Log.Warning(ex, "Error drawing avatar image"); - } + + 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); + + + 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); + + + 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) + { + 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) + { + 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); + } + + 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); + + 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); + + 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 + ); + } + + if (_template.User.Icon.Show) + { + try + { + using var toDraw = Image.Load(avatarImageData); + 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)); + + 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) @@ -1122,7 +1041,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand return await _images.GetXpBackgroundImageAsync(); } - // #if GLOBAL_ELLIE private async Task DrawFrame(Image<Rgba32> img, ulong userId) { var patron = await _ps.GetPatronAsync(userId); @@ -1150,7 +1068,6 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand 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) { @@ -1164,34 +1081,18 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand float x3, x4, y3, y4; - if (info.Direction == XpTemplateDirection.Down) + var matrix = info.Direction switch { - 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; - } + XpTemplateDirection.Down => new float[,] { { 0, 1 }, { 0, 1 } }, + XpTemplateDirection.Up => new float[,] { { 0, -1 }, { 0, -1 } }, + XpTemplateDirection.Left => new float[,] { { -1, 0 }, { -1, 0 } }, + _ => new float[,] { { 1, 0 }, { 1, 0 } }, + }; + + x3 = x1 + matrix[0, 0] * length; + x4 = x2 + matrix[1, 0] * length; + y3 = y1 + matrix[0, 1] * length; + y4 = y2 + matrix[1, 1] * length; img.Mutate(x => x.FillPolygon(info.Color, new PointF(x1, y1), @@ -1278,7 +1179,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand 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)); + 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() @@ -1287,7 +1189,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand 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)); + 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) @@ -1321,7 +1224,8 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand 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}"))) + if (item.Price > 0 && + !await _cs.RemoveAsync(userId, item.Price, new("xpshop", "buy", $"Background {key}"))) return BuyResult.InsufficientFunds; @@ -1445,7 +1349,7 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand public bool IsShopEnabled() => _xpConfig.Data.Shop.IsEnabled; - public async Task<int> GetTotalGuildUsers(ulong requestGuildId, List<ulong>? guildUsers = null) + public async Task<int> GetGuildXpUsersCountAsync(ulong requestGuildId, List<ulong>? guildUsers = null) { await using var ctx = _db.GetDbContext(); return await ctx.GetTable<UserXpStats>() @@ -1478,12 +1382,64 @@ public class XpService : IEService, IReadyExecutor, IExecNoCommand } } -public sealed class UserXpBatch +public sealed class XpTemplateService : IEService, IReadyExecutor { - [Key] - public ulong UserId { get; set; } + private const string XP_TEMPLATE_PATH = "./data/xp_template.json"; - public ulong GuildId { get; set; } - public string Username { get; set; } = string.Empty; - public string AvatarId { get; set; } = string.Empty; + private readonly IPubSub _pubSub; + private XpTemplate _template = new(); + private readonly TypedKey<bool> _xpTemplateReloadKey = new("xp.template.reload"); + + public XpTemplateService(IPubSub pubSub) + { + _pubSub = pubSub; + } + + private void InternalReloadXpTemplate() + { + try + { + var settings = new JsonSerializerSettings + { + ContractResolver = new RequireObjectPropertiesContractResolver() + }; + + if (!File.Exists(XP_TEMPLATE_PATH)) + { + var newTemp = new XpTemplate(); + newTemp.Version = 2; + File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(newTemp, Formatting.Indented)); + } + + _template = JsonConvert.DeserializeObject<XpTemplate>( + File.ReadAllText(XP_TEMPLATE_PATH), + settings)!; + } + catch (Exception ex) + { + Log.Error(ex, "xp_template.json is invalid. Loaded default values"); + _template = new(); + } + } + + public void ReloadXpTemplate() + => _pubSub.Pub(_xpTemplateReloadKey, true); + + public async Task OnReadyAsync() + { + InternalReloadXpTemplate(); + await _pubSub.Sub(_xpTemplateReloadKey, + _ => + { + InternalReloadXpTemplate(); + return default; + }); + } + + public XpTemplate GetTemplate() + => _template; +} + +public sealed class ReadyXpTempalte(XpTemplate template) +{ } \ No newline at end of file diff --git a/src/EllieBot/Modules/Xp/_common/FullUserStats.cs b/src/EllieBot/Modules/Xp/common/FullUserStats.cs similarity index 100% rename from src/EllieBot/Modules/Xp/_common/FullUserStats.cs rename to src/EllieBot/Modules/Xp/common/FullUserStats.cs diff --git a/src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs b/src/EllieBot/Modules/Xp/common/IXpCleanupService.cs similarity index 100% rename from src/EllieBot/Modules/Xp/_common/IXpCleanupService.cs rename to src/EllieBot/Modules/Xp/common/IXpCleanupService.cs diff --git a/src/EllieBot/Modules/Xp/_common/UserCacheItem.cs b/src/EllieBot/Modules/Xp/common/UserCacheItem.cs similarity index 100% rename from src/EllieBot/Modules/Xp/_common/UserCacheItem.cs rename to src/EllieBot/Modules/Xp/common/UserCacheItem.cs diff --git a/src/EllieBot/Modules/Xp/_common/XpCleanupService.cs b/src/EllieBot/Modules/Xp/common/XpCleanupService.cs similarity index 100% rename from src/EllieBot/Modules/Xp/_common/XpCleanupService.cs rename to src/EllieBot/Modules/Xp/common/XpCleanupService.cs diff --git a/src/EllieBot/Modules/Xp/_common/XpTemplate.cs b/src/EllieBot/Modules/Xp/common/XpTemplate.cs similarity index 99% rename from src/EllieBot/Modules/Xp/_common/XpTemplate.cs rename to src/EllieBot/Modules/Xp/common/XpTemplate.cs index 020ad7f..077393f 100644 --- a/src/EllieBot/Modules/Xp/_common/XpTemplate.cs +++ b/src/EllieBot/Modules/Xp/common/XpTemplate.cs @@ -7,8 +7,8 @@ namespace EllieBot.Modules.Xp; public class XpTemplate { - public int Version { get; set; } = 0; - + public int Version { get; set; } = 2; + [JsonProperty("output_size")] public XpTemplatePos OutputSize { get; set; } = new() { diff --git a/src/EllieBot/Services/GrpcApi/XpSvc.cs b/src/EllieBot/Services/GrpcApi/XpSvc.cs index c403340..3cf9167 100644 --- a/src/EllieBot/Services/GrpcApi/XpSvc.cs +++ b/src/EllieBot/Services/GrpcApi/XpSvc.cs @@ -217,7 +217,7 @@ public class XpSvc : GrpcXp.GrpcXpBase, IGrpcSvc, IEService throw new RpcException(new Status(StatusCode.NotFound, "Guild not found")); var data = await _xp.GetGuildUserXps(request.GuildId, request.Page - 1); - var total = await _xp.GetTotalGuildUsers(request.GuildId); + var total = await _xp.GetGuildXpUsersCountAsync(request.GuildId); var reply = new GetXpLbReply { diff --git a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs index 2368ac2..f80a86b 100644 --- a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs +++ b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs @@ -9,12 +9,12 @@ namespace Ellie.Common; public sealed class MemoryBotCache : IBotCache { // needed for overwrites and Delete return value - private readonly object _cacheLock = new object(); + private readonly ConcurrentDictionary<string, object> _locks = new(); private readonly MemoryCache _cache; public MemoryBotCache() { - _cache = new MemoryCache(new MemoryCacheOptions()); + _cache = new(new MemoryCacheOptions()); } public ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true) @@ -26,12 +26,14 @@ public sealed class MemoryBotCache : IBotCache item.AbsoluteExpirationRelativeToNow = expiry; return new(true); } - - lock (_cacheLock) + + var cacheLock = _locks.GetOrAdd(key.Key, static _ => new()); + + lock (cacheLock) { if (_cache.TryGetValue(key.Key, out var old) && old is not null) return new(false); - + using var item = _cache.CreateEntry(key.Key); item.Value = value; item.AbsoluteExpirationRelativeToNow = expiry; @@ -61,9 +63,10 @@ public sealed class MemoryBotCache : IBotCache public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key) { - lock (_cacheLock) + var cacheLock = _locks.GetOrAdd(key.Key, static _ => new()); + lock (cacheLock) { - var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null; + var toReturn = _cache.TryGetValue(key.Key, out var old) && old is not null; _cache.Remove(key.Key); return new(toReturn); } diff --git a/src/EllieBot/data/xp.yml b/src/EllieBot/data/xp.yml index e32fe62..8188f2c 100644 --- a/src/EllieBot/data/xp.yml +++ b/src/EllieBot/data/xp.yml @@ -1,17 +1,13 @@ # DO NOT CHANGE -version: 7 +version: 10 # How much XP will the users receive per message -xpPerMessage: 3 +textXpPerMessage: 3 # How often can the users receive XP, in seconds -messageXpCooldown: 300 +textXpCooldown: 300 # Amount of xp users gain from posting an image -xpFromImage: 0 +textXpFromImage: 3 # Average amount of xp earned per minute in VC voiceXpPerMinute: 0 -# The maximum amount of minutes the bot will keep track of a user in a voice channel -voiceMaxMinutes: 720 -# The amount of currency users will receive for each point of global xp that they earn -currencyPerXp: 0 # Xp Shop config shop: # Whether the xp shop is enabled diff --git a/src/EllieBot/data/xp_template.json b/src/EllieBot/data/xp_template.json index df7f289..5ebe91b 100644 --- a/src/EllieBot/data/xp_template.json +++ b/src/EllieBot/data/xp_template.json @@ -1,5 +1,5 @@ { - "Version": 1, + "Version": 2, "output_size": { "X": 800, "Y": 392