From 5cb2a73360929f7657aae4c042019c76832af991 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Wed, 22 Nov 2023 23:39:32 +0000 Subject: [PATCH 01/14] wip: discord rpc using streamkit --- .../Authentication/DiscordAuthClient.cs | 105 +++++++- .../DiscordModule.cs | 4 +- .../DiscordRpcClient.cs | 232 ++++++++++++------ .../Enums/RpcPacketType.cs | 2 +- 4 files changed, 264 insertions(+), 79 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index ca9a7ab..5c3cff8 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -3,11 +3,22 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Text; using System.Threading.Tasks; namespace Artemis.Plugins.Modules.Discord.Authentication; -public class DiscordAuthClient : IDisposable +public interface IDiscordAuthClient : IDisposable +{ + bool HasToken { get; } + bool IsTokenValid { get; } + string AccessToken { get; } + Task RefreshTokenIfNeededAsync(); + Task GetAccessTokenAsync(string challengeCode); + Task RefreshAccessTokenAsync(); +} + +public class DiscordAuthClient : IDiscordAuthClient { private readonly string _clientId; private readonly string _clientSecret; @@ -110,3 +121,95 @@ public void Dispose() } #endregion } + +public class DiscordStreamKitAuthClient : IDiscordAuthClient +{ + private readonly PluginSetting _token; + private readonly HttpClient _httpClient; + + public DiscordStreamKitAuthClient(PluginSetting token) + { + _token = token; + _httpClient = new(); + } + + public bool HasToken => _token.Value != null; + + public bool IsTokenValid => HasToken && _token.Value!.ExpirationDate >= DateTime.UtcNow; + + public string AccessToken => _token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); + + public async Task RefreshTokenIfNeededAsync() + { + if (!HasToken) + return; + + if (_token.Value!.ExpirationDate >= DateTime.UtcNow.AddDays(1)) + return; + + await RefreshAccessTokenAsync(); + } + + public async Task GetAccessTokenAsync(string challengeCode) + { + var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); + + using HttpResponseMessage response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); + + string responseString = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new UnauthorizedAccessException(responseString); + } + + var token = JsonConvert.DeserializeObject(responseString)!; + SaveToken(token); + + return token; + } + + public async Task RefreshAccessTokenAsync() + { + return; + if (!HasToken) + throw new InvalidOperationException("No token to refresh"); + + throw new NotImplementedException(); + } + + private void SaveToken(TokenResponse newToken) + { + _token.Value = new SavedToken + { + AccessToken = newToken.AccessToken, + RefreshToken = newToken.RefreshToken, + ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) + }; + _token.Save(); + } + + #region IDisposable + private bool disposedValue; + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _httpClient?.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion +} diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index dc4cc58..70a4965 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -40,7 +40,7 @@ public DiscordModule(ILogger logger, PluginSettings pluginSettings) _clientId = pluginSettings.GetSetting("DiscordClientId", string.Empty); _clientSecret = pluginSettings.GetSetting("DiscordClientSecret", string.Empty); - _savedToken = pluginSettings.GetSetting("DiscordToken"); + _savedToken = pluginSettings.GetSetting("DiscordTokenStreamKit"); _discordClientLock = new(); } @@ -150,7 +150,7 @@ private async void OnAuthenticated(object? sender, Authenticate e) //Subscribe to these events as well await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_SETTINGS_UPDATE); - await _discordClient.SubscribeAsync(DiscordRpcEvent.NOTIFICATION_CREATE); + //await _discordClient.SubscribeAsync(DiscordRpcEvent.NOTIFICATION_CREATE); await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_CONNECTION_STATUS); await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_CHANNEL_SELECT); } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 0d51344..2a19ab9 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -12,6 +12,7 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Net.WebSockets; using System.Runtime.InteropServices; using System.Text; using System.Threading; @@ -37,9 +38,8 @@ public class DiscordRpcClient : IDiscordRpcClient private readonly Dictionary> _pendingRequests; private readonly CancellationTokenSource _cancellationTokenSource; private readonly string _clientId; - private readonly DiscordAuthClient _authClient; - private readonly byte[] _headerBuffer; - private NamedPipeClientStream? _pipe; + private readonly IDiscordAuthClient _authClient; + private readonly IDiscordTransport _transport; private Task? _readLoopTask; private Task? _refreshTokenTask; private TaskCompletionSource? _readyTcs; @@ -111,15 +111,16 @@ public class DiscordRpcClient : IDiscordRpcClient public DiscordRpcClient(string clientId, string clientSecret, PluginSetting tokenSetting) { _clientId = clientId; - _authClient = new(clientId, clientSecret, tokenSetting); + // _authClient = new DiscordStreamKitAuthClient(clientId, clientSecret, tokenSetting); + _authClient = new DiscordStreamKitAuthClient(tokenSetting); + _transport = new DiscordWebSocketTransport(); _pendingRequests = new(); _cancellationTokenSource = new(); - _headerBuffer = new byte[HEADER_SIZE]; } public void Connect(int timeoutMs = 500) { - if (_pipe?.IsConnected == true) + if (_transport?.IsConnected == true) throw new InvalidOperationException("Already connected"); //we want discord to be open for at least 10 seconds @@ -135,8 +136,7 @@ public void Connect(int timeoutMs = 500) { try { - _pipe = new(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous); - _pipe.Connect(timeoutMs); + _transport.Connect("ws://127.0.0.1:6463", _cancellationTokenSource.Token); _readLoopTask = Task.Run(ReadLoop, _cancellationTokenSource.Token); Task.Run(InitializeAsync, _cancellationTokenSource.Token); return; @@ -173,19 +173,10 @@ public async Task GetAsync(DiscordRpcCommand command, params (string Key, return response.Data; } - private async Task HandshakeAsync() - { - string handshake = JsonConvert.SerializeObject(new { v = RPC_VERSION, client_id = _clientId }, _jsonSerializerSettings); - - await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE); - } - private async Task InitializeAsync() { _readyTcs = new(); - await HandshakeAsync(); - //this task will complete once the Ready event is received await _readyTcs.Task; @@ -241,11 +232,11 @@ private async Task HandleAuthenticationAsync() private async Task ReadLoop() { - while (!_cancellationTokenSource.IsCancellationRequested && _pipe?.IsConnected == true) + while (!_cancellationTokenSource.IsCancellationRequested && _transport?.IsConnected == true) { try { - var (opCode, data) = await ReadMessageAsync(); + var (opCode, data) = await _transport.ReadMessageAsync(_cancellationTokenSource.Token); await ProcessMessageAsync(opCode, data); } @@ -273,39 +264,11 @@ private async Task RefreshTokenLoop() } } - private async Task<(RpcPacketType, string)> ReadMessageAsync() - { - byte[]? dataBuffer = null; - try - { - int headerReadBytes = await _pipe!.ReadAsync(_headerBuffer.AsMemory(0, HEADER_SIZE), _cancellationTokenSource.Token); - - if (headerReadBytes < HEADER_SIZE) - throw new DiscordRpcClientException("Read less than 4 bytes for the header"); - - var header = MemoryMarshal.AsRef(_headerBuffer); - - if (header.PacketLength == 0) - throw new DiscordRpcClientException("Read zero bytes from the pipe"); - - dataBuffer = ArrayPool.Shared.Rent(header.PacketLength); - - await _pipe.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), _cancellationTokenSource.Token); - - return (header.PacketType, Encoding.UTF8.GetString(dataBuffer.AsSpan(0, header.PacketLength))); - } - finally - { - if (dataBuffer != null) - ArrayPool.Shared.Return(dataBuffer); - } - } - private async Task ProcessMessageAsync(RpcPacketType opCode, string data) { if (opCode == RpcPacketType.PING) { - await SendPacketAsync(data, RpcPacketType.PONG); + await _transport.SendPacketAsync(data, RpcPacketType.PONG, _cancellationTokenSource.Token); return; } if (opCode == RpcPacketType.CLOSE) @@ -356,31 +319,6 @@ private async Task ProcessMessageAsync(RpcPacketType opCode, string data) } } - private async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType) - { - int stringByteLength = Encoding.UTF8.GetByteCount(stringData); - int bufferSize = HEADER_SIZE + stringByteLength; - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - - try - { - if (!BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), (int)rpcPacketType)) - throw new DiscordRpcClientException("Error writing rpc packet type."); - - if (!BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stringByteLength)) - throw new DiscordRpcClientException("Error writing string byte length."); - - if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, HEADER_SIZE) != stringData.Length) - throw new DiscordRpcClientException("Wrote wrong number of characters."); - - await _pipe!.WriteAsync(buffer.AsMemory(0, bufferSize), _cancellationTokenSource.Token); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - private async Task SendRequestAsync(DiscordRequest request, int timeoutMs) { TaskCompletionSource responseCompletionSource = new(); @@ -389,7 +327,7 @@ private async Task SendRequestAsync(DiscordRequest request, int _pendingRequests.Add(request.Nonce, responseCompletionSource); //and send the actual request to the discord client. - await SendPacketAsync(JsonConvert.SerializeObject(request, _jsonSerializerSettings), RpcPacketType.FRAME); + await _transport.SendPacketAsync(JsonConvert.SerializeObject(request, _jsonSerializerSettings), RpcPacketType.FRAME, _cancellationTokenSource.Token); CancellationTokenSource timeoutToken = new(TimeSpan.FromMilliseconds(timeoutMs)); timeoutToken.Token.Register(() => responseCompletionSource.TrySetException(new TimeoutException($"Discord request timed out after {timeoutMs}"))); @@ -512,7 +450,7 @@ protected virtual void Dispose(bool disposing) try { _cancellationTokenSource.Cancel(); - _pipe?.Dispose(); + _transport?.Dispose(); _authClient.Dispose(); Error = null; @@ -550,3 +488,147 @@ public void Dispose() } #endregion } + + +internal interface IDiscordTransport : IDisposable +{ + bool IsConnected { get; } + void Connect(string uri, CancellationToken cancellationToken = default); + Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default); + Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default); +} + +public sealed class DiscordPipeTransport : IDiscordTransport +{ + private const string RPC_VERSION = "1"; + private const int HEADER_SIZE = 8; + private static readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy { ProcessDictionaryKeys = true } + } + }; + + private readonly byte[] _headerBuffer; + private NamedPipeClientStream? _pipe; + private readonly string _clientId; + + public DiscordPipeTransport(string clientId) + { + _clientId = clientId; + _headerBuffer = new byte[8]; + } + + public bool IsConnected => _pipe?.IsConnected == true; + + public void Connect(string uri, CancellationToken cancellationToken = default) + { + _pipe = new NamedPipeClientStream(".", uri, PipeDirection.InOut, PipeOptions.Asynchronous); + _pipe.ConnectAsync(cancellationToken).Wait(cancellationToken); + + string handshake = JsonConvert.SerializeObject(new { v = RPC_VERSION, client_id = _clientId }, _jsonSerializerSettings); + SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken).Wait(); + } + + public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) + { + int stringByteLength = Encoding.UTF8.GetByteCount(stringData); + int bufferSize = 8 + stringByteLength; + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); + + try + { + if (!BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), (int)rpcPacketType)) + throw new DiscordRpcClientException("Error writing rpc packet type."); + + if (!BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stringByteLength)) + throw new DiscordRpcClientException("Error writing string byte length."); + + if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, 8) != stringData.Length) + throw new DiscordRpcClientException("Wrote wrong number of characters."); + + await _pipe.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) + { + byte[]? dataBuffer = null; + try + { + int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, 8)); + + if (headerReadBytes < 8) + throw new DiscordRpcClientException("Read less than 4 bytes for the header"); + + var header = MemoryMarshal.AsRef(_headerBuffer); + + if (header.PacketLength == 0) + throw new DiscordRpcClientException("Read zero bytes from the pipe"); + + dataBuffer = ArrayPool.Shared.Rent(header.PacketLength); + + await _pipe.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), cancellationToken); + + return (header.PacketType, Encoding.UTF8.GetString(dataBuffer.AsSpan(0, header.PacketLength))); + } + finally + { + if (dataBuffer != null) + ArrayPool.Shared.Return(dataBuffer); + } + } + + public void Dispose() + { + _pipe.Dispose(); + } +} + +public sealed class DiscordWebSocketTransport : IDiscordTransport +{ + private readonly ClientWebSocket _webSocket = new(); + + public bool IsConnected => _webSocket.State == WebSocketState.Open; + + public void Connect(string uri, CancellationToken cancellationToken = default) + { + _webSocket.Options.SetRequestHeader("Origin", "https://streamkit.discord.com"); + _webSocket.ConnectAsync(new Uri($"{uri}?v=1&client_id=207646673902501888"), cancellationToken).Wait(); + } + + public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) + { + Debug.WriteLine($"Sending {rpcPacketType}: {stringData}"); + + var buffer = Encoding.UTF8.GetBytes(stringData); + + await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); + } + + public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) + { + var rent = ArrayPool.Shared.Rent(8192); + var result = await _webSocket.ReceiveAsync(rent, cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + throw new DiscordRpcClientException("WebSocket closed"); + + var packetData = Encoding.UTF8.GetString(rent.AsSpan(0, result.Count)); + ArrayPool.Shared.Return(rent); + + Debug.WriteLine($"Received {result.MessageType}: {packetData}"); + + return (RpcPacketType.FRAME, packetData); + } + + public void Dispose() + { + _webSocket.Dispose(); + } +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Enums/RpcPacketType.cs b/src/Artemis.Plugins.Modules.Discord/Enums/RpcPacketType.cs index db667b8..6505d58 100644 --- a/src/Artemis.Plugins.Modules.Discord/Enums/RpcPacketType.cs +++ b/src/Artemis.Plugins.Modules.Discord/Enums/RpcPacketType.cs @@ -1,6 +1,6 @@ namespace Artemis.Plugins.Modules.Discord.Enums; -internal enum RpcPacketType : int +public enum RpcPacketType : int { HANDSHAKE = 0, FRAME = 1, From 6cab8c7013e3eb087e46ebfb4c85230be3b0bce6 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 00:44:29 +0000 Subject: [PATCH 02/14] cleanup --- .../Authentication/DiscordAuthClient.cs | 17 +- .../DiscordModule.cs | 3 +- .../DiscordRpcClient.cs | 248 ++++++++++-------- .../IDiscordRpcClient.cs | 2 +- 4 files changed, 148 insertions(+), 122 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index 5c3cff8..dc58faa 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -18,7 +18,7 @@ public interface IDiscordAuthClient : IDisposable Task RefreshAccessTokenAsync(); } -public class DiscordAuthClient : IDiscordAuthClient +public class DiscordAuthClient : IDiscordAuthClient { private readonly string _clientId; private readonly string _clientSecret; @@ -130,7 +130,7 @@ public class DiscordStreamKitAuthClient : IDiscordAuthClient public DiscordStreamKitAuthClient(PluginSetting token) { _token = token; - _httpClient = new(); + _httpClient = new HttpClient(); } public bool HasToken => _token.Value != null; @@ -154,9 +154,9 @@ public async Task GetAccessTokenAsync(string challengeCode) { var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); - using HttpResponseMessage response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); + using var response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); - string responseString = await response.Content.ReadAsStringAsync(); + var responseString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { @@ -169,13 +169,10 @@ public async Task GetAccessTokenAsync(string challengeCode) return token; } - public async Task RefreshAccessTokenAsync() + public Task RefreshAccessTokenAsync() { - return; - if (!HasToken) - throw new InvalidOperationException("No token to refresh"); - - throw new NotImplementedException(); + // Streamkit tokens don't support refreshing, or at least I can't find anything about it + return Task.CompletedTask; } private void SaveToken(TokenResponse newToken) diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index 70a4965..6ca62f4 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -40,7 +40,8 @@ public DiscordModule(ILogger logger, PluginSettings pluginSettings) _clientId = pluginSettings.GetSetting("DiscordClientId", string.Empty); _clientSecret = pluginSettings.GetSetting("DiscordClientSecret", string.Empty); - _savedToken = pluginSettings.GetSetting("DiscordTokenStreamKit"); + //TODO: when deploying, find another name that is NOT "DiscordToken" because that's for the old token + _savedToken = pluginSettings.GetSetting("DiscordTokenDev"); _discordClientLock = new(); } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 2a19ab9..98838de 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -22,19 +22,14 @@ namespace Artemis.Plugins.Modules.Discord; public class DiscordRpcClient : IDiscordRpcClient { - #region RPC Constants - private const string RPC_VERSION = "1"; - private const int HEADER_SIZE = 8; - private static readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings + private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings { ContractResolver = new DefaultContractResolver { NamingStrategy = new SnakeCaseNamingStrategy { ProcessDictionaryKeys = true } } }; - #endregion - #region Fields private readonly Dictionary> _pendingRequests; private readonly CancellationTokenSource _cancellationTokenSource; private readonly string _clientId; @@ -43,9 +38,9 @@ public class DiscordRpcClient : IDiscordRpcClient private Task? _readLoopTask; private Task? _refreshTokenTask; private TaskCompletionSource? _readyTcs; - #endregion #region Events + /// /// Sent when an error occurs. /// @@ -110,17 +105,35 @@ public class DiscordRpcClient : IDiscordRpcClient public DiscordRpcClient(string clientId, string clientSecret, PluginSetting tokenSetting) { - _clientId = clientId; - // _authClient = new DiscordStreamKitAuthClient(clientId, clientSecret, tokenSetting); - _authClient = new DiscordStreamKitAuthClient(tokenSetting); - _transport = new DiscordWebSocketTransport(); - _pendingRequests = new(); - _cancellationTokenSource = new(); + _pendingRequests = new Dictionary>(); + _cancellationTokenSource = new CancellationTokenSource(); + + //DEBUG, REMOVE + tokenSetting.Value = null; + tokenSetting.Save(); + + var streamkit = true; + + if (streamkit) + { + _clientId = "207646673902501888"; + _transport = new DiscordWebSocketTransport( + _clientId, + "ws://127.0.0.1:6463", + "https://streamkit.discord.com"); + _authClient = new DiscordStreamKitAuthClient(tokenSetting); + } + else + { + _clientId = clientId; + _transport = new DiscordPipeTransport(_clientId); + _authClient = new DiscordAuthClient(_clientId, clientSecret, tokenSetting); + } } - public void Connect(int timeoutMs = 500) + public async Task Connect(int timeoutMs = 500) { - if (_transport?.IsConnected == true) + if (_transport.IsConnected) throw new InvalidOperationException("Already connected"); //we want discord to be open for at least 10 seconds @@ -129,25 +142,12 @@ public void Connect(int timeoutMs = 500) var now = DateTime.Now; var desiredTime = startTime + TimeSpan.FromSeconds(10); if (now < desiredTime) - Thread.Sleep(desiredTime - now); - - const int MAX_TRIES = 10; - for (int i = 0; i < MAX_TRIES; i++) - { - try - { - _transport.Connect("ws://127.0.0.1:6463", _cancellationTokenSource.Token); - _readLoopTask = Task.Run(ReadLoop, _cancellationTokenSource.Token); - Task.Run(InitializeAsync, _cancellationTokenSource.Token); - return; - } - catch (Exception e) - { - Error?.Invoke(this, new DiscordRpcClientException("Error connecting to discord", e)); - } - } + await Task.Delay(desiredTime - now); - throw new DiscordRpcClientException("Failed to connect to Discord"); + await _transport.Connect(_cancellationTokenSource.Token); + _readLoopTask = Task.Run(ReadLoop, _cancellationTokenSource.Token); + //fire and forget, it should be fiiiiine + _ = Task.Run(InitializeAsync, _cancellationTokenSource.Token); } public async Task SubscribeAsync(DiscordRpcEvent evt, params (string Key, object Value)[] parameters) @@ -168,32 +168,32 @@ public async Task GetAsync(DiscordRpcCommand command, params (string Key, { var request = new DiscordRequest(command, parameters); - DiscordResponse response = await SendRequestWithResponseTypeAsync(request); + var response = await SendRequestWithResponseTypeAsync(request); return response.Data; } private async Task InitializeAsync() { - _readyTcs = new(); + _readyTcs = new TaskCompletionSource(); //this task will complete once the Ready event is received await _readyTcs.Task; var authenticatedData = await HandleAuthenticationAsync(); _refreshTokenTask = Task.Run(RefreshTokenLoop, _cancellationTokenSource.Token); - + Authenticated?.Invoke(this, authenticatedData); } - + private async Task AuthorizeAsync() { - DiscordResponse authorizeResponse = await SendRequestWithResponseTypeAsync( + var authorizeResponse = await SendRequestWithResponseTypeAsync( new DiscordRequest(DiscordRpcCommand.AUTHORIZE, ("client_id", _clientId), ("scopes", new string[] { "rpc", "identify", "rpc.notifications.read" })), - timeoutMs: 30000);//high timeout so the user has time to click the button - + timeoutMs: 30000); //high timeout so the user has time to click the button + await _authClient.GetAccessTokenAsync(authorizeResponse.Data.Code); } @@ -206,7 +206,7 @@ private async Task HandleAuthenticationAsync() //to get a token from discord. await AuthorizeAsync(); } - + //Now that we have a token for sure, //we need to check if it expired or not. //If yes, refresh it. @@ -222,9 +222,9 @@ private async Task HandleAuthenticationAsync() await AuthorizeAsync(); } - DiscordResponse authenticateResponse = await SendRequestWithResponseTypeAsync( - new DiscordRequest(DiscordRpcCommand.AUTHENTICATE, - ("access_token", _authClient.AccessToken)) + var authenticateResponse = await SendRequestWithResponseTypeAsync( + new DiscordRequest(DiscordRpcCommand.AUTHENTICATE, + ("access_token", _authClient.AccessToken)) ); return authenticateResponse.Data; @@ -271,6 +271,7 @@ private async Task ProcessMessageAsync(RpcPacketType opCode, string data) await _transport.SendPacketAsync(data, RpcPacketType.PONG, _cancellationTokenSource.Token); return; } + if (opCode == RpcPacketType.CLOSE) { Error?.Invoke(this, new DiscordRpcClientException($"Discord sent RpcPacketType.CLOSE: {data}")); @@ -291,13 +292,13 @@ private async Task ProcessMessageAsync(RpcPacketType opCode, string data) //TODO: investigate //} - if (data.Contains("\"evt\":\"ERROR\""))//this looks kinda stupid ¯\_(ツ)_/¯ + if (data.Contains("\"evt\":\"ERROR\"")) //this looks kinda stupid ¯\_(ツ)_/¯ throw new DiscordRpcClientException($"Discord response contained an error: {data}"); IDiscordMessage discordMessage; try { - discordMessage = JsonConvert.DeserializeObject(data, _jsonSerializerSettings)!; + discordMessage = JsonConvert.DeserializeObject(data, JsonSerializerSettings)!; } catch (Exception exc) { @@ -327,10 +328,12 @@ private async Task SendRequestAsync(DiscordRequest request, int _pendingRequests.Add(request.Nonce, responseCompletionSource); //and send the actual request to the discord client. - await _transport.SendPacketAsync(JsonConvert.SerializeObject(request, _jsonSerializerSettings), RpcPacketType.FRAME, _cancellationTokenSource.Token); + await _transport.SendPacketAsync(JsonConvert.SerializeObject(request, JsonSerializerSettings), RpcPacketType.FRAME, + _cancellationTokenSource.Token); CancellationTokenSource timeoutToken = new(TimeSpan.FromMilliseconds(timeoutMs)); - timeoutToken.Token.Register(() => responseCompletionSource.TrySetException(new TimeoutException($"Discord request timed out after {timeoutMs}"))); + timeoutToken.Token.Register(() => + responseCompletionSource.TrySetException(new TimeoutException($"Discord request timed out after {timeoutMs}"))); //this will wait until the response with the expected Guid is received //and processed by the read loop. @@ -339,7 +342,7 @@ private async Task SendRequestAsync(DiscordRequest request, int private async Task> SendRequestWithResponseTypeAsync(DiscordRequest request, int timeoutMs = 1000) where T : class { - DiscordResponse response = await SendRequestAsync(request, timeoutMs); + var response = await SendRequestAsync(request, timeoutMs); if (response is not DiscordResponse typedResponse) throw new DiscordRpcClientException("Discord response was not of the specified type.", new InvalidCastException()); @@ -402,29 +405,6 @@ private void HandleEvent(DiscordEvent discordEvent) } } - #region Helper Pipe Methods - private static string GetPipeName(int index) - { - const string PIPE_NAME = "discord-ipc-{0}"; - return Environment.OSVersion.Platform switch - { - PlatformID.Unix => Path.Combine(GetTemporaryDirectory(), string.Format(PIPE_NAME, index)), - _ => string.Format(PIPE_NAME, index) - }; - } - - private static string GetTemporaryDirectory() - { - //source: https://github.com/Lachee/discord-rpc-csharp/ - //try all these possible paths it could be, depending on system configuration - return Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") ?? - Environment.GetEnvironmentVariable("TMPDIR") ?? - Environment.GetEnvironmentVariable("TMP") ?? - Environment.GetEnvironmentVariable("TEMP") ?? - "/tmp"; - } - #endregion - private DateTime GetDiscordStartTime() { var processNames = new string[] @@ -440,7 +420,9 @@ private DateTime GetDiscordStartTime() } #region IDisposable + private bool disposedValue; + protected virtual void Dispose(bool disposing) { if (!disposedValue) @@ -465,8 +447,15 @@ protected virtual void Dispose(bool disposing) VoiceStateUpdated = null; VoiceStateDeleted = null; - try { _readLoopTask?.Wait(); _refreshTokenTask?.Wait(); } - catch { /*catch everything*/ } + try + { + _readLoopTask?.Wait(); + _refreshTokenTask?.Wait(); + } + catch + { + /*catch everything*/ + } _cancellationTokenSource.Dispose(); } @@ -486,55 +475,60 @@ public void Dispose() Dispose(disposing: true); GC.SuppressFinalize(this); } + #endregion } - -internal interface IDiscordTransport : IDisposable +public interface IDiscordTransport : IDisposable { bool IsConnected { get; } - void Connect(string uri, CancellationToken cancellationToken = default); + Task Connect(CancellationToken cancellationToken = default); Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default); Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default); } public sealed class DiscordPipeTransport : IDiscordTransport { - private const string RPC_VERSION = "1"; - private const int HEADER_SIZE = 8; - private static readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings - { - ContractResolver = new DefaultContractResolver - { - NamingStrategy = new SnakeCaseNamingStrategy { ProcessDictionaryKeys = true } - } - }; + private const string RpcVersion = "1"; + private const int HeaderSize = 8; - private readonly byte[] _headerBuffer; - private NamedPipeClientStream? _pipe; + private readonly byte[] _headerBuffer = new byte[8]; private readonly string _clientId; - + private NamedPipeClientStream? _pipe; + public bool IsConnected => _pipe?.IsConnected == true; + public DiscordPipeTransport(string clientId) { _clientId = clientId; - _headerBuffer = new byte[8]; } - - public bool IsConnected => _pipe?.IsConnected == true; - public void Connect(string uri, CancellationToken cancellationToken = default) + public async Task Connect(CancellationToken cancellationToken = default) { - _pipe = new NamedPipeClientStream(".", uri, PipeDirection.InOut, PipeOptions.Asynchronous); - _pipe.ConnectAsync(cancellationToken).Wait(cancellationToken); + const int MAX_TRIES = 10; + for (var i = 0; i < MAX_TRIES; i++) + { + try + { + _pipe = new NamedPipeClientStream(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous); + await _pipe.ConnectAsync(cancellationToken); + break; + } + catch (Exception e) + { + //TODO: how to log here?? ignore? + Debug.WriteLine($"Error connecting to pipe {i}: {e.Message}"); + } + } - string handshake = JsonConvert.SerializeObject(new { v = RPC_VERSION, client_id = _clientId }, _jsonSerializerSettings); - SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken).Wait(); + var handshake = JsonConvert.SerializeObject(new { v = RpcVersion, client_id = _clientId }); + + await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken); } public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) { int stringByteLength = Encoding.UTF8.GetByteCount(stringData); - int bufferSize = 8 + stringByteLength; + int bufferSize = HeaderSize + stringByteLength; byte[] buffer = ArrayPool.Shared.Rent(bufferSize); try @@ -545,7 +539,7 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType if (!BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stringByteLength)) throw new DiscordRpcClientException("Error writing string byte length."); - if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, 8) != stringData.Length) + if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, HeaderSize) != stringData.Length) throw new DiscordRpcClientException("Wrote wrong number of characters."); await _pipe.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); @@ -561,9 +555,9 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType byte[]? dataBuffer = null; try { - int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, 8)); + int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, HeaderSize)); - if (headerReadBytes < 8) + if (headerReadBytes < HeaderSize) throw new DiscordRpcClientException("Read less than 4 bytes for the header"); var header = MemoryMarshal.AsRef(_headerBuffer); @@ -588,29 +582,63 @@ public void Dispose() { _pipe.Dispose(); } + + + private static string GetPipeName(int index) + { + const string PIPE_NAME = "discord-ipc-{0}"; + return Environment.OSVersion.Platform switch + { + PlatformID.Unix => Path.Combine(GetTemporaryDirectory(), string.Format(PIPE_NAME, index)), + _ => string.Format(PIPE_NAME, index) + }; + } + + private static string GetTemporaryDirectory() + { + //source: https://github.com/Lachee/discord-rpc-csharp/ + //try all these possible paths it could be, depending on system configuration + return Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") ?? + Environment.GetEnvironmentVariable("TMPDIR") ?? + Environment.GetEnvironmentVariable("TMP") ?? + Environment.GetEnvironmentVariable("TEMP") ?? + "/tmp"; + } + } public sealed class DiscordWebSocketTransport : IDiscordTransport { - private readonly ClientWebSocket _webSocket = new(); + private readonly ClientWebSocket _webSocket; + private readonly string _clientId; + private readonly string _uri; + private readonly string _origin; public bool IsConnected => _webSocket.State == WebSocketState.Open; - public void Connect(string uri, CancellationToken cancellationToken = default) + public DiscordWebSocketTransport(string clientId, string uri, string origin) { - _webSocket.Options.SetRequestHeader("Origin", "https://streamkit.discord.com"); - _webSocket.ConnectAsync(new Uri($"{uri}?v=1&client_id=207646673902501888"), cancellationToken).Wait(); + _webSocket = new ClientWebSocket(); + _clientId = clientId; + _uri = uri; + _origin = origin; + } + + public async Task Connect(CancellationToken cancellationToken = default) + { + _webSocket.Options.SetRequestHeader("Origin", _origin); + await _webSocket.ConnectAsync(new Uri($"{_uri}?v=1&client_id={_clientId}"), cancellationToken); } public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) { Debug.WriteLine($"Sending {rpcPacketType}: {stringData}"); - + var buffer = Encoding.UTF8.GetBytes(stringData); await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); } - + public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) { var rent = ArrayPool.Shared.Rent(8192); @@ -618,15 +646,15 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType if (result.MessageType == WebSocketMessageType.Close) throw new DiscordRpcClientException("WebSocket closed"); - + var packetData = Encoding.UTF8.GetString(rent.AsSpan(0, result.Count)); ArrayPool.Shared.Return(rent); Debug.WriteLine($"Received {result.MessageType}: {packetData}"); - + return (RpcPacketType.FRAME, packetData); } - + public void Dispose() { _webSocket.Dispose(); diff --git a/src/Artemis.Plugins.Modules.Discord/IDiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/IDiscordRpcClient.cs index 874ec25..d87b98f 100644 --- a/src/Artemis.Plugins.Modules.Discord/IDiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/IDiscordRpcClient.cs @@ -21,7 +21,7 @@ public interface IDiscordRpcClient : IDisposable event EventHandler VoiceStateDeleted; event EventHandler VoiceStateUpdated; - void Connect(int timeoutMs = 500); + Task Connect(int timeoutMs = 500); Task GetAsync(DiscordRpcCommand command, params (string Key, object Value)[] parameters) where T : class; Task SubscribeAsync(DiscordRpcEvent evt, params (string Key, object Value)[] parameters); Task UnsubscribeAsync(DiscordRpcEvent evt, params (string Key, object Value)[] parameters); From 1eaadc39bf9ccceffa08b4c11559ef3d1f9cc4e7 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 01:20:47 +0000 Subject: [PATCH 03/14] clarified why transport is pipes and not ws --- .../Authentication/DiscordAuthClient.cs | 102 +------- .../DiscordStreamKitAuthClient.cs | 98 ++++++++ .../Authentication/IDiscordAuthClient.cs | 14 ++ .../DiscordModule.cs | 2 +- .../DiscordRpcClient.cs | 233 +++--------------- .../Transport/DiscordPipeTransport.cs | 133 ++++++++++ .../Transport/DiscordWebSocketTransport.cs | 65 +++++ .../Transport/IDiscordTransport.cs | 14 ++ 8 files changed, 354 insertions(+), 307 deletions(-) create mode 100644 src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs create mode 100644 src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs create mode 100644 src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs create mode 100644 src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs create mode 100644 src/Artemis.Plugins.Modules.Discord/Transport/IDiscordTransport.cs diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index dc58faa..e603a74 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -3,21 +3,10 @@ using System; using System.Collections.Generic; using System.Net.Http; -using System.Text; using System.Threading.Tasks; namespace Artemis.Plugins.Modules.Discord.Authentication; -public interface IDiscordAuthClient : IDisposable -{ - bool HasToken { get; } - bool IsTokenValid { get; } - string AccessToken { get; } - Task RefreshTokenIfNeededAsync(); - Task GetAccessTokenAsync(string challengeCode); - Task RefreshAccessTokenAsync(); -} - public class DiscordAuthClient : IDiscordAuthClient { private readonly string _clientId; @@ -120,93 +109,4 @@ public void Dispose() GC.SuppressFinalize(this); } #endregion -} - -public class DiscordStreamKitAuthClient : IDiscordAuthClient -{ - private readonly PluginSetting _token; - private readonly HttpClient _httpClient; - - public DiscordStreamKitAuthClient(PluginSetting token) - { - _token = token; - _httpClient = new HttpClient(); - } - - public bool HasToken => _token.Value != null; - - public bool IsTokenValid => HasToken && _token.Value!.ExpirationDate >= DateTime.UtcNow; - - public string AccessToken => _token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); - - public async Task RefreshTokenIfNeededAsync() - { - if (!HasToken) - return; - - if (_token.Value!.ExpirationDate >= DateTime.UtcNow.AddDays(1)) - return; - - await RefreshAccessTokenAsync(); - } - - public async Task GetAccessTokenAsync(string challengeCode) - { - var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); - - using var response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); - - var responseString = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - throw new UnauthorizedAccessException(responseString); - } - - var token = JsonConvert.DeserializeObject(responseString)!; - SaveToken(token); - - return token; - } - - public Task RefreshAccessTokenAsync() - { - // Streamkit tokens don't support refreshing, or at least I can't find anything about it - return Task.CompletedTask; - } - - private void SaveToken(TokenResponse newToken) - { - _token.Value = new SavedToken - { - AccessToken = newToken.AccessToken, - RefreshToken = newToken.RefreshToken, - ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) - }; - _token.Save(); - } - - #region IDisposable - private bool disposedValue; - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - _httpClient?.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - #endregion -} +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs new file mode 100644 index 0000000..af5483d --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Artemis.Core; +using Newtonsoft.Json; + +namespace Artemis.Plugins.Modules.Discord.Authentication; + +public class DiscordStreamKitAuthClient : IDiscordAuthClient +{ + private readonly PluginSetting _token; + private readonly HttpClient _httpClient; + + public DiscordStreamKitAuthClient(PluginSetting token) + { + _token = token; + _httpClient = new HttpClient(); + } + + public bool HasToken => _token.Value != null; + + public bool IsTokenValid => HasToken && _token.Value!.ExpirationDate >= DateTime.UtcNow; + + public string AccessToken => _token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); + + public async Task RefreshTokenIfNeededAsync() + { + if (!HasToken) + return; + + if (_token.Value!.ExpirationDate >= DateTime.UtcNow.AddDays(1)) + return; + + await RefreshAccessTokenAsync(); + } + + public async Task GetAccessTokenAsync(string challengeCode) + { + var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); + + using var response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); + + var responseString = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new UnauthorizedAccessException(responseString); + } + + var token = JsonConvert.DeserializeObject(responseString)!; + SaveToken(token); + + return token; + } + + public Task RefreshAccessTokenAsync() + { + // Streamkit tokens don't support refreshing, or at least I can't find anything about it + return Task.CompletedTask; + } + + private void SaveToken(TokenResponse newToken) + { + _token.Value = new SavedToken + { + AccessToken = newToken.AccessToken, + RefreshToken = newToken.RefreshToken, + ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) + }; + _token.Save(); + } + + #region IDisposable + private bool disposedValue; + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + _httpClient?.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs new file mode 100644 index 0000000..d821e08 --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading.Tasks; + +namespace Artemis.Plugins.Modules.Discord.Authentication; + +public interface IDiscordAuthClient : IDisposable +{ + bool HasToken { get; } + bool IsTokenValid { get; } + string AccessToken { get; } + Task RefreshTokenIfNeededAsync(); + Task GetAccessTokenAsync(string challengeCode); + Task RefreshAccessTokenAsync(); +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index 6ca62f4..520ed5c 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -151,7 +151,7 @@ private async void OnAuthenticated(object? sender, Authenticate e) //Subscribe to these events as well await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_SETTINGS_UPDATE); - //await _discordClient.SubscribeAsync(DiscordRpcEvent.NOTIFICATION_CREATE); + await _discordClient.SubscribeAsync(DiscordRpcEvent.NOTIFICATION_CREATE); await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_CONNECTION_STATUS); await _discordClient.SubscribeAsync(DiscordRpcEvent.VOICE_CHANNEL_SELECT); } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 98838de..cfe3518 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -6,22 +6,21 @@ using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using System; -using System.Buffers; using System.Collections.Generic; using System.Diagnostics; -using System.IO; -using System.IO.Pipes; using System.Linq; -using System.Net.WebSockets; -using System.Runtime.InteropServices; -using System.Text; using System.Threading; using System.Threading.Tasks; +using Artemis.Plugins.Modules.Discord.Transport; namespace Artemis.Plugins.Modules.Discord; public class DiscordRpcClient : IDiscordRpcClient { + private const string StreamkitClientId = "207646673902501888"; + private const string StreamkitOrigin = "https://streamkit.discord.com"; + private const string StreamkitWebsocketUri = "ws://localhost:6463"; + private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings { ContractResolver = new DefaultContractResolver @@ -112,23 +111,29 @@ public DiscordRpcClient(string clientId, string clientSecret, PluginSetting ReadMessageAsync(CancellationToken cancellationToken = default); -} - -public sealed class DiscordPipeTransport : IDiscordTransport -{ - private const string RpcVersion = "1"; - private const int HeaderSize = 8; - - private readonly byte[] _headerBuffer = new byte[8]; - private readonly string _clientId; - private NamedPipeClientStream? _pipe; - public bool IsConnected => _pipe?.IsConnected == true; - - public DiscordPipeTransport(string clientId) - { - _clientId = clientId; - } - - public async Task Connect(CancellationToken cancellationToken = default) - { - const int MAX_TRIES = 10; - for (var i = 0; i < MAX_TRIES; i++) - { - try - { - _pipe = new NamedPipeClientStream(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous); - await _pipe.ConnectAsync(cancellationToken); - break; - } - catch (Exception e) - { - //TODO: how to log here?? ignore? - Debug.WriteLine($"Error connecting to pipe {i}: {e.Message}"); - } - } - - var handshake = JsonConvert.SerializeObject(new { v = RpcVersion, client_id = _clientId }); - - await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken); - } - - public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) - { - int stringByteLength = Encoding.UTF8.GetByteCount(stringData); - int bufferSize = HeaderSize + stringByteLength; - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - - try - { - if (!BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), (int)rpcPacketType)) - throw new DiscordRpcClientException("Error writing rpc packet type."); - - if (!BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stringByteLength)) - throw new DiscordRpcClientException("Error writing string byte length."); - - if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, HeaderSize) != stringData.Length) - throw new DiscordRpcClientException("Wrote wrong number of characters."); - - await _pipe.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) - { - byte[]? dataBuffer = null; - try - { - int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, HeaderSize)); - - if (headerReadBytes < HeaderSize) - throw new DiscordRpcClientException("Read less than 4 bytes for the header"); - - var header = MemoryMarshal.AsRef(_headerBuffer); - - if (header.PacketLength == 0) - throw new DiscordRpcClientException("Read zero bytes from the pipe"); - - dataBuffer = ArrayPool.Shared.Rent(header.PacketLength); - - await _pipe.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), cancellationToken); - - return (header.PacketType, Encoding.UTF8.GetString(dataBuffer.AsSpan(0, header.PacketLength))); - } - finally - { - if (dataBuffer != null) - ArrayPool.Shared.Return(dataBuffer); - } - } - - public void Dispose() - { - _pipe.Dispose(); - } - - - private static string GetPipeName(int index) - { - const string PIPE_NAME = "discord-ipc-{0}"; - return Environment.OSVersion.Platform switch - { - PlatformID.Unix => Path.Combine(GetTemporaryDirectory(), string.Format(PIPE_NAME, index)), - _ => string.Format(PIPE_NAME, index) - }; - } - - private static string GetTemporaryDirectory() - { - //source: https://github.com/Lachee/discord-rpc-csharp/ - //try all these possible paths it could be, depending on system configuration - return Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") ?? - Environment.GetEnvironmentVariable("TMPDIR") ?? - Environment.GetEnvironmentVariable("TMP") ?? - Environment.GetEnvironmentVariable("TEMP") ?? - "/tmp"; - } - -} - -public sealed class DiscordWebSocketTransport : IDiscordTransport -{ - private readonly ClientWebSocket _webSocket; - private readonly string _clientId; - private readonly string _uri; - private readonly string _origin; - - public bool IsConnected => _webSocket.State == WebSocketState.Open; - - public DiscordWebSocketTransport(string clientId, string uri, string origin) - { - _webSocket = new ClientWebSocket(); - _clientId = clientId; - _uri = uri; - _origin = origin; - } - - public async Task Connect(CancellationToken cancellationToken = default) - { - _webSocket.Options.SetRequestHeader("Origin", _origin); - await _webSocket.ConnectAsync(new Uri($"{_uri}?v=1&client_id={_clientId}"), cancellationToken); - } - - public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) - { - Debug.WriteLine($"Sending {rpcPacketType}: {stringData}"); - - var buffer = Encoding.UTF8.GetBytes(stringData); - - await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); - } - - public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) - { - var rent = ArrayPool.Shared.Rent(8192); - var result = await _webSocket.ReceiveAsync(rent, cancellationToken); - - if (result.MessageType == WebSocketMessageType.Close) - throw new DiscordRpcClientException("WebSocket closed"); - - var packetData = Encoding.UTF8.GetString(rent.AsSpan(0, result.Count)); - ArrayPool.Shared.Return(rent); - - Debug.WriteLine($"Received {result.MessageType}: {packetData}"); - - return (RpcPacketType.FRAME, packetData); - } - - public void Dispose() - { - _webSocket.Dispose(); - } } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs new file mode 100644 index 0000000..eb2aeb4 --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs @@ -0,0 +1,133 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Plugins.Modules.Discord.Enums; +using Newtonsoft.Json; + +namespace Artemis.Plugins.Modules.Discord.Transport; + +public sealed class DiscordPipeTransport : IDiscordTransport +{ + private const string RpcVersion = "1"; + private const int HeaderSize = 8; + + private readonly byte[] _headerBuffer = new byte[8]; + private readonly string _clientId; + private NamedPipeClientStream? _pipe; + public bool IsConnected => _pipe?.IsConnected == true; + + public DiscordPipeTransport(string clientId) + { + _clientId = clientId; + } + + public async Task Connect(CancellationToken cancellationToken = default) + { + const int MAX_TRIES = 10; + for (var i = 0; i < MAX_TRIES; i++) + { + try + { + _pipe = new NamedPipeClientStream(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous); + await _pipe.ConnectAsync(cancellationToken); + break; + } + catch (Exception e) + { + //TODO: how to log here?? ignore? + Debug.WriteLine($"Error connecting to pipe {i}: {e.Message}"); + } + } + + var handshake = JsonConvert.SerializeObject(new { v = RpcVersion, client_id = _clientId }); + + await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken); + } + + public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) + { + int stringByteLength = Encoding.UTF8.GetByteCount(stringData); + int bufferSize = HeaderSize + stringByteLength; + byte[] buffer = ArrayPool.Shared.Rent(bufferSize); + + try + { + if (!BitConverter.TryWriteBytes(buffer.AsSpan(0, 4), (int)rpcPacketType)) + throw new DiscordRpcClientException("Error writing rpc packet type."); + + if (!BitConverter.TryWriteBytes(buffer.AsSpan(4, 4), stringByteLength)) + throw new DiscordRpcClientException("Error writing string byte length."); + + if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, HeaderSize) != stringData.Length) + throw new DiscordRpcClientException("Wrote wrong number of characters."); + + await _pipe.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) + { + byte[]? dataBuffer = null; + try + { + int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, HeaderSize)); + + if (headerReadBytes < HeaderSize) + throw new DiscordRpcClientException("Read less than 4 bytes for the header"); + + var header = MemoryMarshal.AsRef(_headerBuffer); + + if (header.PacketLength == 0) + throw new DiscordRpcClientException("Read zero bytes from the pipe"); + + dataBuffer = ArrayPool.Shared.Rent(header.PacketLength); + + await _pipe.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), cancellationToken); + + return (header.PacketType, Encoding.UTF8.GetString(dataBuffer.AsSpan(0, header.PacketLength))); + } + finally + { + if (dataBuffer != null) + ArrayPool.Shared.Return(dataBuffer); + } + } + + public void Dispose() + { + _pipe.Dispose(); + } + + + private static string GetPipeName(int index) + { + const string PIPE_NAME = "discord-ipc-{0}"; + return Environment.OSVersion.Platform switch + { + PlatformID.Unix => Path.Combine(GetTemporaryDirectory(), string.Format(PIPE_NAME, index)), + _ => string.Format(PIPE_NAME, index) + }; + } + + private static string GetTemporaryDirectory() + { + //source: https://github.com/Lachee/discord-rpc-csharp/ + //try all these possible paths it could be, depending on system configuration + return Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") ?? + Environment.GetEnvironmentVariable("TMPDIR") ?? + Environment.GetEnvironmentVariable("TMP") ?? + Environment.GetEnvironmentVariable("TEMP") ?? + "/tmp"; + } + +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs new file mode 100644 index 0000000..dbe3f82 --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs @@ -0,0 +1,65 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Plugins.Modules.Discord.Enums; + +namespace Artemis.Plugins.Modules.Discord.Transport; + +[Obsolete("Use DiscordPipeTransport instead, this one doesn't support notification events...")] +public sealed class DiscordWebSocketTransport : IDiscordTransport +{ + private readonly ClientWebSocket _webSocket; + private readonly string _clientId; + private readonly string _uri; + private readonly string _origin; + + public bool IsConnected => _webSocket.State == WebSocketState.Open; + + public DiscordWebSocketTransport(string clientId, string uri, string origin) + { + _webSocket = new ClientWebSocket(); + _clientId = clientId; + _uri = uri; + _origin = origin; + } + + public async Task Connect(CancellationToken cancellationToken = default) + { + _webSocket.Options.SetRequestHeader("Origin", _origin); + await _webSocket.ConnectAsync(new Uri($"{_uri}?v=1&client_id={_clientId}"), cancellationToken); + } + + public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) + { + Debug.WriteLine($"Sending {rpcPacketType}: {stringData}"); + + var buffer = Encoding.UTF8.GetBytes(stringData); + + await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); + } + + public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) + { + var rent = ArrayPool.Shared.Rent(8192); + var result = await _webSocket.ReceiveAsync(rent, cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + throw new DiscordRpcClientException("WebSocket closed"); + + var packetData = Encoding.UTF8.GetString(rent.AsSpan(0, result.Count)); + ArrayPool.Shared.Return(rent); + + Debug.WriteLine($"Received {result.MessageType}: {packetData}"); + + return (RpcPacketType.FRAME, packetData); + } + + public void Dispose() + { + _webSocket.Dispose(); + } +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/IDiscordTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/IDiscordTransport.cs new file mode 100644 index 0000000..95bd63f --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Transport/IDiscordTransport.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Artemis.Plugins.Modules.Discord.Enums; + +namespace Artemis.Plugins.Modules.Discord.Transport; + +public interface IDiscordTransport : IDisposable +{ + bool IsConnected { get; } + Task Connect(CancellationToken cancellationToken = default); + Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default); + Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file From af6314290afc3e91871e704c38291fe1e75b9d3b Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 01:25:08 +0000 Subject: [PATCH 04/14] remove old client id and secret configuration dialog is kept just in case but not user accessible. --- .../DiscordModule.cs | 31 ++----------------- .../DiscordPluginBootstrapper.cs | 3 +- .../DiscordRpcClient.cs | 6 +--- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index 520ed5c..5a2bf87 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -19,8 +19,6 @@ public class DiscordModule : Module public override List ActivationRequirements { get; } private readonly ILogger _logger; - private readonly PluginSetting _clientId; - private readonly PluginSetting _clientSecret; private readonly PluginSetting _savedToken; private readonly object _discordClientLock; @@ -37,21 +35,12 @@ public DiscordModule(ILogger logger, PluginSettings pluginSettings) }; _logger = logger; - - _clientId = pluginSettings.GetSetting("DiscordClientId", string.Empty); - _clientSecret = pluginSettings.GetSetting("DiscordClientSecret", string.Empty); - //TODO: when deploying, find another name that is NOT "DiscordToken" because that's for the old token - _savedToken = pluginSettings.GetSetting("DiscordTokenDev"); - - _discordClientLock = new(); + _savedToken = pluginSettings.GetSetting("DiscordTokenStreamKit"); + _discordClientLock = new object(); } public override void Enable() { - if (!AreClientIdAndSecretValid()) - { - _logger.Error("Discord client ID or secret invalid"); - } } public override void Disable() @@ -67,12 +56,6 @@ public override void ModuleActivated(bool isOverride) if (isOverride) return; - if (!AreClientIdAndSecretValid()) - { - _logger.Error("Discord client ID or secret invalid"); - return; - } - ConnectToDiscord(); } @@ -83,12 +66,9 @@ public override void ModuleDeactivated(bool isOverride) private void ConnectToDiscord() { - ArgumentException.ThrowIfNullOrEmpty(_clientId.Value, nameof(_clientId)); - ArgumentException.ThrowIfNullOrEmpty(_clientSecret.Value, nameof(_clientSecret)); - lock (_discordClientLock) { - _discordClient = new DiscordRpcClient(_clientId.Value, _clientSecret.Value, _savedToken); + _discordClient = new DiscordRpcClient(_savedToken); _discordClient.Authenticated += OnAuthenticated; _discordClient.Error += OnError; _discordClient.NotificationReceived += OnNotificationReceived; @@ -344,9 +324,4 @@ private Task UnsubscribeFromVoiceChannelEvents() //await discordClient.UnsubscribeAsync(DiscordRpcEvent.VOICE_STATE_DELETE, ("channel_id", channelId)); return Task.CompletedTask; } - - private bool AreClientIdAndSecretValid() - { - return _clientId.Value?.All(c => char.IsDigit(c)) == true && _clientSecret.Value?.Length > 0; - } } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs index a1b0b69..de0363c 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs @@ -8,6 +8,7 @@ public class DiscordPluginBootstrapper : PluginBootstrapper { public override void OnPluginLoaded(Plugin plugin) { - plugin.ConfigurationDialog = new PluginConfigurationDialog(); + //New version works without any configuration, so we don't need this + //plugin.ConfigurationDialog = new PluginConfigurationDialog(); } } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index cfe3518..40acdab 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -102,15 +102,11 @@ public class DiscordRpcClient : IDiscordRpcClient #endregion - public DiscordRpcClient(string clientId, string clientSecret, PluginSetting tokenSetting) + public DiscordRpcClient(PluginSetting tokenSetting) { _pendingRequests = new Dictionary>(); _cancellationTokenSource = new CancellationTokenSource(); - //DEBUG, REMOVE - tokenSetting.Value = null; - tokenSetting.Save(); - //1. the websocket transport works fine, but we lose access to the notifications event. //the pipe transport has it, so we'll use that for now. From f7a404fe2a036a9201df6fd1e98b75ec1f93ad23 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 01:35:06 +0000 Subject: [PATCH 05/14] cleanup pipe init --- .../Transport/DiscordPipeTransport.cs | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs index eb2aeb4..4f473ab 100644 --- a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordPipeTransport.cs @@ -1,6 +1,5 @@ using System; using System.Buffers; -using System.Diagnostics; using System.IO; using System.IO.Pipes; using System.Runtime.InteropServices; @@ -36,18 +35,19 @@ public async Task Connect(CancellationToken cancellationToken = default) { _pipe = new NamedPipeClientStream(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous); await _pipe.ConnectAsync(cancellationToken); - break; + + var handshake = JsonConvert.SerializeObject(new { v = RpcVersion, client_id = _clientId }); + + await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken); + return; } - catch (Exception e) + catch { - //TODO: how to log here?? ignore? - Debug.WriteLine($"Error connecting to pipe {i}: {e.Message}"); + await Task.Delay(1000, cancellationToken); } } - var handshake = JsonConvert.SerializeObject(new { v = RpcVersion, client_id = _clientId }); - - await SendPacketAsync(handshake, RpcPacketType.HANDSHAKE, cancellationToken); + throw new DiscordRpcClientException("Could not connect to any pipe."); } public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) @@ -67,7 +67,7 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType if (Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, buffer, HeaderSize) != stringData.Length) throw new DiscordRpcClientException("Wrote wrong number of characters."); - await _pipe.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); + await _pipe!.WriteAsync(buffer.AsMemory(0, bufferSize), cancellationToken); } finally { @@ -80,7 +80,7 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType byte[]? dataBuffer = null; try { - int headerReadBytes = await _pipe.ReadAsync(_headerBuffer.AsMemory(0, HeaderSize)); + int headerReadBytes = await _pipe!.ReadAsync(_headerBuffer.AsMemory(0, HeaderSize)); if (headerReadBytes < HeaderSize) throw new DiscordRpcClientException("Read less than 4 bytes for the header"); @@ -92,7 +92,7 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType dataBuffer = ArrayPool.Shared.Rent(header.PacketLength); - await _pipe.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), cancellationToken); + await _pipe!.ReadAsync(dataBuffer.AsMemory(0, header.PacketLength), cancellationToken); return (header.PacketType, Encoding.UTF8.GetString(dataBuffer.AsSpan(0, header.PacketLength))); } @@ -105,18 +105,16 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType public void Dispose() { - _pipe.Dispose(); + _pipe!.Dispose(); } - private static string GetPipeName(int index) { - const string PIPE_NAME = "discord-ipc-{0}"; - return Environment.OSVersion.Platform switch - { - PlatformID.Unix => Path.Combine(GetTemporaryDirectory(), string.Format(PIPE_NAME, index)), - _ => string.Format(PIPE_NAME, index) - }; + var pipeName = $"discord-ipc-{index}"; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return pipeName; + + return Path.Combine(GetTemporaryDirectory(), pipeName); } private static string GetTemporaryDirectory() @@ -129,5 +127,4 @@ private static string GetTemporaryDirectory() Environment.GetEnvironmentVariable("TEMP") ?? "/tmp"; } - } \ No newline at end of file From 62909cb8898af2f9bc440a7eb443b02310304299 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 13:03:29 +0000 Subject: [PATCH 06/14] add auth with razer --- .../Authentication/DiscordAuthClient.cs | 81 +++++-------------- .../Authentication/DiscordRazerAuthClient.cs | 60 ++++++++++++++ .../DiscordStreamKitAuthClient.cs | 61 ++------------ .../Authentication/IDiscordAuthClient.cs | 31 ++++++- ...del.cs => DiscordVoiceChannelDataModel.cs} | 0 .../DiscordModule.cs | 6 +- .../DiscordRpcClient.cs | 48 +++-------- .../Transport/DiscordWebSocketTransport.cs | 2 + 8 files changed, 132 insertions(+), 157 deletions(-) create mode 100644 src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs rename src/Artemis.Plugins.Modules.Discord/DataModels/{VoiceChannelDataModel.cs => DiscordVoiceChannelDataModel.cs} (100%) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index e603a74..1c4eb1c 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -2,56 +2,49 @@ using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; namespace Artemis.Plugins.Modules.Discord.Authentication; -public class DiscordAuthClient : IDiscordAuthClient +public class DiscordAuthClient : DiscordAuthClientBase { - private readonly string _clientId; + public override string ClientId { get; } private readonly string _clientSecret; - private readonly PluginSetting _token; private readonly HttpClient _httpClient; - public DiscordAuthClient(string clientId, string clientSecret, PluginSetting token) + public DiscordAuthClient(PluginSettings settings) : base(settings.GetSetting("DiscordToken")) { - _clientId = clientId; - _clientSecret = clientSecret; - _token = token; + var clientIdSetting = settings.GetSetting("DiscordClientId"); + var clientSecretSetting = settings.GetSetting("DiscordClientSecret"); + + if (!AreClientIdAndSecretValid(clientIdSetting, clientSecretSetting)) + throw new InvalidOperationException("Invalid client id or secret. Please check your settings."); + + ClientId = clientIdSetting.Value!; + _clientSecret = clientSecretSetting.Value!; _httpClient = new(); } - - public bool HasToken => _token.Value != null; - - public bool IsTokenValid => HasToken && _token.Value!.ExpirationDate >= DateTime.UtcNow; - - public string AccessToken => _token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); - - public async Task RefreshTokenIfNeededAsync() + + private bool AreClientIdAndSecretValid(PluginSetting clientId, PluginSetting clientSecret) { - if (!HasToken) - return; - - if (_token.Value!.ExpirationDate >= DateTime.UtcNow.AddDays(1)) - return; - - await RefreshAccessTokenAsync(); + return clientId.Value?.All(c => char.IsDigit(c)) == true && clientSecret.Value?.Length > 0; } - public async Task GetAccessTokenAsync(string challengeCode) + public override async Task GetAccessTokenAsync(string challengeCode) { var token = await GetCredentialsAsync("authorization_code", "code", challengeCode); SaveToken(token); return token; } - public async Task RefreshAccessTokenAsync() + public override async Task RefreshAccessTokenAsync() { if (!HasToken) throw new InvalidOperationException("No token to refresh"); - TokenResponse token = await GetCredentialsAsync("refresh_token", "refresh_token", _token.Value!.RefreshToken); + TokenResponse token = await GetCredentialsAsync("refresh_token", "refresh_token", Token.Value!.RefreshToken); SaveToken(token); } @@ -61,7 +54,7 @@ private async Task GetCredentialsAsync(string grantType, string s { ["grant_type"] = grantType, [secretType] = secret, - ["client_id"] = _clientId, + ["client_id"] = ClientId, ["client_secret"] = _clientSecret }; @@ -74,39 +67,9 @@ private async Task GetCredentialsAsync(string grantType, string s return JsonConvert.DeserializeObject(responseString)!; } - - private void SaveToken(TokenResponse newToken) - { - _token.Value = new SavedToken - { - AccessToken = newToken.AccessToken, - RefreshToken = newToken.RefreshToken, - ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) - }; - _token.Save(); - } - - #region IDisposable - private bool disposedValue; - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - _httpClient?.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() + + public override void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); + _httpClient.Dispose(); } - #endregion } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs new file mode 100644 index 0000000..5afcd0b --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Artemis.Core; +using Newtonsoft.Json; + +namespace Artemis.Plugins.Modules.Discord.Authentication; + +public class DiscordRazerAuthClient : DiscordAuthClientBase +{ + private const string RefreshEndpoint = "https://chroma.razer.com/discord/refreshtoken.php"; + private const string GrantEndpoint = "https://chroma.razer.com/discord/grant.php"; + private const string RedirectUri = "http://chroma.razer.com/discord/"; + + private readonly HttpClient _http = new HttpClient(); + + public DiscordRazerAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenRazer")) + { + } + + public override string ClientId => "331511201655685132"; + + public override async Task GetAccessTokenAsync(string challengeCode) + { + var values = new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "authorization_code", + ["code"] = challengeCode, + ["redirect_uri"] = RedirectUri + }; + + using var response = await _http.PostAsync(GrantEndpoint, new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); + var token = JsonConvert.DeserializeObject(responseString); + SaveToken(token); + return token; + } + + public override async Task RefreshAccessTokenAsync() + { + var values = new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "refresh_token", + ["refresh_token"] = Token.Value.RefreshToken, + ["redirect_uri"] = RedirectUri + }; + + using var response = await _http.PostAsync(RefreshEndpoint, new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); + var tkn = JsonConvert.DeserializeObject(responseString); + SaveToken(tkn); + } + + public override void Dispose() + { + _http.Dispose(); + } +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs index af5483d..5743304 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs @@ -8,35 +8,18 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; -public class DiscordStreamKitAuthClient : IDiscordAuthClient +public class DiscordStreamKitAuthClient : DiscordAuthClientBase { - private readonly PluginSetting _token; private readonly HttpClient _httpClient; - public DiscordStreamKitAuthClient(PluginSetting token) + public DiscordStreamKitAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenStreamKit")) { - _token = token; _httpClient = new HttpClient(); } - public bool HasToken => _token.Value != null; + public override string ClientId => "207646673902501888"; - public bool IsTokenValid => HasToken && _token.Value!.ExpirationDate >= DateTime.UtcNow; - - public string AccessToken => _token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); - - public async Task RefreshTokenIfNeededAsync() - { - if (!HasToken) - return; - - if (_token.Value!.ExpirationDate >= DateTime.UtcNow.AddDays(1)) - return; - - await RefreshAccessTokenAsync(); - } - - public async Task GetAccessTokenAsync(string challengeCode) + public override async Task GetAccessTokenAsync(string challengeCode) { var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); @@ -55,44 +38,14 @@ public async Task GetAccessTokenAsync(string challengeCode) return token; } - public Task RefreshAccessTokenAsync() + public override Task RefreshAccessTokenAsync() { // Streamkit tokens don't support refreshing, or at least I can't find anything about it return Task.CompletedTask; } - private void SaveToken(TokenResponse newToken) - { - _token.Value = new SavedToken - { - AccessToken = newToken.AccessToken, - RefreshToken = newToken.RefreshToken, - ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) - }; - _token.Save(); - } - - #region IDisposable - private bool disposedValue; - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - _httpClient?.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() + public override void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); + _httpClient.Dispose(); } - #endregion } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs index d821e08..a1c8608 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs @@ -1,14 +1,41 @@ using System; using System.Threading.Tasks; +using Artemis.Core; namespace Artemis.Plugins.Modules.Discord.Authentication; public interface IDiscordAuthClient : IDisposable { + string ClientId { get; } bool HasToken { get; } - bool IsTokenValid { get; } string AccessToken { get; } - Task RefreshTokenIfNeededAsync(); Task GetAccessTokenAsync(string challengeCode); Task RefreshAccessTokenAsync(); +} + +public abstract class DiscordAuthClientBase : IDiscordAuthClient +{ + protected readonly PluginSetting Token; + protected DiscordAuthClientBase(PluginSetting token) + { + Token = token; + } + + public abstract string ClientId { get; } + public bool HasToken => Token.Value != null; + public string AccessToken => Token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); + public abstract Task GetAccessTokenAsync(string challengeCode); + public abstract Task RefreshAccessTokenAsync(); + public abstract void Dispose(); + + protected void SaveToken(TokenResponse newToken) + { + Token.Value = new SavedToken + { + AccessToken = newToken.AccessToken, + RefreshToken = newToken.RefreshToken, + ExpirationDate = DateTime.UtcNow.AddSeconds(newToken.ExpiresIn) + }; + Token.Save(); + } } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/DataModels/VoiceChannelDataModel.cs b/src/Artemis.Plugins.Modules.Discord/DataModels/DiscordVoiceChannelDataModel.cs similarity index 100% rename from src/Artemis.Plugins.Modules.Discord/DataModels/VoiceChannelDataModel.cs rename to src/Artemis.Plugins.Modules.Discord/DataModels/DiscordVoiceChannelDataModel.cs diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index 5a2bf87..4f6b696 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -19,7 +19,7 @@ public class DiscordModule : Module public override List ActivationRequirements { get; } private readonly ILogger _logger; - private readonly PluginSetting _savedToken; + private readonly PluginSettings _pluginSettings; private readonly object _discordClientLock; private IDiscordRpcClient? _discordClient; @@ -35,7 +35,7 @@ public DiscordModule(ILogger logger, PluginSettings pluginSettings) }; _logger = logger; - _savedToken = pluginSettings.GetSetting("DiscordTokenStreamKit"); + _pluginSettings = pluginSettings; _discordClientLock = new object(); } @@ -68,7 +68,7 @@ private void ConnectToDiscord() { lock (_discordClientLock) { - _discordClient = new DiscordRpcClient(_savedToken); + _discordClient = new DiscordRpcClient(_pluginSettings); _discordClient.Authenticated += OnAuthenticated; _discordClient.Error += OnError; _discordClient.NotificationReceived += OnNotificationReceived; diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 40acdab..79bd67c 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -1,5 +1,4 @@ using Artemis.Core; -using Artemis.Plugins.Modules.Discord.Authentication; using Artemis.Plugins.Modules.Discord.DiscordPackets; using Artemis.Plugins.Modules.Discord.DiscordPackets.CommandData; using Artemis.Plugins.Modules.Discord.Enums; @@ -11,17 +10,14 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Artemis.Plugins.Modules.Discord.Authentication; using Artemis.Plugins.Modules.Discord.Transport; namespace Artemis.Plugins.Modules.Discord; public class DiscordRpcClient : IDiscordRpcClient { - private const string StreamkitClientId = "207646673902501888"; - private const string StreamkitOrigin = "https://streamkit.discord.com"; - private const string StreamkitWebsocketUri = "ws://localhost:6463"; - - private static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings + private static readonly JsonSerializerSettings JsonSerializerSettings = new() { ContractResolver = new DefaultContractResolver { @@ -31,11 +27,9 @@ public class DiscordRpcClient : IDiscordRpcClient private readonly Dictionary> _pendingRequests; private readonly CancellationTokenSource _cancellationTokenSource; - private readonly string _clientId; private readonly IDiscordAuthClient _authClient; private readonly IDiscordTransport _transport; private Task? _readLoopTask; - private Task? _refreshTokenTask; private TaskCompletionSource? _readyTcs; #region Events @@ -102,7 +96,7 @@ public class DiscordRpcClient : IDiscordRpcClient #endregion - public DiscordRpcClient(PluginSetting tokenSetting) + public DiscordRpcClient(PluginSettings settings) { _pendingRequests = new Dictionary>(); _cancellationTokenSource = new CancellationTokenSource(); @@ -121,15 +115,8 @@ public DiscordRpcClient(PluginSetting tokenSetting) //4. Idea: It would be relatively easy to add Razer/Logitech/Steelseries clientId's and auth to this // just in case one of them breaks. - - _clientId = StreamkitClientId; - _transport = new DiscordPipeTransport(_clientId); - _authClient = new DiscordStreamKitAuthClient(tokenSetting); - - //old impl for custom clientIds - // _clientId = clientId; - // _transport = new DiscordPipeTransport(_clientId); - // _authClient = new DiscordAuthClient(_clientId, clientSecret, tokenSetting); + _authClient = new DiscordRazerAuthClient(settings); + _transport = new DiscordPipeTransport(_authClient.ClientId); } public async Task Connect(int timeoutMs = 500) @@ -182,7 +169,6 @@ private async Task InitializeAsync() await _readyTcs.Task; var authenticatedData = await HandleAuthenticationAsync(); - _refreshTokenTask = Task.Run(RefreshTokenLoop, _cancellationTokenSource.Token); Authenticated?.Invoke(this, authenticatedData); } @@ -191,13 +177,14 @@ private async Task AuthorizeAsync() { var authorizeResponse = await SendRequestWithResponseTypeAsync( new DiscordRequest(DiscordRpcCommand.AUTHORIZE, - ("client_id", _clientId), + ("client_id", _authClient.ClientId), ("scopes", new string[] { "rpc", "identify", "rpc.notifications.read" })), timeoutMs: 30000); //high timeout so the user has time to click the button await _authClient.GetAccessTokenAsync(authorizeResponse.Data.Code); } + //TODO: fix private async Task HandleAuthenticationAsync() { if (!_authClient.HasToken) @@ -214,6 +201,7 @@ private async Task HandleAuthenticationAsync() //Then, authenticate. try { + //TODO: What if we do not support refreshing? await _authClient.RefreshAccessTokenAsync(); } catch (UnauthorizedAccessException e) @@ -248,23 +236,6 @@ private async Task ReadLoop() } } - private async Task RefreshTokenLoop() - { - while (!_cancellationTokenSource.IsCancellationRequested) - { - try - { - await _authClient.RefreshTokenIfNeededAsync(); - - await Task.Delay(TimeSpan.FromDays(1), _cancellationTokenSource.Token); - } - catch - { - //we can safely ignore this error - } - } - } - private async Task ProcessMessageAsync(RpcPacketType opCode, string data) { if (opCode == RpcPacketType.PING) @@ -433,7 +404,7 @@ protected virtual void Dispose(bool disposing) try { _cancellationTokenSource.Cancel(); - _transport?.Dispose(); + _transport.Dispose(); _authClient.Dispose(); Error = null; @@ -451,7 +422,6 @@ protected virtual void Dispose(bool disposing) try { _readLoopTask?.Wait(); - _refreshTokenTask?.Wait(); } catch { diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs index dbe3f82..307f78d 100644 --- a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs @@ -12,6 +12,8 @@ namespace Artemis.Plugins.Modules.Discord.Transport; [Obsolete("Use DiscordPipeTransport instead, this one doesn't support notification events...")] public sealed class DiscordWebSocketTransport : IDiscordTransport { + private const string StreamkitWebsocketUri = "ws://localhost:6463"; + private readonly ClientWebSocket _webSocket; private readonly string _clientId; private readonly string _uri; From 7872e36dc2e19586daca1ec1c693171de6ed98f6 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 13:11:32 +0000 Subject: [PATCH 07/14] dry --- .../Authentication/DiscordAuthClient.cs | 9 +-------- .../Authentication/DiscordRazerAuthClient.cs | 11 ++--------- .../Authentication/DiscordStreamKitAuthClient.cs | 9 +-------- .../Authentication/IDiscordAuthClient.cs | 7 ++++++- .../DiscordRpcClient.cs | 4 +--- 5 files changed, 11 insertions(+), 29 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index 1c4eb1c..231ce15 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -12,7 +12,6 @@ public class DiscordAuthClient : DiscordAuthClientBase { public override string ClientId { get; } private readonly string _clientSecret; - private readonly HttpClient _httpClient; public DiscordAuthClient(PluginSettings settings) : base(settings.GetSetting("DiscordToken")) { @@ -24,7 +23,6 @@ public DiscordAuthClient(PluginSettings settings) : base(settings.GetSetting clientId, PluginSetting clientSecret) @@ -58,7 +56,7 @@ private async Task GetCredentialsAsync(string grantType, string s ["client_secret"] = _clientSecret }; - using HttpResponseMessage response = await _httpClient.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(values)); + using HttpResponseMessage response = await HttpClient.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(values)); string responseString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { @@ -67,9 +65,4 @@ private async Task GetCredentialsAsync(string grantType, string s return JsonConvert.DeserializeObject(responseString)!; } - - public override void Dispose() - { - _httpClient.Dispose(); - } } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs index 5afcd0b..8cd6c60 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs @@ -12,8 +12,6 @@ public class DiscordRazerAuthClient : DiscordAuthClientBase private const string GrantEndpoint = "https://chroma.razer.com/discord/grant.php"; private const string RedirectUri = "http://chroma.razer.com/discord/"; - private readonly HttpClient _http = new HttpClient(); - public DiscordRazerAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenRazer")) { } @@ -30,7 +28,7 @@ public override async Task GetAccessTokenAsync(string challengeCo ["redirect_uri"] = RedirectUri }; - using var response = await _http.PostAsync(GrantEndpoint, new FormUrlEncodedContent(values)); + using var response = await HttpClient.PostAsync(GrantEndpoint, new FormUrlEncodedContent(values)); var responseString = await response.Content.ReadAsStringAsync(); var token = JsonConvert.DeserializeObject(responseString); SaveToken(token); @@ -47,14 +45,9 @@ public override async Task RefreshAccessTokenAsync() ["redirect_uri"] = RedirectUri }; - using var response = await _http.PostAsync(RefreshEndpoint, new FormUrlEncodedContent(values)); + using var response = await HttpClient.PostAsync(RefreshEndpoint, new FormUrlEncodedContent(values)); var responseString = await response.Content.ReadAsStringAsync(); var tkn = JsonConvert.DeserializeObject(responseString); SaveToken(tkn); } - - public override void Dispose() - { - _http.Dispose(); - } } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs index 5743304..3b4ca2d 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs @@ -10,11 +10,9 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; public class DiscordStreamKitAuthClient : DiscordAuthClientBase { - private readonly HttpClient _httpClient; public DiscordStreamKitAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenStreamKit")) { - _httpClient = new HttpClient(); } public override string ClientId => "207646673902501888"; @@ -23,7 +21,7 @@ public override async Task GetAccessTokenAsync(string challengeCo { var body = new StringContent(JsonConvert.SerializeObject(new { code = challengeCode }), Encoding.UTF8, "application/json"); - using var response = await _httpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); + using var response = await HttpClient.PostAsync("https://streamkit.discord.com/overlay/token", body); var responseString = await response.Content.ReadAsStringAsync(); @@ -43,9 +41,4 @@ public override Task RefreshAccessTokenAsync() // Streamkit tokens don't support refreshing, or at least I can't find anything about it return Task.CompletedTask; } - - public override void Dispose() - { - _httpClient.Dispose(); - } } \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs index a1c8608..c5c4331 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs @@ -1,4 +1,5 @@ using System; +using System.Net.Http; using System.Threading.Tasks; using Artemis.Core; @@ -15,6 +16,7 @@ public interface IDiscordAuthClient : IDisposable public abstract class DiscordAuthClientBase : IDiscordAuthClient { + protected HttpClient HttpClient = new(); protected readonly PluginSetting Token; protected DiscordAuthClientBase(PluginSetting token) { @@ -26,7 +28,10 @@ protected DiscordAuthClientBase(PluginSetting token) public string AccessToken => Token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); public abstract Task GetAccessTokenAsync(string challengeCode); public abstract Task RefreshAccessTokenAsync(); - public abstract void Dispose(); + public void Dispose() + { + HttpClient.Dispose(); + } protected void SaveToken(TokenResponse newToken) { diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 79bd67c..23fb547 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -112,9 +112,7 @@ public DiscordRpcClient(PluginSettings settings) //3. The authentication is different for the streamkit client. Discord is using something similar to // Razer, Logitech, Steelseries etc where they have a worker on some cloud accepting challenge codes // and returning tokens. For our own clientIds, we can just use the normal oauth flow. - - //4. Idea: It would be relatively easy to add Razer/Logitech/Steelseries clientId's and auth to this - // just in case one of them breaks. + _authClient = new DiscordRazerAuthClient(settings); _transport = new DiscordPipeTransport(_authClient.ClientId); } From 7364721f895149c2614514332dad0f69035145b5 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 13:15:54 +0000 Subject: [PATCH 08/14] cleanup --- .../Authentication/DiscordAuthClient.cs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index 231ce15..6eb742d 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -32,7 +32,22 @@ private bool AreClientIdAndSecretValid(PluginSetting clientId, PluginSet public override async Task GetAccessTokenAsync(string challengeCode) { - var token = await GetCredentialsAsync("authorization_code", "code", challengeCode); + Dictionary values = new() + { + ["grant_type"] = "authorization_code", + ["code"] = challengeCode, + ["client_id"] = ClientId, + ["client_secret"] = _clientSecret + }; + + using var response = await HttpClient.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + throw new UnauthorizedAccessException(responseString); + } + + var token = JsonConvert.DeserializeObject(responseString)!; SaveToken(token); return token; } @@ -42,27 +57,22 @@ public override async Task RefreshAccessTokenAsync() if (!HasToken) throw new InvalidOperationException("No token to refresh"); - TokenResponse token = await GetCredentialsAsync("refresh_token", "refresh_token", Token.Value!.RefreshToken); - SaveToken(token); - } - - private async Task GetCredentialsAsync(string grantType, string secretType, string secret) - { Dictionary values = new() { - ["grant_type"] = grantType, - [secretType] = secret, + ["grant_type"] = "refresh_token", + ["refresh_token"] = Token.Value!.RefreshToken, ["client_id"] = ClientId, ["client_secret"] = _clientSecret }; - using HttpResponseMessage response = await HttpClient.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(values)); - string responseString = await response.Content.ReadAsStringAsync(); + using var response = await HttpClient.PostAsync("https://discord.com/api/oauth2/token", new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new UnauthorizedAccessException(responseString); } - return JsonConvert.DeserializeObject(responseString)!; + var token = JsonConvert.DeserializeObject(responseString)!; + SaveToken(token); } } \ No newline at end of file From 713a3f3c8aa4791d5d8950bfa05226f43d3c6021 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 13:26:04 +0000 Subject: [PATCH 09/14] addded other auth clients --- .../Authentication/LogitechAuthClient.cs | 48 +++++++++++++++++++ ...dRazerAuthClient.cs => RazerAuthClient.cs} | 4 +- .../Authentication/SteelseriesAuthClient.cs | 48 +++++++++++++++++++ ...itAuthClient.cs => StreamKitAuthClient.cs} | 4 +- .../DiscordRpcClient.cs | 2 +- 5 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs rename src/Artemis.Plugins.Modules.Discord/Authentication/{DiscordRazerAuthClient.cs => RazerAuthClient.cs} (91%) create mode 100644 src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs rename src/Artemis.Plugins.Modules.Discord/Authentication/{DiscordStreamKitAuthClient.cs => StreamKitAuthClient.cs} (86%) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs new file mode 100644 index 0000000..a4191f9 --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Artemis.Core; +using Newtonsoft.Json; + +namespace Artemis.Plugins.Modules.Discord.Authentication; + +public class LogitechAuthClient : DiscordAuthClientBase +{ + private const string TokenEndpoint = "https://ymj1tb3arf.execute-api.us-east-1.amazonaws.com/prod/create_discord_access_token"; + + public LogitechAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenLogitech")) + { + } + + public override string ClientId => "227491271223017472"; + + public override async Task GetAccessTokenAsync(string challengeCode) + { + var values = new Dictionary + { + ["code"] = challengeCode + }; + + using var httpContent = new StringContent(JsonConvert.SerializeObject(values), Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync(TokenEndpoint, httpContent); + var responseString = await response.Content.ReadAsStringAsync(); + var token = JsonConvert.DeserializeObject(responseString); + SaveToken(token); + return token; + } + + public override async Task RefreshAccessTokenAsync() + { + var values = new Dictionary + { + ["refresh_token"] = Token.Value.RefreshToken + }; + + using var httpContent = new StringContent(JsonConvert.SerializeObject(values), Encoding.UTF8, "application/json"); + using var response = await HttpClient.PostAsync(TokenEndpoint, httpContent); + var responseString = await response.Content.ReadAsStringAsync(); + var token = JsonConvert.DeserializeObject(responseString); + SaveToken(token); + } +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs similarity index 91% rename from src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs rename to src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs index 8cd6c60..58bf49b 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordRazerAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs @@ -6,13 +6,13 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; -public class DiscordRazerAuthClient : DiscordAuthClientBase +public class RazerAuthClient : DiscordAuthClientBase { private const string RefreshEndpoint = "https://chroma.razer.com/discord/refreshtoken.php"; private const string GrantEndpoint = "https://chroma.razer.com/discord/grant.php"; private const string RedirectUri = "http://chroma.razer.com/discord/"; - public DiscordRazerAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenRazer")) + public RazerAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenRazer")) { } diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs new file mode 100644 index 0000000..8871180 --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Artemis.Core; +using Newtonsoft.Json; + +namespace Artemis.Plugins.Modules.Discord.Authentication; + +public class SteelseriesAuthClient : DiscordAuthClientBase +{ + private const string TokenEndpoint = "https://id.steelseries.com/discord/auth"; + + public SteelseriesAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenSteelseries")) + { + } + + public override string ClientId => "211138759029293067"; + public override async Task GetAccessTokenAsync(string challengeCode) + { + var values = new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "authorization_code", + ["code"] = challengeCode + }; + + using var response = await HttpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); + var token = JsonConvert.DeserializeObject(responseString); + SaveToken(token); + return token; + } + + public override async Task RefreshAccessTokenAsync() + { + var values = new Dictionary + { + ["client_id"] = ClientId, + ["grant_type"] = "refresh_token", + ["refresh_token"] = Token.Value.RefreshToken + }; + + using var response = await HttpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(values)); + var responseString = await response.Content.ReadAsStringAsync(); + var token = JsonConvert.DeserializeObject(responseString); + SaveToken(token); + } +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs similarity index 86% rename from src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs rename to src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs index 3b4ca2d..0166926 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordStreamKitAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs @@ -8,10 +8,10 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; -public class DiscordStreamKitAuthClient : DiscordAuthClientBase +public class StreamKitAuthClient : DiscordAuthClientBase { - public DiscordStreamKitAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenStreamKit")) + public StreamKitAuthClient(PluginSettings token) : base(token.GetSetting("DiscordTokenStreamKit")) { } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index 23fb547..f1c8cd5 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -113,7 +113,7 @@ public DiscordRpcClient(PluginSettings settings) // Razer, Logitech, Steelseries etc where they have a worker on some cloud accepting challenge codes // and returning tokens. For our own clientIds, we can just use the normal oauth flow. - _authClient = new DiscordRazerAuthClient(settings); + _authClient = new StreamKitAuthClient(settings); _transport = new DiscordPipeTransport(_authClient.ClientId); } From 4aa5ba9bcba84d73574fa9e3bbe46e38d83383d4 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 13:42:04 +0000 Subject: [PATCH 10/14] make other authclients work over ws transport --- .../Authentication/DiscordAuthClient.cs | 3 ++- .../Authentication/IDiscordAuthClient.cs | 2 ++ .../Authentication/LogitechAuthClient.cs | 3 ++- .../Authentication/RazerAuthClient.cs | 1 + .../Authentication/SteelseriesAuthClient.cs | 2 ++ .../Authentication/StreamKitAuthClient.cs | 1 + .../DiscordRpcClient.cs | 20 ++++++++----------- .../Transport/DiscordWebSocketTransport.cs | 9 +++------ 8 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs index 6eb742d..593b591 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/DiscordAuthClient.cs @@ -1,4 +1,4 @@ -using Artemis.Core; +using Artemis.Core; using Newtonsoft.Json; using System; using System.Collections.Generic; @@ -11,6 +11,7 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; public class DiscordAuthClient : DiscordAuthClientBase { public override string ClientId { get; } + public override string Origin => throw new NotSupportedException("Discord does not support a custom origin"); private readonly string _clientSecret; public DiscordAuthClient(PluginSettings settings) : base(settings.GetSetting("DiscordToken")) diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs index c5c4331..5db7a9f 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/IDiscordAuthClient.cs @@ -8,6 +8,7 @@ namespace Artemis.Plugins.Modules.Discord.Authentication; public interface IDiscordAuthClient : IDisposable { string ClientId { get; } + string Origin { get; } bool HasToken { get; } string AccessToken { get; } Task GetAccessTokenAsync(string challengeCode); @@ -24,6 +25,7 @@ protected DiscordAuthClientBase(PluginSetting token) } public abstract string ClientId { get; } + public abstract string Origin { get; } public bool HasToken => Token.Value != null; public string AccessToken => Token.Value?.AccessToken ?? throw new InvalidOperationException("No token available"); public abstract Task GetAccessTokenAsync(string challengeCode); diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs index a4191f9..de5a5e3 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/LogitechAuthClient.cs @@ -16,7 +16,8 @@ public LogitechAuthClient(PluginSettings token) : base(token.GetSetting "227491271223017472"; - + public override string Origin => "http://localhost"; + public override async Task GetAccessTokenAsync(string challengeCode) { var values = new Dictionary diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs index 58bf49b..16eacea 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/RazerAuthClient.cs @@ -17,6 +17,7 @@ public RazerAuthClient(PluginSettings token) : base(token.GetSetting } public override string ClientId => "331511201655685132"; + public override string Origin => "http://chroma.razer.com"; public override async Task GetAccessTokenAsync(string challengeCode) { diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs index 8871180..9771c16 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/SteelseriesAuthClient.cs @@ -15,6 +15,8 @@ public SteelseriesAuthClient(PluginSettings token) : base(token.GetSetting "211138759029293067"; + public override string Origin => "https://steelseries.com"; + public override async Task GetAccessTokenAsync(string challengeCode) { var values = new Dictionary diff --git a/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs b/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs index 0166926..5a2fe83 100644 --- a/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/Authentication/StreamKitAuthClient.cs @@ -16,6 +16,7 @@ public StreamKitAuthClient(PluginSettings token) : base(token.GetSetting "207646673902501888"; + public override string Origin => "https://streamkit.discord.com"; public override async Task GetAccessTokenAsync(string challengeCode) { diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index f1c8cd5..c32d4aa 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -101,20 +101,16 @@ public DiscordRpcClient(PluginSettings settings) _pendingRequests = new Dictionary>(); _cancellationTokenSource = new CancellationTokenSource(); - //1. the websocket transport works fine, but we lose access to the notifications event. - //the pipe transport has it, so we'll use that for now. - - //2. the pipe transport works for both our custom clientIds and the streamkit one, - // but the websocket transport only works for the streamkit. This is because the websocket transport - // requires the origin header to be set correctly, which we can't do with our custom clientIds (afaik). - // _transport = new DiscordWebSocketTransport(_clientId, StreamkitWebsocketUri, StreamkitOrigin); - - //3. The authentication is different for the streamkit client. Discord is using something similar to - // Razer, Logitech, Steelseries etc where they have a worker on some cloud accepting challenge codes - // and returning tokens. For our own clientIds, we can just use the normal oauth flow. + //TODO: sometimes subscribing to NOTIFICATION_CREATE throws an error. investigate. + // it might be dependent on the transport used, not 100% sure. _authClient = new StreamKitAuthClient(settings); - _transport = new DiscordPipeTransport(_authClient.ClientId); + // _authClient = new RazerAuthClient(settings); + // _authClient = new SteelseriesAuthClient(settings); + // _authClient = new LogitechAuthClient(settings); + _transport = new DiscordWebSocketTransport(_authClient.ClientId, _authClient.Origin); + // _transport = new DiscordPipeTransport(_authClient.ClientId); + } public async Task Connect(int timeoutMs = 500) diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs index 307f78d..55d67f3 100644 --- a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs @@ -9,30 +9,27 @@ namespace Artemis.Plugins.Modules.Discord.Transport; -[Obsolete("Use DiscordPipeTransport instead, this one doesn't support notification events...")] public sealed class DiscordWebSocketTransport : IDiscordTransport { - private const string StreamkitWebsocketUri = "ws://localhost:6463"; + private const string WebsocketUri = "ws://localhost:6463"; private readonly ClientWebSocket _webSocket; private readonly string _clientId; - private readonly string _uri; private readonly string _origin; public bool IsConnected => _webSocket.State == WebSocketState.Open; - public DiscordWebSocketTransport(string clientId, string uri, string origin) + public DiscordWebSocketTransport(string clientId, string origin) { _webSocket = new ClientWebSocket(); _clientId = clientId; - _uri = uri; _origin = origin; } public async Task Connect(CancellationToken cancellationToken = default) { _webSocket.Options.SetRequestHeader("Origin", _origin); - await _webSocket.ConnectAsync(new Uri($"{_uri}?v=1&client_id={_clientId}"), cancellationToken); + await _webSocket.ConnectAsync(new Uri($"{WebsocketUri}?v=1&client_id={_clientId}"), cancellationToken); } public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) From e14df349bfec213be5b8ae75d2cae27df5c7a3c4 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 16:20:05 +0000 Subject: [PATCH 11/14] clean up config UI --- .../DiscordPluginBootstrapper.cs | 3 +- .../DiscordPluginConfigurationView.axaml | 103 ++++++++++++++++-- .../DiscordPluginConfigurationViewModel.cs | 66 ++++------- .../DiscordRpcProvider.cs | 14 +++ .../DiscordRpcClient.cs | 16 ++- .../Transport/DiscordWebSocketTransport.cs | 2 +- 6 files changed, 139 insertions(+), 65 deletions(-) create mode 100644 src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordRpcProvider.cs diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs index de0363c..a1b0b69 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginBootstrapper.cs @@ -8,7 +8,6 @@ public class DiscordPluginBootstrapper : PluginBootstrapper { public override void OnPluginLoaded(Plugin plugin) { - //New version works without any configuration, so we don't need this - //plugin.ConfigurationDialog = new PluginConfigurationDialog(); + plugin.ConfigurationDialog = new PluginConfigurationDialog(); } } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationView.axaml b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationView.axaml index 5936a08..91854fe 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationView.axaml +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationView.axaml @@ -2,18 +2,99 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:local="clr-namespace:Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration" + xmlns:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared" + xmlns:converters="clr-namespace:Artemis.UI.Shared.Converters;assembly=Artemis.UI.Shared" xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + x:DataType="local:DiscordPluginConfigurationViewModel" x:Class="Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration.DiscordPluginConfigurationView"> + + + - - - - - - - Help - - - - + + + + + + + Discord Rpc Provider + + + Sets the provider used for authentication. Custom allows you to use your own client id and secret. Check the wiki for more information. + + + + + + + + + + + + + Client Id + + + Only required if you use the custom provider. Used to authenticate your application with discord. You can find it in the discord developer portal. + + + + + + + + + + + + + Client Secret + + + Only required if you use the custom provider. Used to authenticate your application with discord. You can find it in the discord developer portal. + + + + + + + + + + + + + Wiki + + + Check the wiki for information before making any changes, this should work out of the box. + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs index ebb6aa3..70e1f69 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs @@ -1,58 +1,32 @@ -using Artemis.Core; +using System; +using System.Collections.ObjectModel; +using Artemis.Core; using Artemis.UI.Shared; using ReactiveUI; -using ReactiveUI.Validation.Extensions; -using System.Linq; -using System.Reactive; +using System.Reactive.Disposables; namespace Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration; -#pragma warning disable CS8618 - public class DiscordPluginConfigurationViewModel : PluginConfigurationViewModel { - private readonly PluginSetting _clientIdSetting; - private readonly PluginSetting _clientSecretSetting; - private string _clientId; - private string _clientSecret; - - public DiscordPluginConfigurationViewModel( - Plugin plugin, - PluginSettings pluginSettings) - : base(plugin) - { - _clientIdSetting = pluginSettings.GetSetting("DiscordClientId", string.Empty); - _clientSecretSetting = pluginSettings.GetSetting("DiscordClientSecret", string.Empty); - - ClientId = _clientIdSetting.Value!; - ClientSecret = _clientSecretSetting.Value!; - - this.ValidationRule(vm => vm.ClientId, clientId => clientId!.All(c => char.IsDigit(c)), "Client Id must be only number characters"); - this.ValidationRule(vm => vm.ClientSecret, clientSecret => clientSecret?.Length > 0, "Client Secret must not be empty"); - - Save = ReactiveCommand.Create(ExecuteSave, ValidationContext.Valid); - } + private readonly PluginSettings _pluginSettings; - public string ClientId + public DiscordPluginConfigurationViewModel(Plugin plugin, PluginSettings pluginSettings) : base(plugin) { - get => _clientId; - set => RaiseAndSetIfChanged(ref _clientId, value); + _pluginSettings = pluginSettings; + + this.WhenActivated(d => + { + Disposable.Create(() => + { + _pluginSettings.SaveAllSettings(); + }).DisposeWith(d); + }); } + + public PluginSetting ClientIdSetting => _pluginSettings.GetSetting("DiscordClientId", string.Empty); + public PluginSetting ClientSecretSetting => _pluginSettings.GetSetting("DiscordClientSecret", string.Empty); + public PluginSetting Provider => _pluginSettings.GetSetting("DiscordRpcProvider", DiscordRpcProvider.StreamKit); - public string ClientSecret - { - get => _clientSecret; - set => RaiseAndSetIfChanged(ref _clientSecret, value); - } - - public ReactiveCommand Save { get; } - - public void ExecuteSave() - { - _clientIdSetting.Value = ClientId; - _clientIdSetting.Save(); - - _clientSecretSetting.Value = ClientSecret; - _clientSecretSetting.Save(); - } + public ObservableCollection RpcProviders { get; } = new(Enum.GetNames(typeof(DiscordRpcProvider))); } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordRpcProvider.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordRpcProvider.cs new file mode 100644 index 0000000..97b8fda --- /dev/null +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordRpcProvider.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration; + +public enum DiscordRpcProvider +{ + //streamkit is the default + [Description("StreamKit (Recommended)")] + StreamKit = 0, + Custom, + Razer, + Steelseries, + Logitech, +} \ No newline at end of file diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index c32d4aa..de648c7 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using Artemis.Plugins.Modules.Discord.Authentication; +using Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration; using Artemis.Plugins.Modules.Discord.Transport; namespace Artemis.Plugins.Modules.Discord; @@ -100,17 +101,22 @@ public DiscordRpcClient(PluginSettings settings) { _pendingRequests = new Dictionary>(); _cancellationTokenSource = new CancellationTokenSource(); + var rpcType = settings.GetSetting("RpcProvider", DiscordRpcProvider.StreamKit); //TODO: sometimes subscribing to NOTIFICATION_CREATE throws an error. investigate. // it might be dependent on the transport used, not 100% sure. + _authClient = rpcType.Value switch + { + DiscordRpcProvider.StreamKit => new StreamKitAuthClient(settings), + DiscordRpcProvider.Custom => new DiscordAuthClient(settings), + DiscordRpcProvider.Razer => new RazerAuthClient(settings), + DiscordRpcProvider.Steelseries => new SteelseriesAuthClient(settings), + DiscordRpcProvider.Logitech => new LogitechAuthClient(settings), + _ => throw new ArgumentOutOfRangeException() + }; - _authClient = new StreamKitAuthClient(settings); - // _authClient = new RazerAuthClient(settings); - // _authClient = new SteelseriesAuthClient(settings); - // _authClient = new LogitechAuthClient(settings); _transport = new DiscordWebSocketTransport(_authClient.ClientId, _authClient.Origin); // _transport = new DiscordPipeTransport(_authClient.ClientId); - } public async Task Connect(int timeoutMs = 500) diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs index 55d67f3..6ed38e4 100644 --- a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs @@ -43,7 +43,7 @@ public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) { - var rent = ArrayPool.Shared.Rent(8192); + var rent = ArrayPool.Shared.Rent(32768); var result = await _webSocket.ReceiveAsync(rent, cancellationToken); if (result.MessageType == WebSocketMessageType.Close) From f9da1b67ecbf483a81949e6e132e62781e9b9b51 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 17:25:35 +0000 Subject: [PATCH 12/14] cleanup --- .../DiscordPluginConfigurationViewModel.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs index 70e1f69..27a4fbf 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs @@ -27,6 +27,4 @@ public DiscordPluginConfigurationViewModel(Plugin plugin, PluginSettings pluginS public PluginSetting ClientIdSetting => _pluginSettings.GetSetting("DiscordClientId", string.Empty); public PluginSetting ClientSecretSetting => _pluginSettings.GetSetting("DiscordClientSecret", string.Empty); public PluginSetting Provider => _pluginSettings.GetSetting("DiscordRpcProvider", DiscordRpcProvider.StreamKit); - - public ObservableCollection RpcProviders { get; } = new(Enum.GetNames(typeof(DiscordRpcProvider))); } From 744bc2fed424ddeb9a4a1f4ea2f72850fe9adcc9 Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 17:40:47 +0000 Subject: [PATCH 13/14] Fix some packets not being fully received --- .../Transport/DiscordWebSocketTransport.cs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs index 6ed38e4..49128ab 100644 --- a/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs +++ b/src/Artemis.Plugins.Modules.Discord/Transport/DiscordWebSocketTransport.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Diagnostics; +using System.IO; using System.Net.WebSockets; using System.Text; using System.Threading; @@ -34,25 +35,42 @@ public async Task Connect(CancellationToken cancellationToken = default) public async Task SendPacketAsync(string stringData, RpcPacketType rpcPacketType, CancellationToken cancellationToken = default) { - Debug.WriteLine($"Sending {rpcPacketType}: {stringData}"); + var length = Encoding.UTF8.GetByteCount(stringData); + var rent = ArrayPool.Shared.Rent(length); + Encoding.UTF8.GetBytes(stringData, 0, stringData.Length, rent, 0); - var buffer = Encoding.UTF8.GetBytes(stringData); - - await _webSocket.SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); + try + { + await _webSocket.SendAsync(rent.AsMemory(0, length), WebSocketMessageType.Text, true, cancellationToken); + } + finally + { + ArrayPool.Shared.Return(rent); + } } public async Task<(RpcPacketType, string)> ReadMessageAsync(CancellationToken cancellationToken = default) { - var rent = ArrayPool.Shared.Rent(32768); - var result = await _webSocket.ReceiveAsync(rent, cancellationToken); - - if (result.MessageType == WebSocketMessageType.Close) + using var memoryStream = new MemoryStream(); + WebSocketReceiveResult result; + do + { + var rent = ArrayPool.Shared.Rent(4096); + try + { + result = await _webSocket.ReceiveAsync(rent, cancellationToken); + memoryStream.Write(rent, 0, result.Count); + } + finally + { + ArrayPool.Shared.Return(rent); + } + } while (!result.EndOfMessage); + + if (result.MessageType != WebSocketMessageType.Text) throw new DiscordRpcClientException("WebSocket closed"); - var packetData = Encoding.UTF8.GetString(rent.AsSpan(0, result.Count)); - ArrayPool.Shared.Return(rent); - - Debug.WriteLine($"Received {result.MessageType}: {packetData}"); + var packetData = Encoding.UTF8.GetString(memoryStream.ToArray()); return (RpcPacketType.FRAME, packetData); } From d5da3f2a01365fa28bbbc38d20d98bf7587afecc Mon Sep 17 00:00:00 2001 From: Diogo Trindade Date: Thu, 23 Nov 2023 17:58:31 +0000 Subject: [PATCH 14/14] reconnect when changing provider --- .../DiscordModule.cs | 6 +++--- .../DiscordPluginConfigurationViewModel.cs | 16 ++++++++++++++-- .../DiscordRpcClient.cs | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs index 4f6b696..f88eb53 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordModule.cs @@ -64,7 +64,7 @@ public override void ModuleDeactivated(bool isOverride) DisconnectFromDiscord(); } - private void ConnectToDiscord() + internal void ConnectToDiscord() { lock (_discordClientLock) { @@ -81,11 +81,11 @@ private void ConnectToDiscord() _discordClient.VoiceStateCreated += OnVoiceStateCreated; _discordClient.VoiceStateDeleted += OnVoiceStateDeleted; _discordClient.VoiceStateUpdated += OnVoiceStateUpdated; - _discordClient.Connect(); + _discordClient.Connect().Wait(); } } - private void DisconnectFromDiscord() + internal void DisconnectFromDiscord() { lock (_discordClientLock) { diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs index 27a4fbf..6e4bcac 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordPluginConfiguration/DiscordPluginConfigurationViewModel.cs @@ -4,22 +4,34 @@ using Artemis.UI.Shared; using ReactiveUI; using System.Reactive.Disposables; +using System.Threading.Tasks; namespace Artemis.Plugins.Modules.Discord.DiscordPluginConfiguration; public class DiscordPluginConfigurationViewModel : PluginConfigurationViewModel { private readonly PluginSettings _pluginSettings; - + private readonly Plugin _plugin; + public DiscordPluginConfigurationViewModel(Plugin plugin, PluginSettings pluginSettings) : base(plugin) { _pluginSettings = pluginSettings; + _plugin = plugin; this.WhenActivated(d => { Disposable.Create(() => { - _pluginSettings.SaveAllSettings(); + Task.Run(async () => + { + _pluginSettings.SaveAllSettings(); + + var feature = _plugin.GetFeature(); + + feature?.DisconnectFromDiscord(); + await Task.Delay(1000); + feature?.ConnectToDiscord(); + }); }).DisposeWith(d); }); } diff --git a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs index de648c7..a91d384 100644 --- a/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs +++ b/src/Artemis.Plugins.Modules.Discord/DiscordRpcClient.cs @@ -101,7 +101,7 @@ public DiscordRpcClient(PluginSettings settings) { _pendingRequests = new Dictionary>(); _cancellationTokenSource = new CancellationTokenSource(); - var rpcType = settings.GetSetting("RpcProvider", DiscordRpcProvider.StreamKit); + var rpcType = settings.GetSetting("DiscordRpcProvider", DiscordRpcProvider.StreamKit); //TODO: sometimes subscribing to NOTIFICATION_CREATE throws an error. investigate. // it might be dependent on the transport used, not 100% sure.