using Discord; using Discord.WebSocket; using NadekoBot.Common.Collections; using NadekoBot.Extensions; using NadekoBot.Modules.Xp.Common; using NadekoBot.Services; using NadekoBot.Services.Database.Models; using NadekoBot.Services.Impl; using NLog; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using ImageSharp; using Image = ImageSharp.Image; using SixLabors.Fonts; using System.IO; using SixLabors.Primitives; using System.Net.Http; using SixLabors.Shapes; using System.Numerics; using ImageSharp.Drawing.Pens; using ImageSharp.Drawing.Brushes; namespace NadekoBot.Modules.Xp.Services { public class XpService : INService, IUnloadableService { private enum NotifOf { Server, Global } // is it a server level-up or global level-up notification private readonly DbService _db; private readonly CommandHandler _cmd; private readonly IBotConfigProvider _bc; private readonly IImagesService _images; private readonly Logger _log; private readonly NadekoStrings _strings; private readonly IDataCache _cache; private readonly FontCollection _fonts = new FontCollection(); public const int XP_REQUIRED_LVL_1 = 36; private readonly ConcurrentDictionary> _excludedRoles = new ConcurrentDictionary>(); private readonly ConcurrentDictionary> _excludedChannels = new ConcurrentDictionary>(); private readonly ConcurrentHashSet _excludedServers = new ConcurrentHashSet(); private readonly ConcurrentHashSet _rewardedUsers = new ConcurrentHashSet(); private readonly ConcurrentQueue _addMessageXp = new ConcurrentQueue(); private readonly Timer _updateXpTimer; private readonly CancellationTokenSource _clearRewardTimerTokenSource; private readonly Task _clearRewardTimer; private readonly HttpClient http = new HttpClient(); private FontFamily _usernameFontFamily; private FontFamily _clubFontFamily; private Font _levelFont; private Font _xpFont; private Font _awardedFont; private Font _rankFont; private Font _timeFont; public XpService(CommandHandler cmd, IBotConfigProvider bc, NadekoBot bot, IImagesService images, DbService db, NadekoStrings strings, IDataCache cache) { _db = db; _cmd = cmd; _bc = bc; _images = images; _log = LogManager.GetCurrentClassLogger(); _strings = strings; _cache = cache; //load settings var allGuildConfigs = bot.AllGuildConfigs.Where(x => x.XpSettings != null); _excludedChannels = allGuildConfigs .ToDictionary( x => x.GuildId, x => new ConcurrentHashSet(x.XpSettings .ExclusionList .Where(ex => ex.ItemType == ExcludedItemType.Channel) .Select(ex => ex.ItemId) .Distinct())) .ToConcurrent(); _excludedRoles = allGuildConfigs .ToDictionary( x => x.GuildId, x => new ConcurrentHashSet(x.XpSettings .ExclusionList .Where(ex => ex.ItemType == ExcludedItemType.Role) .Select(ex => ex.ItemId) .Distinct())) .ToConcurrent(); _excludedServers = new ConcurrentHashSet( allGuildConfigs.Where(x => x.XpSettings.ServerExcluded) .Select(x => x.GuildId)); //todo 60 move to font provider or somethign _fonts = new FontCollection(); if (Directory.Exists("data/fonts")) foreach (var file in Directory.GetFiles("data/fonts")) { _fonts.Install(file); } InitializeFonts(); _cmd.OnMessageNoTrigger += _cmd_OnMessageNoTrigger; _updateXpTimer = new Timer(async _ => { try { var toNotify = new List<(IMessageChannel MessageChannel, IUser User, int Level, XpNotificationType NotifyType, NotifOf NotifOf)>(); var roleRewards = new Dictionary>(); var toAddTo = new List(); while (_addMessageXp.TryDequeue(out var usr)) toAddTo.Add(usr); var group = toAddTo.GroupBy(x => (GuildId: x.Guild.Id, User: x.User)); if (toAddTo.Count == 0) return; using (var uow = _db.UnitOfWork) { foreach (var item in group) { var xp = item.Select(x => bc.BotConfig.XpPerMessage).Sum(); var usr = uow.Xp.GetOrCreateUser(item.Key.GuildId, item.Key.User.Id); var du = uow.DiscordUsers.GetOrCreate(item.Key.User); if (du.LastXpGain + TimeSpan.FromMinutes(_bc.BotConfig.XpMinutesTimeout) > DateTime.UtcNow) continue; du.LastXpGain = DateTime.UtcNow; var globalXp = du.TotalXp; var oldGlobalLevelData = new LevelStats(globalXp); var newGlobalLevelData = new LevelStats(globalXp + xp); var oldGuildLevelData = new LevelStats(usr.Xp + usr.AwardedXp); usr.Xp += xp; //todo revert du.TotalXp += xp * 1000; if (du.Club != null) du.Club.Xp += xp; var newGuildLevelData = new LevelStats(usr.Xp + usr.AwardedXp); if (oldGlobalLevelData.Level < newGlobalLevelData.Level) { du.LastLevelUp = DateTime.UtcNow; var first = item.First(); if (du.NotifyOnLevelUp != XpNotificationType.None) toNotify.Add((first.Channel, first.User, newGlobalLevelData.Level, du.NotifyOnLevelUp, NotifOf.Global)); } if (oldGuildLevelData.Level < newGuildLevelData.Level) { usr.LastLevelUp = DateTime.UtcNow; //send level up notification var first = item.First(); if (usr.NotifyOnLevelUp != XpNotificationType.None) toNotify.Add((first.Channel, first.User, newGuildLevelData.Level, usr.NotifyOnLevelUp, NotifOf.Server)); //give role if (!roleRewards.TryGetValue(usr.GuildId, out var rewards)) { rewards = uow.GuildConfigs.XpSettingsFor(usr.GuildId).RoleRewards.ToList(); roleRewards.Add(usr.GuildId, rewards); } var rew = rewards.FirstOrDefault(x => x.Level == newGuildLevelData.Level); if (rew != null) { var role = first.User.Guild.GetRole(rew.RoleId); if (role != null) { var __ = first.User.AddRoleAsync(role); } } } } uow.Complete(); } await Task.WhenAll(toNotify.Select(async x => { if (x.NotifOf == NotifOf.Server) { if (x.NotifyType == XpNotificationType.Dm) { var chan = await x.User.GetOrCreateDMChannelAsync().ConfigureAwait(false); if (chan != null) await chan.SendConfirmAsync(_strings.GetText("level_up_dm", (x.MessageChannel as ITextChannel)?.GuildId, "xp", x.User.Mention, Format.Bold(x.Level.ToString()), Format.Bold((x.MessageChannel as ITextChannel)?.Guild.ToString() ?? "-"))) .ConfigureAwait(false); } else // channel { await x.MessageChannel.SendConfirmAsync(_strings.GetText("level_up_channel", (x.MessageChannel as ITextChannel)?.GuildId, "xp", x.User.Mention, Format.Bold(x.Level.ToString()))) .ConfigureAwait(false); } } else { IMessageChannel chan; if (x.NotifyType == XpNotificationType.Dm) { chan = await x.User.GetOrCreateDMChannelAsync().ConfigureAwait(false); } else // channel { chan = x.MessageChannel; } await chan.SendConfirmAsync(_strings.GetText("level_up_global", (x.MessageChannel as ITextChannel)?.GuildId, "xp", x.User.Mention, Format.Bold(x.Level.ToString()))) .ConfigureAwait(false); } })); } catch (Exception ex) { _log.Warn(ex); } }, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); _clearRewardTimerTokenSource = new CancellationTokenSource(); var token = _clearRewardTimerTokenSource.Token; //just a first line, in order to prevent queries. But since other shards can try to do this too, //i'll check in the db too. _clearRewardTimer = Task.Run(async () => { while (!token.IsCancellationRequested) { _rewardedUsers.Clear(); await Task.Delay(TimeSpan.FromMinutes(_bc.BotConfig.XpMinutesTimeout)); } }, token); } public IEnumerable GetRoleRewards(ulong id) { using (var uow = _db.UnitOfWork) { return uow.GuildConfigs.XpSettingsFor(id) .RoleRewards .ToArray(); } } public void SetRoleReward(ulong guildId, int level, ulong? roleId) { using (var uow = _db.UnitOfWork) { var settings = uow.GuildConfigs.XpSettingsFor(guildId); if (roleId == null) { var toRemove = settings.RoleRewards.FirstOrDefault(x => x.Level == level); if (toRemove != null) { uow._context.Remove(toRemove); settings.RoleRewards.Remove(toRemove); } } else { var rew = settings.RoleRewards.FirstOrDefault(x => x.Level == level); if (rew != null) rew.RoleId = roleId.Value; else settings.RoleRewards.Add(new XpRoleReward() { Level = level, RoleId = roleId.Value, }); } uow.Complete(); } } public UserXpStats[] GetUserXps(ulong guildId, int page) { using (var uow = _db.UnitOfWork) { return uow.Xp.GetUsersFor(guildId, page); } } public DiscordUser[] GetUserXps(int page) { using (var uow = _db.UnitOfWork) { return uow.DiscordUsers.GetUsersXpLeaderboardFor(page); } } public async Task ChangeNotificationType(ulong userId, ulong guildId, XpNotificationType type) { using (var uow = _db.UnitOfWork) { var user = uow.Xp.GetOrCreateUser(guildId, userId); user.NotifyOnLevelUp = type; await uow.CompleteAsync().ConfigureAwait(false); } } public async Task ChangeNotificationType(IUser user, XpNotificationType type) { using (var uow = _db.UnitOfWork) { var du = uow.DiscordUsers.GetOrCreate(user); du.NotifyOnLevelUp = type; await uow.CompleteAsync().ConfigureAwait(false); } } private Task _cmd_OnMessageNoTrigger(IUserMessage arg) { if (!(arg.Author is SocketGuildUser user) || user.IsBot) return Task.CompletedTask; var _ = Task.Run(() => { if (_excludedChannels.TryGetValue(user.Guild.Id, out var chans) && chans.Contains(arg.Channel.Id)) return; if (_excludedServers.Contains(user.Guild.Id)) return; if (_excludedRoles.TryGetValue(user.Guild.Id, out var roles) && user.Roles.Any(x => roles.Contains(x.Id))) return; if (!arg.Content.Contains(' ') && arg.Content.Length < 5) return; if (!SetUserRewarded(user.Id)) return; _addMessageXp.Enqueue(new UserCacheItem { Guild = user.Guild, Channel = arg.Channel, User = user }); }); return Task.CompletedTask; } public void AddXp(ulong userId, ulong guildId, int amount) { using (var uow = _db.UnitOfWork) { var usr = uow.Xp.GetOrCreateUser(guildId, userId); usr.AwardedXp += amount; uow.Complete(); } } public bool IsServerExcluded(ulong id) { return _excludedServers.Contains(id); } public IEnumerable GetExcludedRoles(ulong id) { if (_excludedRoles.TryGetValue(id, out var val)) return val.ToArray(); return Enumerable.Empty(); } public IEnumerable GetExcludedChannels(ulong id) { if (_excludedChannels.TryGetValue(id, out var val)) return val.ToArray(); return Enumerable.Empty(); } private bool SetUserRewarded(ulong userId) { return _rewardedUsers.Add(userId); } public FullUserStats GetUserStats(IGuildUser user) { DiscordUser du; UserXpStats stats; int totalXp; int globalRank; int guildRank; using (var uow = _db.UnitOfWork) { du = uow.DiscordUsers.GetOrCreate(user); stats = uow.Xp.GetOrCreateUser(user.GuildId, user.Id); totalXp = du.TotalXp; globalRank = uow.DiscordUsers.GetUserGlobalRanking(user.Id); guildRank = uow.Xp.GetUserGuildRanking(user.Id, user.GuildId); } return new FullUserStats(du, stats, new LevelStats(totalXp), new LevelStats(stats.Xp + stats.AwardedXp), globalRank, guildRank); } public static (int Level, int LevelXp, int LevelRequiredXp) GetLevelData(UserXpStats stats) { var baseXp = XpService.XP_REQUIRED_LVL_1; var required = baseXp; var totalXp = 0; var lvl = 1; while (true) { required = (int)(baseXp + baseXp / 4.0 * (lvl - 1)); if (required + totalXp > stats.Xp) break; totalXp += required; lvl++; } return (lvl - 1, stats.Xp - totalXp, required); } public bool ToggleExcludeServer(ulong id) { using (var uow = _db.UnitOfWork) { var xpSetting = uow.GuildConfigs.XpSettingsFor(id); if (_excludedServers.Add(id)) { xpSetting.ServerExcluded = true; uow.Complete(); return true; } _excludedServers.TryRemove(id); xpSetting.ServerExcluded = false; uow.Complete(); return false; } } public bool ToggleExcludeRole(ulong guildId, ulong rId) { var roles = _excludedRoles.GetOrAdd(guildId, _ => new ConcurrentHashSet()); using (var uow = _db.UnitOfWork) { var xpSetting = uow.GuildConfigs.XpSettingsFor(guildId); var excludeObj = new ExcludedItem { ItemId = rId, ItemType = ExcludedItemType.Role, }; if (roles.Add(rId)) { if (xpSetting.ExclusionList.Add(excludeObj)) { uow.Complete(); } return true; } else { roles.TryRemove(rId); if (xpSetting.ExclusionList.Remove(excludeObj)) { uow.Complete(); } return false; } } } public bool ToggleExcludeChannel(ulong guildId, ulong chId) { var channels = _excludedChannels.GetOrAdd(guildId, _ => new ConcurrentHashSet()); using (var uow = _db.UnitOfWork) { var xpSetting = uow.GuildConfigs.XpSettingsFor(guildId); var excludeObj = new ExcludedItem { ItemId = chId, ItemType = ExcludedItemType.Channel, }; if (channels.Add(chId)) { if (xpSetting.ExclusionList.Add(excludeObj)) { uow.Complete(); } return true; } else { channels.TryRemove(chId); if (xpSetting.ExclusionList.Remove(excludeObj)) { uow.Complete(); } return false; } } } public Task GenerateImageAsync(IGuildUser user) { return GenerateImageAsync(GetUserStats(user)); } private void InitializeFonts() { _usernameFontFamily = _fonts.Find("Whitney-Bold"); _clubFontFamily = _fonts.Find("Whitney-Bold"); _levelFont = _fonts.Find("Whitney-Bold").CreateFont(45); _xpFont = _fonts.Find("Whitney-Bold").CreateFont(50); _awardedFont = _fonts.Find("Whitney-Bold").CreateFont(25); _rankFont = _fonts.Find("Uni Sans Thin CAPS").CreateFont(30); _timeFont = _fonts.Find("Whitney-Bold").CreateFont(20); } public Task GenerateImageAsync(FullUserStats stats) => Task.Run(async () => { using (var img = Image.Load(_images.XpCard.ToArray())) { var username = stats.User.ToString(); var usernameFont = _usernameFontFamily .CreateFont(username.Length <= 6 ? 50 : 50 - username.Length); img.DrawText("@" + username, usernameFont, Rgba32.White, new PointF(130, 5)); // level img.DrawText(stats.Global.Level.ToString(), _levelFont, Rgba32.White, new PointF(47, 137)); img.DrawText(stats.Guild.Level.ToString(), _levelFont, Rgba32.White, new PointF(47, 285)); //club name var clubName = stats.User.Club?.ToString() ?? "-"; var clubFont = _clubFontFamily .CreateFont(clubName.Length <= 8 ? 35 : 35 - (clubName.Length / 2)); img.DrawText(clubName, clubFont, Rgba32.White, new PointF(650 - clubName.Length * 10, 40)); var pen = new Pen(Rgba32.Black, 1); var brush = Brushes.Solid(Rgba32.White); var xpBgBrush = Brushes.Solid(new Rgba32(0, 0, 0, 0.4f)); var global = stats.Global; var guild = stats.Guild; //xp bar img.FillPolygon(xpBgBrush, new[] { new PointF(321, 104), new PointF(321 + (450 * (global.LevelXp / (float)global.RequiredXp)), 104), new PointF(286 + (450 * (global.LevelXp / (float)global.RequiredXp)), 235), new PointF(286, 235), }); img.DrawText($"{global.LevelXp}/{global.RequiredXp}", _xpFont, brush, pen, new PointF(430, 130)); img.FillPolygon(xpBgBrush, new[] { new PointF(282, 248), new PointF(282 + (450 * (guild.LevelXp / (float)guild.RequiredXp)), 248), new PointF(247 + (450 * (guild.LevelXp / (float)guild.RequiredXp)), 379), new PointF(247, 379), }); img.DrawText($"{guild.LevelXp}/{guild.RequiredXp}", _xpFont, brush, pen, new PointF(400, 270)); if (stats.FullGuildStats.AwardedXp != 0) { var sign = stats.FullGuildStats.AwardedXp > 0 ? "+ " : ""; img.DrawText($"({sign}{stats.FullGuildStats.AwardedXp})", _awardedFont, brush, pen, new PointF(445 - (Math.Max(0, (stats.FullGuildStats.AwardedXp.ToString().Length - 2)) * 5), 335)); } //ranking img.DrawText(stats.GlobalRanking.ToString(), _rankFont, Rgba32.White, new PointF(148, 170)); img.DrawText(stats.GuildRanking.ToString(), _rankFont, Rgba32.White, new PointF(148, 317)); //time on this level string GetTimeSpent(DateTime time) { var offset = DateTime.UtcNow - time; return $"{offset.Days}d{offset.Hours}h{offset.Minutes}m"; } img.DrawText(GetTimeSpent(stats.User.LastLevelUp), _timeFont, Rgba32.White, new PointF(50, 197)); img.DrawText(GetTimeSpent(stats.FullGuildStats.LastLevelUp), _timeFont, Rgba32.White, new PointF(50, 344)); //avatar if (stats.User.AvatarId != null) { try { var avatarUrl = stats.User.RealAvatarUrl(); var (succ, data) = await _cache.TryGetImageDataAsync(avatarUrl); if (!succ) { using (var temp = await http.GetStreamAsync(avatarUrl)) using (var tempDraw = Image.Load(temp).Resize(69, 70)) { ApplyRoundedCorners(tempDraw, 35); data = tempDraw.ToStream().ToArray(); } await _cache.SetImageDataAsync(avatarUrl, data); } var toDraw = Image.Load(data); img.DrawImage(toDraw, 1, new Size(69, 70), new Point(32, 10)); } catch (Exception ex) { _log.Warn(ex); } } //club image await DrawClubImage(img, stats).ConfigureAwait(false); return img.Resize(432, 211).ToStream(); } }); private async Task DrawClubImage(Image img, FullUserStats stats) { if (!string.IsNullOrWhiteSpace(stats.User.Club?.ImageUrl)) { var imgUrl = stats.User.Club.ImageUrl; try { var (succ, data) = await _cache.TryGetImageDataAsync(imgUrl); if (!succ) { //todo make sure it's a picture using (var temp = await http.GetAsync(imgUrl, HttpCompletionOption.ResponseHeadersRead)) { if (temp.Content.Headers.ContentType.MediaType != "image/png" && temp.Content.Headers.ContentType.MediaType != "image/jpeg" && temp.Content.Headers.ContentType.MediaType != "image/gif") return; using (var tempDraw = Image.Load(await temp.Content.ReadAsStreamAsync()).Resize(45, 45)) { ApplyRoundedCorners(tempDraw, 22.5f); data = tempDraw.ToStream().ToArray(); } } await _cache.SetImageDataAsync(imgUrl, data); } var toDraw = Image.Load(data); img.DrawImage(toDraw, 1, new Size(45, 45), new Point(722, 25)); } catch (Exception ex) { _log.Warn(ex); } } } // https://github.com/SixLabors/ImageSharp/tree/master/samples/AvatarWithRoundedCorner public static void ApplyRoundedCorners(Image img, float cornerRadius) { var corners = BuildCorners(img.Width, img.Height, cornerRadius); // now we have our corners time to draw them img.Fill(Rgba32.Transparent, corners, new GraphicsOptions(true) { BlenderMode = ImageSharp.PixelFormats.PixelBlenderMode.Src // enforces that any part of this shape that has color is punched out of the background }); } public static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius) { // first create a square var rect = new RectangularePolygon(-0.5f, -0.5f, cornerRadius, cornerRadius); // then cut out of the square a circle so we are left with a corner var cornerToptLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius)); // corner is now a corner shape positions top left //lets make 3 more positioned correctly, we can do that by translating the orgional around the center of the image var center = new Vector2(imageWidth / 2, imageHeight / 2); float rightPos = imageWidth - cornerToptLeft.Bounds.Width + 1; float bottomPos = imageHeight - cornerToptLeft.Bounds.Height + 1; // move it across the width of the image - the width of the shape var cornerTopRight = cornerToptLeft.RotateDegree(90).Translate(rightPos, 0); var cornerBottomLeft = cornerToptLeft.RotateDegree(-90).Translate(0, bottomPos); var cornerBottomRight = cornerToptLeft.RotateDegree(180).Translate(rightPos, bottomPos); return new PathCollection(cornerToptLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight); } public Task Unload() { _cmd.OnMessageNoTrigger -= _cmd_OnMessageNoTrigger; if (!_clearRewardTimerTokenSource.IsCancellationRequested) _clearRewardTimerTokenSource.Cancel(); _updateXpTimer.Change(Timeout.Infinite, Timeout.Infinite); _clearRewardTimerTokenSource.Dispose(); return Task.CompletedTask; } } }