diff --git a/.gitignore b/.gitignore
index 95477a8..fcb73ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
## Get latest from `dotnet new gitignore`
Generated/
+*.txt
# dotenv files
.env
diff --git a/Discord.API/Discord.API.csproj b/Discord.API/Discord.API.csproj
index 8e08e06..0c77361 100644
--- a/Discord.API/Discord.API.csproj
+++ b/Discord.API/Discord.API.csproj
@@ -5,6 +5,7 @@
enable
enable
Discord.API
+ true
diff --git a/Discord.API/DiscordClient.cs b/Discord.API/DiscordClient.cs
new file mode 100644
index 0000000..4a28d6d
--- /dev/null
+++ b/Discord.API/DiscordClient.cs
@@ -0,0 +1,46 @@
+using Discord.API.Rest;
+using Serilog;
+
+namespace Discord.API;
+
+public class DiscordClient
+{
+ private static readonly string ApiBaseUrl = "https://discord.com/api/v10"; //TODO Export version number to a separate constant
+
+ private string ApiKey;
+ private RestClient Rest;
+ private GatewayClient? Gateway;
+ private TimeProvider TimeProvider;
+
+ public DiscordClient(string api_key) : this(api_key, TimeProvider.System){
+ }
+
+ internal DiscordClient(string api_key, TimeProvider time_provider){
+ this.TimeProvider=time_provider;
+ this.ApiKey=api_key;
+ Rest = new RestClient(ApiBaseUrl, ApiKey);
+ Task.Run(ConnectGateway);
+ }
+
+ private async Task ConnectGateway(){
+ RestResponse resp = await Rest.GetGateway();
+
+ while(resp is not RestSuccessResponse){
+ Log.Debug("DiscordClient: Failed to get gateway url");
+ if(resp is RestErrorResponse error){
+ Log.Error("DiscordClient: Get gateway returned error: {error_msg}", error.Error.Message);
+ break;
+ }else{
+ Log.Information("DiscordClient: Get gateway failed: {ex}; Retrying later", (resp as RestFailResponse)!.Exception?.Message);
+ }
+ await Task.Delay(TimeSpan.FromMilliseconds(2000), TimeProvider, cancellationToken: default);
+ resp = await Rest.GetGateway();
+ }
+
+ Log.Information("DiscordClient: Gateway url: {gateway_url}", resp.Value.Url);
+
+ Gateway = new GatewayClient(resp.Value.Url, ApiKey, TimeProvider);
+
+ Log.Information("DiscordClient: Started gateway");
+ }
+}
diff --git a/Discord.API/Gateway/GatewayClient.cs b/Discord.API/Gateway/GatewayClient.cs
index 772fe21..0aaac96 100644
--- a/Discord.API/Gateway/GatewayClient.cs
+++ b/Discord.API/Gateway/GatewayClient.cs
@@ -1,4 +1,5 @@
using System.Collections.Specialized;
+using System.Security.Cryptography;
using System.Text.Json;
using Serilog;
using Websocket.Client;
@@ -10,26 +11,35 @@ public class GatewayClient {
private const int ApiVersion = 10;
private string ApiKey;
- private WebsocketClient Websocket;
- private CancellationTokenSource? heartbeat_cts;
+ private IWebsocketClient Websocket;
+ private CancellationTokenSource? HeartbeatCts;
private ulong? Sequence = null;
+ private DateTime? HeartbeatAckReceived = null;
+ private TimeProvider timeProvider;
- public GatewayClient(string url, string api_key){
+ public GatewayClient(string url, string api_key, TimeProvider time_provider)
+ : this(url, api_key, time_provider, uri => new WebsocketClient(uri))
+ { }
+
+ internal GatewayClient(string url, string api_key, TimeProvider time_provider, Func websocket_client_factory){
+ this.timeProvider=time_provider;
this.ApiKey=api_key;
UriBuilder uriBuilder = new(url);
NameValueCollection query = System.Web.HttpUtility.ParseQueryString("");
query.Add("v", ApiVersion.ToString());
query.Add("encoding", "json");
uriBuilder.Query=query.ToString();
- Websocket = new WebsocketClient(uriBuilder.Uri);
+ Websocket = websocket_client_factory.Invoke(uriBuilder.Uri);
Websocket.DisconnectionHappened.Subscribe(DisconnectionHandler);
Websocket.ReconnectionHappened.Subscribe(ReconnectionHandler);
Websocket.MessageReceived.Subscribe(MessageReceivedHandler);
+ Websocket.Start();
Log.Debug("GATEWAY: Created new gateway, with url: {url}", uriBuilder.ToString());
}
private void DisconnectionHandler(DisconnectionInfo info){
+ StopHeartbeat();
Log.Information("GATEWAY: Disconnected. Type: {DisconnectionType}", info.Type);
}
private void ReconnectionHandler(ReconnectionInfo info){
@@ -41,10 +51,18 @@ public class GatewayClient {
GatewayPacket packet = JsonSerializer.Deserialize(msg.Text!, SourceGenerationContext.Default.GatewayPacket)
?? throw new Exception("Failed to deserialize packet"); // This can be optimized //TODO
+ Log.Debug("GATEWAY: Packet received");
+
switch(packet){
case HelloPacket helloPacket:
HelloPacketHandler(helloPacket);
break;
+ case HeartbeatAckPacket:
+ HeartbeatAckHandler();
+ break;
+ default:
+ Log.Debug("GATEWAY: Packet not handled");
+ break;
}
}catch(Exception ex){
Log.Warning(ex, "GATEWAY: Error processing gateway event");
@@ -53,16 +71,30 @@ public class GatewayClient {
private void HelloPacketHandler(HelloPacket packet){
StartHeartbeat((int)packet.Data.HeartbeatInterval);
+ Log.Debug("GATEWAY: Hello packet received, heartbeat interval: {heartbeat_ms}", packet.Data.HeartbeatInterval);
+ }
+
+ private void HeartbeatAckHandler(){
+ HeartbeatAckReceived = timeProvider.GetUtcNow().DateTime;
+ Log.Debug("GATEWAY: Heartbeat ACK received");
}
private void StartHeartbeat(int heartbeat_interval){
- heartbeat_cts?.Cancel();
- heartbeat_cts = new CancellationTokenSource();
- CancellationToken ct = heartbeat_cts.Token;
+ HeartbeatCts?.Cancel();
+ HeartbeatCts = new CancellationTokenSource();
+ CancellationToken ct = HeartbeatCts.Token;
Task.Run(async ()=>{
await Task.Delay(Random.Shared.Next(1, heartbeat_interval));
- using PeriodicTimer pd = new(TimeSpan.FromMilliseconds(heartbeat_interval));
+ using PeriodicTimer pd = new(TimeSpan.FromMilliseconds(heartbeat_interval), timeProvider);
+ HeartbeatAckReceived = timeProvider.GetUtcNow().DateTime;
+ DateTime HeartbeatSent = timeProvider.GetUtcNow().DateTime;
do{
+ if(HeartbeatAckReceived == null){
+ Log.Debug("GATEWAY: Heartbeat ack not received. Reconnecting.");
+ _ = Websocket.Reconnect();
+ break;
+ }
+ Log.Information("GATEWAY: Heartbeat ping time is {time_ms} ms", (HeartbeatAckReceived.Value - HeartbeatSent).TotalMilliseconds);
HeartbeatPacket packet = new HeartbeatPacket(){
Sequence=this.Sequence
};
@@ -70,7 +102,14 @@ public class GatewayClient {
if(!Websocket.Send(JsonSerializer.Serialize(packet, SourceGenerationContext.Default.HeartbeatPacket))){
Log.Warning("GATEWAY: Failed to queue heartbeat message");
}
+ HeartbeatSent = timeProvider.GetUtcNow().DateTime;
+ HeartbeatAckReceived = null;
+ Log.Debug("GATEWAY: Heartbeat sent");
}while(await pd.WaitForNextTickAsync(ct) && !ct.IsCancellationRequested);
});
}
+
+ private void StopHeartbeat(){
+ HeartbeatCts?.Cancel();
+ }
}
diff --git a/example_bot/Program.cs b/example_bot/Program.cs
index 3751555..2170101 100644
--- a/example_bot/Program.cs
+++ b/example_bot/Program.cs
@@ -1,2 +1,13 @@
-// See https://aka.ms/new-console-template for more information
-Console.WriteLine("Hello, World!");
+using Discord.API;
+using Serilog;
+
+Log.Logger=new LoggerConfiguration()
+ .WriteTo.Console(Serilog.Events.LogEventLevel.Verbose)
+ .MinimumLevel.Verbose()
+ .CreateLogger();
+
+string api_key = File.ReadAllText("api_key.txt");
+
+DiscordClient client = new DiscordClient(api_key);
+
+Console.ReadLine();
\ No newline at end of file
diff --git a/example_bot/example_bot.csproj b/example_bot/example_bot.csproj
index 2150e37..7655526 100644
--- a/example_bot/example_bot.csproj
+++ b/example_bot/example_bot.csproj
@@ -1,5 +1,14 @@
+
+
+
+
+
+
+
+
+
Exe
net8.0