3xpl websocket client in case anyone wanted one. Don't bother using it though, their websocket service is a piece of shit that's totally broken which I only found out after wasting a day on it.

This commit is contained in:
barelyprofessional
2024-06-16 12:18:56 +08:00
parent cdad1d6549
commit 5cdab275c3
12 changed files with 4026 additions and 0 deletions

View File

@@ -10,6 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KickWsClient", "KickWsClien
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KfChatDotNetKickBot", "KfChatDotNetKickBot\KfChatDotNetKickBot.csproj", "{4734E0A4-150E-4915-B905-928BB4BE3FF6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThreeXplWsClient", "ThreeXplWsClient\ThreeXplWsClient.csproj", "{3D72D70A-48AD-4EE8-89DC-C78153EEA879}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThreeXplCliClient", "ThreeXplCliClient\ThreeXplCliClient.csproj", "{D098E281-5535-4A07-9514-57AF78704B0C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -36,5 +40,13 @@ Global
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4734E0A4-150E-4915-B905-928BB4BE3FF6}.Release|Any CPU.Build.0 = Release|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3D72D70A-48AD-4EE8-89DC-C78153EEA879}.Release|Any CPU.Build.0 = Release|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D098E281-5535-4A07-9514-57AF78704B0C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
autoReload="true"
throwExceptions="false"
internalLogLevel="Off" internalLogFile="~/nlog-internal.log">
<targets>
<target xsi:type="Console" name="console"/>
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="console" />
</rules>
</nlog>

3483
ThreeXplCliClient/NLog.xsd Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
using System.Net;
using System.Text;
using Spectre.Console;
using ThreeXplWsClient.Events;
namespace ThreeXplCliClient
{
public class Program
{
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
AnsiConsole.MarkupLine("[green]3xpl test client started[/]");
var cliClient = new ThreeXplClient();
await cliClient.Start();
}
}
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NLog" Version="5.3.2" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="System.Text.Json" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ThreeXplWsClient\ThreeXplWsClient.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="NLog.config">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<None Remove="NLog.config" />
<Content Include="NLog.config">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,38 @@
using System.Text.Json;
using Spectre.Console;
using ThreeXplWsClient.Events;
namespace ThreeXplCliClient;
public class ThreeXplClient
{
private List<string> _addresses =
[
"MC8TiBEsnQVjxbvLtTsUXjTBZTQaR8fe8X",
"ltc1qks2m7hvmhs3c20zrfvptv9pvk82p8g70sgw5mk"
];
public async Task Start()
{
var client = new ThreeXplWsClient.ThreeXplWsClient();
client.OnThreeXplPush += OnThreeXplEvent;
await client.StartWsClient();
while (true)
{
var prompt = AnsiConsole.Ask<string>("Channel: ");
client.SendSubscribeRequest(prompt);
}
}
private void OnThreeXplEvent(object sender, ThreeXplPushModel e, int connectionId)
{
AnsiConsole.MarkupLine("[blue]Received event from 3xpl[/]");
foreach (var txn in e.Pub.Data.Data)
{
if (txn.Address == null) return;
if (_addresses.Contains(txn.Address))
{
AnsiConsole.MarkupLine($"[green]Saw txn I'm interested in: {txn.Address}, effect {txn.Effect}, currency {txn.Currency}[/]");
}
}
}
}

View File

@@ -0,0 +1,22 @@
using Websocket.Client;
namespace ThreeXplWsClient.Events;
public class EventHandlers
{
public delegate void OnThreeXplPing(object sender, int connectionId);
public delegate void OnThreeXplPush(object sender, ThreeXplPushModel e, int connectionId);
public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e, int connectionId);
public delegate void OnWsReconnectEventHandler(object sender, ReconnectionInfo e, int connectionId);
public delegate void OnWsMessageReceivedEventHandler(object sender, ResponseMessage e, int connectionId);
public delegate void OnThreeXplConnect(object sender, ThreeXplConnectDataModel e, int connectionId);
public delegate void OnThreeXplError(object sender, ThreeXplErrorModel e, int connectionId);
public delegate void OnThreeXplSubscribe(object sender, ThreeXplSubscribeModel e, int connectionId);
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
namespace ThreeXplWsClient.Events;
public class BaseThreeXplPacketModel
{
[JsonPropertyName("connect")]
public ThreeXplConnectDataModel? Connect { get; set; }
[JsonPropertyName("id")]
public int? Id { get; set; }
[JsonPropertyName("error")]
public ThreeXplErrorModel? Error { get; set; }
[JsonPropertyName("subscribe")]
public ThreeXplSubscribeModel? Subscribe { get; set; }
[JsonPropertyName("push")]
public ThreeXplPushModel? Push { get; set; }
}
public class ThreeXplDataModel
{
[JsonPropertyName("blockchain")]
public string? Blockchain { get; set; }
[JsonPropertyName("module")]
public string? Module { get; set; }
[JsonPropertyName("block")]
public int? Block { get; set; }
[JsonPropertyName("transaction")]
public string? Transaction { get; set; }
[JsonPropertyName("sort_key")]
public int? SortKey { get; set; }
[JsonPropertyName("time")]
public DateTimeOffset? Time { get; set; }
[JsonPropertyName("currency")]
public string? Currency { get; set; }
[JsonPropertyName("effect")]
public string? Effect { get; set; }
[JsonPropertyName("failed")]
public bool? Failed { get; set; }
[JsonPropertyName("extra")]
public object? Extra { get; set; }
[JsonPropertyName("extra_indexed")]
public object? ExtraIndexed { get; set; }
[JsonPropertyName("address")]
public string? Address { get; set; }
}
public class ThreeXplContextModel
{
// "time":"0.21778600 1718465848"
[JsonPropertyName("time")]
public string? Time { get; set; }
}
public class ThreeXplConnectDataModel
{
[JsonPropertyName("client")]
public string? Client { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("ping")]
public int? Ping { get; set; }
[JsonPropertyName("pong")]
public bool? Pong { get; set; }
}
public class ThreeXplErrorModel
{
[JsonPropertyName("code")]
public int? Code { get; set; }
[JsonPropertyName("Message")]
public string? Message { get; set; }
[JsonPropertyName("temporary")]
public bool? Temporary { get; set; }
}
public class ThreeXplSubscribeModel
{
[JsonPropertyName("recoverable")]
public bool? Recoverable { get; set; }
[JsonPropertyName("epoch")]
public string? Epoch { get; set; }
[JsonPropertyName("positioned")]
public bool? Positioned { get; set; }
}
public class ThreeXplPushModel
{
[JsonPropertyName("channel")]
public required string Channel { get; set; }
[JsonPropertyName("pub")]
public required ThreeXplPushPubModel Pub { get; set; }
[JsonPropertyName("offset")]
public int? Offset { get; set; }
}
public class ThreeXplPushPubModel
{
[JsonPropertyName("data")]
public required ThreeXplPushDataModel Data { get; set; }
}
public class ThreeXplPushDataModel
{
[JsonPropertyName("data")]
public required List<ThreeXplDataModel> Data { get; set; }
[JsonPropertyName("context")]
public required ThreeXplContextModel Context { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace ThreeXplWsClient.Models;
public class GetWebsocketTokenModel
{
[JsonPropertyName("data")]
public required string Data { get; set; }
[JsonPropertyName("context")]
public GetWebSocketTokenContextModel? Context { get; set; }
}
public class GetWebSocketTokenContextModel
{
[JsonPropertyName("code")]
public int? Code { get; set; }
}

View File

@@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace ThreeXplWsClient.Models;
public class ConnectRequestModel
{
[JsonPropertyName("connect")]
public required ConnectRequestTokenModel Connect { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; } = 1;
}
public class ConnectRequestTokenModel
{
[JsonPropertyName("token")]
public required string Token { get; set; }
}
public class SubscribeRequestModel
{
[JsonPropertyName("subscribe")]
public required SubscribeRequestChannelModel Subscribe { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; } = 2;
}
public class SubscribeRequestChannelModel
{
[JsonPropertyName("channel")]
public required string Channel { get; set; }
}

View File

@@ -0,0 +1,235 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text.Json;
using NLog;
using ThreeXplWsClient.Events;
using ThreeXplWsClient.Models;
using Websocket.Client;
namespace ThreeXplWsClient;
public class ThreeXplWsClient
{
public event EventHandlers.OnWsMessageReceivedEventHandler OnWsMessageReceived;
public event EventHandlers.OnWsDisconnectionEventHandler OnWsDisconnection;
public event EventHandlers.OnWsReconnectEventHandler OnWsReconnect;
public event EventHandlers.OnThreeXplPing OnThreeXplPing;
public event EventHandlers.OnThreeXplPush OnThreeXplPush;
public event EventHandlers.OnThreeXplConnect OnThreeXplConnect;
public event EventHandlers.OnThreeXplError OnThreeXplError;
public event EventHandlers.OnThreeXplSubscribe OnThreeXplSubscribe;
private WebsocketClient _wsClient;
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
private string? _wsJwt;
private DateTime _wsJwtLastRetrieved = DateTime.Now;
private int _wsJwtValidityPeriodSeconds;
private string? _proxy;
private int _reconnectTimeout;
private Uri _wsUri;
// Basically they have a limit of 10 subscriptions per connection and I have more than 10 addresses to monitor, so
// I give each connection an ID number as that way I know what addresses need to be resubscribed in the event of a
// connection drop. This ID is included with every event fired and set when the class is constructed.
private int _connectionId;
/// <summary>
/// Client for the 3xpl WebSocket API
/// </summary>
/// <param name="threeXplWsUri">URI for the websocket API, published at https://3xpl.com/data/websocket-api</param>
/// <param name="proxy">Web proxy to use for the WebSocket connection</param>
/// <param name="reconnectTimeout">Reconnect timeout, defaults to 30 seconds as 3xpl tells us to expect a ping every 25 seconds</param>
/// <param name="jwtValidityPeriodSeconds">How long the JWT is valid for. Set to int.MaxValue if you've manually provided a non-expiring token</param>
/// <param name="jwtApiToken">Manually provide a JWT if you have access to create your own</param>
/// <param name="connectionId">ID that can be used to differentiate multiple 3xpl connections</param>
public ThreeXplWsClient(string threeXplWsUri = "wss://stream.3xpl.net", string? proxy = null,
int reconnectTimeout = 30, int jwtValidityPeriodSeconds = 600, string? jwtApiToken = null, int connectionId = 0)
{
_wsUri = new Uri(threeXplWsUri);
_proxy = proxy;
_reconnectTimeout = reconnectTimeout;
_wsJwtValidityPeriodSeconds = jwtValidityPeriodSeconds;
_wsJwt = jwtApiToken;
_connectionId = connectionId;
}
private async Task RefreshApiToken()
{
_logger.Debug("Refreshing the API token");
if (_wsJwtValidityPeriodSeconds == int.MaxValue)
{
_logger.Debug($"Token is non expiring as it is set to {int.MaxValue}");
return;
}
if (_wsJwt != null && _wsJwtLastRetrieved.AddSeconds(_wsJwtValidityPeriodSeconds) >= DateTime.Now)
{
_logger.Debug(
$"Token has not yet expired. Its expiration date is {_wsJwtLastRetrieved.AddSeconds(_wsJwtValidityPeriodSeconds):yyyy-MM-dd HH:mm:ss}");
return;
}
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All };
if (_proxy != null)
{
handler.Proxy = new WebProxy(_proxy);
handler.UseProxy = true;
}
using var client = new HttpClient(handler);
var token = await client.GetFromJsonAsync<GetWebsocketTokenModel>("https://3xpl.com/get-websockets-token");
if (token == null)
{
_logger.Error("Caught a null when retrieving a WebSocket JWT from 3xpl");
throw new InvalidOperationException("Caught a null when retrieving a WebSocket JWT from 3xp");
}
_wsJwt = token.Data;
_wsJwtLastRetrieved = DateTime.Now;
}
public async Task StartWsClient()
{
_logger.Debug("StartWsClient() called, creating client");
await CreateWsClient();
}
private async Task CreateWsClient()
{
var factory = new Func<ClientWebSocket>(() =>
{
var clientWs = new ClientWebSocket();
if (_proxy == null) return clientWs;
clientWs.Options.Proxy = new WebProxy(_proxy);
return clientWs;
});
var client = new WebsocketClient(_wsUri, factory)
{
ReconnectTimeout = TimeSpan.FromSeconds(_reconnectTimeout)
};
_wsClient = client;
client.ReconnectionHappened.Subscribe(WsReconnection);
client.MessageReceived.Subscribe(WsMessageReceived);
client.DisconnectionHappened.Subscribe(WsDisconnection);
_logger.Debug("Websocket client has been built, about to start");
await client.Start();
_logger.Debug("Websocket client started!");
}
public bool IsConnected()
{
return _wsClient is { IsRunning: true };
}
private void WsDisconnection(DisconnectionInfo disconnectionInfo)
{
_logger.Error($"Client disconnected from the chat (or never successfully connected). Type is {disconnectionInfo.Type}");
_logger.Error(disconnectionInfo.Exception);
OnWsDisconnection?.Invoke(this, disconnectionInfo, _connectionId);
}
private void SendConnectRequest()
{
if (_wsJwt == null)
{
_logger.Error("JWT was null.");
throw new InvalidOperationException("JWT was null");
}
var data = new ConnectRequestModel { Connect = new ConnectRequestTokenModel { Token = _wsJwt } };
var payload = JsonSerializer.Serialize(data);
_logger.Debug("Sending the following payload to 3xpl");
_logger.Debug(payload);
_wsClient.Send(payload);
}
private void WsReconnection(ReconnectionInfo reconnectionInfo)
{
_logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}");
_logger.Info("Refreshing JWT");
RefreshApiToken().Wait();
_logger.Info("Sending connect request");
SendConnectRequest();
OnWsReconnect?.Invoke(this, reconnectionInfo, _connectionId);
}
public void SendSubscribeRequest(string channel)
{
var data = new SubscribeRequestModel { Subscribe = new SubscribeRequestChannelModel { Channel = channel }};
var payload = JsonSerializer.Serialize(data);
_logger.Debug("Sending the following subscription payload to 3xpl");
_logger.Debug(payload);
_wsClient.Send(payload);
}
private void WsMessageReceived(ResponseMessage message)
{
OnWsMessageReceived?.Invoke(this, message, _connectionId);
_logger.Debug("Received JSON from 3xpl");
_logger.Debug(message.Text);
if (message.Text == null)
{
_logger.Info("Websocket message was null, ignoring packet");
return;
}
if (message.Text == "{}")
{
_logger.Debug("Received ping from 3xpl. Sending back a pong and invoking event");
_wsClient.Send("{}");
OnThreeXplPing?.Invoke(this, _connectionId);
return;
}
BaseThreeXplPacketModel threeXplPacket;
try
{
threeXplPacket = JsonSerializer.Deserialize<BaseThreeXplPacketModel>(message.Text) ??
throw new InvalidOperationException();
}
catch (Exception e)
{
_logger.Error("Failed to parse 3xpl payload. Exception follows:");
_logger.Error(e);
_logger.Error("--- Message from 3xpl follows ---");
_logger.Error(message.Text);
_logger.Error("--- /end of message ---");
return;
}
if (threeXplPacket.Connect != null)
{
_logger.Debug("Received connect packet from 3xpl, invoking event");
OnThreeXplConnect?.Invoke(this, threeXplPacket.Connect, _connectionId);
return;
}
if (threeXplPacket.Push != null)
{
_logger.Debug("Received data event from 3xpl");
OnThreeXplPush?.Invoke(this, threeXplPacket.Push, _connectionId);
return;
}
if (threeXplPacket.Error != null)
{
_logger.Debug("Received error packet from 3xpl");
OnThreeXplError?.Invoke(this, threeXplPacket.Error, _connectionId);
return;
}
if (threeXplPacket.Subscribe != null)
{
_logger.Debug("Received subscribe packet from 3xpl");
OnThreeXplSubscribe?.Invoke(this, threeXplPacket.Subscribe, _connectionId);
return;
}
_logger.Error("Failed to handle 3xpl packet");
_logger.Error("--- Message from 3xpl follows ---");
_logger.Error(message.Text);
_logger.Error("--- /end of message ---");
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NLog" Version="5.3.2" />
<PackageReference Include="System.Text.Json" Version="8.0.3" />
<PackageReference Include="Websocket.Client" Version="5.1.1" />
</ItemGroup>
</Project>