2024-06-18 23:55:36 +12:00
#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 ;
2024-07-15 15:44:58 +12:00
using System.Text.Json.Serialization ;
2024-06-18 23:55:36 +12:00
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 = >
{
2024-09-04 22:02:50 +12:00
x . DrawLine ( color , 2 , points ) ;
2024-06-18 23:55:36 +12:00
} ) ;
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
{
2024-07-08 01:34:34 +12:00
var data = await _cache . GetOrAddAsync ( new ( "ellie:crypto_data" ) ,
2024-06-18 23:55:36 +12:00
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 ;
}
2024-07-15 15:44:58 +12:00
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 ; }
2024-06-18 23:55:36 +12:00
}