From 7bc408d227644c953b6a2c3ca172bbab582fbaf1 Mon Sep 17 00:00:00 2001
From: Amy <amy@litdevs.org>
Date: Fri, 16 Feb 2024 20:00:18 +0200
Subject: [PATCH] Gateway implementation

---
 SharpQuark/Lightquark.cs             | 80 ++++++++++++++++++++++++----
 SharpQuark/Methods/Gateway.cs        | 36 +++++++++++++
 SharpQuark/Methods/User.cs           | 17 +++++-
 SharpQuark/Objects/GatewayMessage.cs | 15 ++++++
 SharpQuark/SharpQuark.csproj         |  1 +
 SharpQuark/Token/TokenCredential.cs  | 21 ++++++--
 readme.md                            |  7 ++-
 7 files changed, 155 insertions(+), 22 deletions(-)
 create mode 100644 SharpQuark/Methods/Gateway.cs
 create mode 100644 SharpQuark/Objects/GatewayMessage.cs

diff --git a/SharpQuark/Lightquark.cs b/SharpQuark/Lightquark.cs
index 80eabf4..e4209d7 100644
--- a/SharpQuark/Lightquark.cs
+++ b/SharpQuark/Lightquark.cs
@@ -1,37 +1,86 @@
-using System.Net.Http.Headers;
+using System.Diagnostics;
+using System.Net.Http.Headers;
+using System.Net.WebSockets;
 using System.Reflection;
 using Newtonsoft.Json;
 using SharpQuark.Objects;
 using SharpQuark.Token;
+using Websocket.Client;
 
 namespace SharpQuark;
 
 public class UnsupportedVersionException(string e) : Exception(e);
 
-public partial class Lightquark
+public partial class Lightquark : IDisposable
 {
+    private static int _sid;
+    private readonly int _id;
     private readonly HttpClient _http = new();
+    private static readonly string[] UnsupportedVersions = ["v1", "v2"];
     private readonly string _version;
-    private readonly Uri _baseUri;
     private readonly TokenCredential _tokenCredential;
     private readonly string _agent;
-    private List<Channel> _channels;
+    private WebsocketClient? _client;
+    public readonly NetworkInformation Network;
+    private Uri BaseUri => new (Network.BaseUrl ?? throw new Exception("Invalid network"));
+    private Timer? _heartbeatTimer;
     
-    public Lightquark(TokenCredential credential, NetworkInformation networkInformation, string? agent = null, string version = "v3", bool suppressStartupMessage = false)
+    public Lightquark(TokenCredential credential, NetworkInformation networkInformation, string? agent = null, string version = "v3", bool suppressStartupMessage = false, bool connectWebsocket = true)
     {
+        _id = _sid;
+        _sid++;
         _tokenCredential = credential;
         _version = version;
         _agent = agent ?? $"SharpQuark {Assembly.GetExecutingAssembly().GetName().Version}";
-        if (networkInformation.BaseUrl == null) throw new Exception("Invalid network");
-        _baseUri = new Uri(networkInformation.BaseUrl);
-        _channels = new List<Channel>();
+        Network = networkInformation;
         if (!suppressStartupMessage)
         {
-            Console.WriteLine($"Running {_agent}");
+            Console.WriteLine($"[{_id}] Running {_agent}");
         }
+
+        // Gateway
+        if (!connectWebsocket) return;
+        _connectGateway();
+
+    }
+
+    private void _connectGateway()
+    {
+        if (Network.Gateway == null) throw new Exception("Network information missing gateway uri when trying to connect gateway.");
+        var clientFactory = new Func<ClientWebSocket>(() =>
+        {
+            var client = new ClientWebSocket();
+            client.Options.AddSubProtocol(_tokenCredential.AccessToken.ToString()!);
+            return client;
+        });
+        _client = new WebsocketClient(new Uri(Network.Gateway), clientFactory);
+        
+        // Reconnections
+        _client.ReconnectTimeout = TimeSpan.FromSeconds(30);
+        _client.ReconnectionHappened.Subscribe(info =>
+            Debug.WriteLine($"[{_id}] Reconnection happened, type: {info.Type}"));
+
+        _client.DisconnectionHappened.Subscribe(info =>
+        {
+            Debug.WriteLine($"[{_id}] Disconnection happened, type: {info.Type}");
+            if (info.Exception != null) Console.Error.WriteLine(info.Exception);
+        });
+        
+        // Start
+        _client.MessageReceived.Subscribe(msg => GatewayMessage(msg));
+        _client.StartOrFail();
+        
+        _heartbeatTimer = new Timer(
+            _ =>
+            {
+                SendGateway(new GatewayMessage("heartbeat", "alive"));
+                Debug.WriteLine($"[{_id}] Sent heartbeat message.");
+            },
+            null,
+            TimeSpan.Zero,
+            TimeSpan.FromSeconds(15));
     }
 
-    private static readonly string[] UnsupportedVersions = ["v1", "v2"];
     // ReSharper disable once MemberCanBePrivate.Global
     public async Task<string> Call(string endpoint, string method = "GET", ApiCallOptions? options = null)
     {
@@ -53,7 +102,7 @@ public partial class Lightquark
         }
         
         // Use request specific version if available
-        var requestUri = new Uri(_baseUri, $"{options?.Version ?? _version}/{endpoint}");
+        var requestUri = new Uri(BaseUri, $"{options?.Version ?? _version}/{endpoint}");
 
         var request = new HttpRequestMessage
         {
@@ -86,6 +135,15 @@ public partial class Lightquark
 
         return resultString;
     }
+
+    public void Dispose()
+    {
+        Debug.WriteLine($"[{_id}] Goodbye :(");
+        _http.Dispose();
+        _client?.Dispose();
+        _heartbeatTimer?.Dispose();
+        GC.SuppressFinalize(this);
+    }
 }
 
 public class ApiCallOptions
diff --git a/SharpQuark/Methods/Gateway.cs b/SharpQuark/Methods/Gateway.cs
new file mode 100644
index 0000000..1f6c704
--- /dev/null
+++ b/SharpQuark/Methods/Gateway.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics;
+using Newtonsoft.Json;
+using SharpQuark.Objects;
+using Websocket.Client;
+
+namespace SharpQuark;
+
+public partial class Lightquark
+{
+    private void GatewayMessage(ResponseMessage message)
+    {
+        Task.Run(() =>
+        {
+            if (message.Text == null) return;
+            var baseMessage = JsonConvert.DeserializeObject<GatewayEventBase>(message.Text);
+            if (baseMessage == null) throw new Exception($"Invalid event from gateway {message.Text}");
+
+            switch (baseMessage.Event)
+            {
+                case "heartbeat":
+                    Debug.WriteLine("Heartbeat received");
+                    return;
+                default:
+                    Console.WriteLine($"Unimplemented event received {baseMessage.Event}");
+                    break;
+            }
+        });
+    }
+
+    private void SendGateway(GatewayMessage message)
+    {
+        if (_client == null) _connectGateway();
+        _client!.Send(JsonConvert.SerializeObject(message));
+    }
+    
+}
\ No newline at end of file
diff --git a/SharpQuark/Methods/User.cs b/SharpQuark/Methods/User.cs
index aecbf88..dbce182 100644
--- a/SharpQuark/Methods/User.cs
+++ b/SharpQuark/Methods/User.cs
@@ -1,13 +1,20 @@
 using Newtonsoft.Json;
 using SharpQuark.ApiResult;
+using SharpQuark.Objects;
 using SharpQuark.Objects.Id;
 
 namespace SharpQuark;
 
 public partial class Lightquark
 {
+    public async Task<User> UserMe()
+    {
+        var user = await UserMeRaw();
+        return user.Response.User;
+    }
+    
     // /user/me
-    public async Task<UserMeApiResult> UserMe()
+    public async Task<UserMeApiResult> UserMeRaw()
     {
         var rawApiResult = await Call("/user/me");
 
@@ -16,8 +23,14 @@ public partial class Lightquark
         return parsedApiResult ?? throw new Exception("/user/me API Result is null");
     }
     
+    public async Task<User> UserById(UserId userId)
+    {
+        var user = await UserByIdRaw(userId);
+        return user.Response.User;
+    }
+    
     // /user/:userId
-    public async Task<UserApiResult> UserById(UserId userId)
+    public async Task<UserApiResult> UserByIdRaw(UserId userId)
     {
         var rawApiResult = await Call($"/user/{userId}");
 
diff --git a/SharpQuark/Objects/GatewayMessage.cs b/SharpQuark/Objects/GatewayMessage.cs
new file mode 100644
index 0000000..5d1e994
--- /dev/null
+++ b/SharpQuark/Objects/GatewayMessage.cs
@@ -0,0 +1,15 @@
+using Newtonsoft.Json;
+
+namespace SharpQuark.Objects;
+
+public struct GatewayMessage(string @event, string message, string? state = null)
+{
+    [JsonProperty("event")] public string Event = @event;
+    [JsonProperty("message")] public string Message = message;
+    [JsonProperty("state")] public string? State = state;
+}
+
+public class GatewayEventBase
+{
+    [JsonProperty("eventId")] public required string Event;
+}
\ No newline at end of file
diff --git a/SharpQuark/SharpQuark.csproj b/SharpQuark/SharpQuark.csproj
index 334ae98..3f11d46 100644
--- a/SharpQuark/SharpQuark.csproj
+++ b/SharpQuark/SharpQuark.csproj
@@ -10,6 +10,7 @@
 
     <ItemGroup>
       <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
+      <PackageReference Include="Websocket.Client" Version="5.1.1" />
     </ItemGroup>
 
 </Project>
diff --git a/SharpQuark/Token/TokenCredential.cs b/SharpQuark/Token/TokenCredential.cs
index b8a7075..e7bd788 100644
--- a/SharpQuark/Token/TokenCredential.cs
+++ b/SharpQuark/Token/TokenCredential.cs
@@ -1,4 +1,6 @@
-namespace SharpQuark.Token;
+using SharpQuark.ApiResult;
+
+namespace SharpQuark.Token;
 
 public class TokenCredential(AccessToken accessToken, RefreshToken refreshToken)
 {
@@ -24,18 +26,27 @@ public class TokenCredential(AccessToken accessToken, RefreshToken refreshToken)
     public static async Task<TokenCredential> Login(string email, string password, NetworkInformation networkInformation)
     {
         // Create temporary Lightquark instance
-        var tempLq = new Lightquark(new TokenCredential(new AccessToken(), new RefreshToken()), networkInformation, null, "v3", true);
-        var res = await tempLq.AuthToken(email, password);
+        AuthTokenApiResult res;
+        using (var tempLq = new Lightquark(new TokenCredential(new AccessToken(), new RefreshToken()), networkInformation, null, "v3", true, false))
+        {
+            res = await tempLq.AuthToken(email, password);
+        }
+
         var accessToken = (AccessToken)Token.From(res.Response.AccessToken);
         var refreshToken = (RefreshToken)Token.From(res.Response.RefreshToken);
+        
         return new TokenCredential(accessToken, refreshToken);
     }
     
     public static async Task<TokenCredential> Register(string email, string password, string username, NetworkInformation networkInformation)
     {
         // Create temporary Lightquark instance
-        var tempLq = new Lightquark(new TokenCredential(new AccessToken(), new RefreshToken()), networkInformation, null, "v3", true);
-        var res = await tempLq.AuthRegister(email, password, username);
+        AuthTokenApiResult res;
+        using (var tempLq = new Lightquark(new TokenCredential(new AccessToken(), new RefreshToken()), networkInformation, null, "v3", true))
+        {
+            res = await tempLq.AuthRegister(email, password, username);
+        }
+
         var accessToken = (AccessToken)Token.From(res.Response.AccessToken);
         var refreshToken = (RefreshToken)Token.From(res.Response.RefreshToken);
         return new TokenCredential(accessToken, refreshToken);
diff --git a/readme.md b/readme.md
index f8760f7..8e39ec4 100644
--- a/readme.md
+++ b/readme.md
@@ -26,11 +26,11 @@ var lq = new Lightquark(tokens, netInfo);
 
 // To get data about the logged in user 
 var userMe = await lq.UserMe();
-Console.WriteLine(userMe.Response.User.Username); // Test_User
+Console.WriteLine(userMe.Username); // Test_User
 
 // To get data about a specific user
-var userById = await lq.UserById("62b3515989cdb45c9e06e010");
-Console.WriteLine($"{userById.Response.User.Status?.Type} {userById.Response.User.Status?.Content}"); // Playing Stardew Valley
+var userById = await lq.UserById(new UserId("62b3515989cdb45c9e06e010"));
+Console.WriteLine($"{userById.Status?.Type} {userById.Status?.Content}"); // Playing Stardew Valley
 
 // Get a channel
 var speakySpeaky = await lq.ChannelById(new ChannelId("643aa2e550c913775aec2057"));
@@ -43,5 +43,4 @@ Console.WriteLine(speakySpeaky.Messages.Last().Content);
 var speakySpeaky2 = await lq.ChannelById(new ChannelId("643aa2e550c913775aec2057"));
 
 Console.WriteLine(speakySpeaky2.Messages.Last().Timestamp);
-
 ```
\ No newline at end of file
-- 
GitLab