forked from EllieBotDevs/elliebot
266 lines
No EOL
8.5 KiB
C#
266 lines
No EOL
8.5 KiB
C#
#nullable enable
|
|
using EllieBot.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.Text.Json.Serialization;
|
|
using System.Xml;
|
|
using Color = SixLabors.ImageSharp.Color;
|
|
|
|
namespace EllieBot.Modules.Searches.Services;
|
|
|
|
public class CryptoService : IEService
|
|
{
|
|
private readonly IBotCache _cache;
|
|
private readonly IHttpClientFactory _httpFactory;
|
|
private readonly IBotCredentials _creds;
|
|
|
|
private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
|
|
|
|
public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
|
|
{
|
|
_cache = cache;
|
|
_httpFactory = httpFactory;
|
|
_creds = creds;
|
|
}
|
|
|
|
private PointF[] GetSparklinePointsFromSvgText(string svgText)
|
|
{
|
|
var xml = new XmlDocument();
|
|
xml.LoadXml(svgText);
|
|
|
|
var gElement = xml["svg"]?["g"];
|
|
if (gElement is null)
|
|
return Array.Empty<PointF>();
|
|
|
|
Span<PointF> 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<PointF>();
|
|
|
|
return points.Slice(0, cnt).ToArray();
|
|
}
|
|
|
|
private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
|
|
{
|
|
const int width = 164;
|
|
const int height = 48;
|
|
|
|
var img = new Image<Rgba32>(width, height, Color.Transparent);
|
|
var color = up
|
|
? Color.Green
|
|
: Color.FromRgb(220, 0, 0);
|
|
|
|
img.Mutate(x =>
|
|
{
|
|
x.DrawLine(color, 2, points);
|
|
});
|
|
|
|
return img;
|
|
}
|
|
|
|
public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return (null, null);
|
|
|
|
name = name.ToUpperInvariant();
|
|
var cryptos = await GetCryptoDataInternal();
|
|
|
|
if (cryptos is null or { Count: 0 })
|
|
return (null, null);
|
|
|
|
var crypto = cryptos.FirstOrDefault(x
|
|
=> x.Slug.ToUpperInvariant() == name
|
|
|| x.Name.ToUpperInvariant() == name
|
|
|| x.Symbol.ToUpperInvariant() == name);
|
|
|
|
if (crypto is not null)
|
|
return (crypto, null);
|
|
|
|
|
|
var nearest = cryptos
|
|
.Select(elem => (Elem: elem,
|
|
Distance: elem.Name.ToUpperInvariant().LevenshteinDistance(name)))
|
|
.OrderBy(x => x.Distance)
|
|
.FirstOrDefault(x => x.Distance <= 2);
|
|
|
|
return (null, nearest.Elem);
|
|
}
|
|
|
|
public async Task<List<CmcResponseData>?> GetCryptoDataInternal()
|
|
{
|
|
await _getCryptoLock.WaitAsync();
|
|
try
|
|
{
|
|
var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"),
|
|
async () =>
|
|
{
|
|
try
|
|
{
|
|
using var http = _httpFactory.CreateClient();
|
|
var data = await http.GetFromJsonAsync<CryptoResponse>(
|
|
"https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
|
|
+ $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
|
|
+ "&start=1"
|
|
+ "&limit=5000"
|
|
+ "&convert=USD");
|
|
|
|
return data;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Error getting crypto data: {Message}", ex.Message);
|
|
return default;
|
|
}
|
|
},
|
|
TimeSpan.FromHours(2));
|
|
|
|
if (data is null)
|
|
return default;
|
|
|
|
return data.Data;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message);
|
|
return default;
|
|
}
|
|
finally
|
|
{
|
|
_getCryptoLock.Release();
|
|
}
|
|
}
|
|
|
|
private TypedKey<byte[]> GetSparklineKey(int id)
|
|
=> new($"crypto:sparkline:{id}");
|
|
|
|
public async Task<Stream?> GetSparklineAsync(int id, bool up)
|
|
{
|
|
try
|
|
{
|
|
var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
|
|
async () =>
|
|
{
|
|
// if it fails, generate a new one
|
|
var points = await DownloadSparklinePointsAsync(id);
|
|
var sparkline = GenerateSparklineChart(points, up);
|
|
|
|
using var stream = await sparkline.ToStreamAsync();
|
|
return stream.ToArray();
|
|
},
|
|
TimeSpan.FromHours(1));
|
|
|
|
if (bytes is { Length: > 0 })
|
|
{
|
|
return bytes.ToStream();
|
|
}
|
|
|
|
return default;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex,
|
|
"Exception occurred while downloading sparkline points: {ErrorMessage}",
|
|
ex.Message);
|
|
return default;
|
|
}
|
|
}
|
|
|
|
private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
|
|
{
|
|
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;
|
|
}
|
|
|
|
private static TypedKey<IReadOnlyCollection<GeckoCoinsResult>> GetTopCoinsKey()
|
|
=> new($"crypto:top_coins");
|
|
|
|
public async Task<IReadOnlyCollection<GeckoCoinsResult>?> GetTopCoins(int page)
|
|
{
|
|
if (page >= 25)
|
|
page = 24;
|
|
|
|
using var http = _httpFactory.CreateClient();
|
|
|
|
http.AddFakeHeaders();
|
|
|
|
var result = await _cache.GetOrAddAsync<IReadOnlyCollection<GeckoCoinsResult>>(GetTopCoinsKey(),
|
|
async () => await http.GetFromJsonAsync<List<GeckoCoinsResult>>(
|
|
"https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250")
|
|
?? [],
|
|
expiry: TimeSpan.FromHours(1));
|
|
|
|
return result!.Skip(page * 10).Take(10).ToList();
|
|
}
|
|
}
|
|
|
|
public sealed class GeckoCoinsResult
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public required string Id { get; init; }
|
|
|
|
[JsonPropertyName("name")]
|
|
public required string Name { get; init; }
|
|
|
|
[JsonPropertyName("symbol")]
|
|
public required string Symbol { get; init; }
|
|
|
|
[JsonPropertyName("current_price")]
|
|
public required decimal CurrentPrice { get; init; }
|
|
|
|
[JsonPropertyName("price_change_percentage_24h")]
|
|
public required decimal PercentChange24h { get; init; }
|
|
|
|
[JsonPropertyName("market_cap")]
|
|
public required decimal MarketCap { get; init; }
|
|
|
|
[JsonPropertyName("circulating_supply")]
|
|
public required decimal? CirculatingSupply { get; init; }
|
|
|
|
[JsonPropertyName("total_supply")]
|
|
public required decimal? TotalSupply { get; init; }
|
|
|
|
[JsonPropertyName("market_cap_rank")]
|
|
public required int MarketCapRank { get; init; }
|
|
} |