From ab1272b491921dc842833ffb24fee6786aa6ba3f Mon Sep 17 00:00:00 2001 From: Kwoth Date: Tue, 17 May 2022 17:05:10 +0200 Subject: [PATCH] Fixed .crypto sparklines --- CHANGELOG.md | 4 + .../Modules/Searches/Crypto/CryptoCommands.cs | 13 +- .../Modules/Searches/Crypto/CryptoService.cs | 134 +++++++++++++++++- 3 files changed, 141 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7806094a0..c2700b3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog. ## Unreleased +### Fixed + +- Fixed `.crypto` sparklines + ## [4.1.6] - 14.05.2022 ### Fixed diff --git a/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs index fe887f6e7..633b184eb 100644 --- a/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs +++ b/src/NadekoBot/Modules/Searches/Crypto/CryptoCommands.cs @@ -157,18 +157,21 @@ public partial class Searches var marketCap = usd.MarketCap.ToString("C0", localCulture); var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture); + await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0); + var fileName = $"{crypto.Slug}_7d.png"; + var toSend = _eb.Create() .WithOkColor() .WithAuthor($"#{crypto.CmcRank}") .WithTitle($"{crypto.Name} ({crypto.Symbol})") .WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/") - .WithThumbnailUrl( $"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png") + .WithThumbnailUrl($"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png") .AddField(GetText(strs.market_cap), marketCap, true) .AddField(GetText(strs.price), price, true) .AddField(GetText(strs.volume_24h), volume, true) .AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true) .AddField(GetText(strs.market_cap_dominance), dominance, true) - .WithImageUrl($"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{crypto.Id}.png"); + .WithImageUrl($"attachment://{fileName}"); if (crypto.CirculatingSupply is double cs) { @@ -185,9 +188,9 @@ public partial class Searches toSend.AddField(GetText(strs.circulating_supply), csStr, true); } } - - - await ctx.Channel.EmbedAsync(toSend); + + + await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build()); } } } \ No newline at end of file diff --git a/src/NadekoBot/Modules/Searches/Crypto/CryptoService.cs b/src/NadekoBot/Modules/Searches/Crypto/CryptoService.cs index 76b757129..8fdd67ad4 100644 --- a/src/NadekoBot/Modules/Searches/Crypto/CryptoService.cs +++ b/src/NadekoBot/Modules/Searches/Crypto/CryptoService.cs @@ -1,6 +1,13 @@ -#nullable disable +#nullable enable using NadekoBot.Modules.Searches.Common; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Globalization; using System.Net.Http.Json; +using System.Xml; +using Color = SixLabors.ImageSharp.Color; using JsonSerializer = System.Text.Json.JsonSerializer; using StringExtensions = NadekoBot.Extensions.StringExtensions; @@ -21,7 +28,75 @@ public class CryptoService : INService _creds = creds; } - public async Task<(CmcResponseData Data, CmcResponseData Nearest)> GetCryptoData(string name) + private PointF[] GetSparklinePointsFromSvgText(string svgText) + { + var xml = new XmlDocument(); + xml.LoadXml(svgText); + + var gElement = xml["svg"]?["g"]; + if (gElement is null) + return Array.Empty(); + + Span points = new PointF[gElement.ChildNodes.Count]; + var cnt = 0; + + bool GetValuesFromAttributes(XmlAttributeCollection attrs, + out float x1, + out float y1, + out float x2, + out float y2) + { + (x1, y1, x2, y2) = (0, 0, 0, 0); + return attrs["x1"]?.Value is string x1Str + && float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1) + && attrs["y1"]?.Value is string y1Str + && float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1) + && attrs["x2"]?.Value is string x2Str + && float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2) + && attrs["y2"]?.Value is string y2Str + && float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2); + } + + foreach (XmlElement x in gElement.ChildNodes) + { + if (x.Name != "line") + continue; + + if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2)) + { + points[cnt++] = new(x1, y1); + // this point will be set twice to the same value + // on all points except the last one + if(cnt + 1 < points.Length) + points[cnt + 1] = new(x2, y2); + } + } + + if (cnt == 0) + return Array.Empty(); + + return points.Slice(0, cnt).ToArray(); + } + + private SixLabors.ImageSharp.Image GenerateSparklineChart(PointF[] points, bool up) + { + const int width = 164; + const int height = 48; + + var img = new Image(width, height, Color.Transparent); + var color = up + ? Color.Green + : Color.FromRgb(220, 0, 0); + + img.Mutate(x => + { + x.DrawLines(color, 2, points); + }); + + return img; + } + + public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name) { if (string.IsNullOrWhiteSpace(name)) return (null, null); @@ -29,10 +104,10 @@ public class CryptoService : INService name = name.ToUpperInvariant(); var cryptos = await GetCryptoDataInternal(); - if (cryptos is null) + if (cryptos is null or { Count: 0 }) return (null, null); - var crypto = cryptos?.FirstOrDefault(x + var crypto = cryptos.FirstOrDefault(x => x.Slug.ToUpperInvariant() == name || x.Name.ToUpperInvariant() == name || x.Symbol.ToUpperInvariant() == name); @@ -50,7 +125,7 @@ public class CryptoService : INService return (null, nearest.Elem); } - public async Task> GetCryptoDataInternal() + public async Task?> GetCryptoDataInternal() { await _getCryptoLock.WaitAsync(); try @@ -79,6 +154,9 @@ public class CryptoService : INService "", TimeSpan.FromHours(2)); + if (fullStrData is null) + return default; + return JsonSerializer.Deserialize(fullStrData)?.Data ?? new(); } catch (Exception ex) @@ -91,4 +169,50 @@ public class CryptoService : INService _getCryptoLock.Release(); } } + + public async Task GetSparklineAsync(int id, bool up) + { + var key = $"crypto:sparkline:{id}"; + + // attempt to get from cache + var db = _cache.Redis.GetDatabase(); + byte[] bytes = await db.StringGetAsync(key); + // if it succeeds, return it + if (bytes is { Length: > 0 }) + { + return bytes.ToStream(); + } + + // if it fails, generate a new one + var points = await DownloadSparklinePointsAsync(id); + if (points is null) + return default; + + var sparkline = GenerateSparklineChart(points, up); + + // add to cache for 1h and return it + + var stream = sparkline.ToStream(); + await db.StringSetAsync(key, stream.ToArray(), expiry: TimeSpan.FromHours(1)); + return stream; + } + + private async Task DownloadSparklinePointsAsync(int id) + { + try + { + using var http = _httpFactory.CreateClient(); + var str = await http.GetStringAsync( + $"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg"); + var points = GetSparklinePointsFromSvgText(str); + return points; + } + catch(Exception ex) + { + Log.Warning(ex, + "Exception occurred while downloading sparkline points: {ErrorMessage}", + ex.Message); + return default; + } + } } \ No newline at end of file