Did more groundwork for AudioClient, added ILogManager
This commit is contained in:
176
src/Discord.Net.Audio/AudioAPIClient.cs
Normal file
176
src/Discord.Net.Audio/AudioAPIClient.cs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
using Discord.API;
|
||||||
|
using Discord.API.Voice;
|
||||||
|
using Discord.Net.Converters;
|
||||||
|
using Discord.Net.WebSockets;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Discord.Audio
|
||||||
|
{
|
||||||
|
public class AudioAPIClient
|
||||||
|
{
|
||||||
|
public const int MaxBitrate = 128;
|
||||||
|
private const string Mode = "xsalsa20_poly1305";
|
||||||
|
|
||||||
|
public event Func<string, string, double, Task> SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } }
|
||||||
|
private readonly AsyncEvent<Func<string, string, double, Task>> _sentRequestEvent = new AsyncEvent<Func<string, string, double, Task>>();
|
||||||
|
public event Func<int, Task> SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } }
|
||||||
|
private readonly AsyncEvent<Func<int, Task>> _sentGatewayMessageEvent = new AsyncEvent<Func<int, Task>>();
|
||||||
|
|
||||||
|
public event Func<VoiceOpCode, object, Task> ReceivedEvent { add { _receivedEvent.Add(value); } remove { _receivedEvent.Remove(value); } }
|
||||||
|
private readonly AsyncEvent<Func<VoiceOpCode, object, Task>> _receivedEvent = new AsyncEvent<Func<VoiceOpCode, object, Task>>();
|
||||||
|
public event Func<Exception, Task> Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } }
|
||||||
|
private readonly AsyncEvent<Func<Exception, Task>> _disconnectedEvent = new AsyncEvent<Func<Exception, Task>>();
|
||||||
|
|
||||||
|
private readonly ulong _userId;
|
||||||
|
private readonly string _token;
|
||||||
|
private readonly JsonSerializer _serializer;
|
||||||
|
private readonly IWebSocketClient _gatewayClient;
|
||||||
|
private readonly SemaphoreSlim _connectionLock;
|
||||||
|
private CancellationTokenSource _connectCancelToken;
|
||||||
|
|
||||||
|
public ulong GuildId { get; }
|
||||||
|
public string SessionId { get; }
|
||||||
|
public ConnectionState ConnectionState { get; private set; }
|
||||||
|
|
||||||
|
internal AudioAPIClient(ulong guildId, ulong userId, string sessionId, string token, WebSocketProvider webSocketProvider, JsonSerializer serializer = null)
|
||||||
|
{
|
||||||
|
GuildId = guildId;
|
||||||
|
_userId = userId;
|
||||||
|
SessionId = sessionId;
|
||||||
|
_token = token;
|
||||||
|
_connectionLock = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
|
_gatewayClient = webSocketProvider();
|
||||||
|
//_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .Net 4.6+)
|
||||||
|
_gatewayClient.BinaryMessage += async (data, index, count) =>
|
||||||
|
{
|
||||||
|
using (var compressed = new MemoryStream(data, index + 2, count - 2))
|
||||||
|
using (var decompressed = new MemoryStream())
|
||||||
|
{
|
||||||
|
using (var zlib = new DeflateStream(compressed, CompressionMode.Decompress))
|
||||||
|
zlib.CopyTo(decompressed);
|
||||||
|
decompressed.Position = 0;
|
||||||
|
using (var reader = new StreamReader(decompressed))
|
||||||
|
{
|
||||||
|
var msg = JsonConvert.DeserializeObject<WebSocketMessage>(reader.ReadToEnd());
|
||||||
|
await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_gatewayClient.TextMessage += async text =>
|
||||||
|
{
|
||||||
|
var msg = JsonConvert.DeserializeObject<WebSocketMessage>(text);
|
||||||
|
await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false);
|
||||||
|
};
|
||||||
|
_gatewayClient.Closed += async ex =>
|
||||||
|
{
|
||||||
|
await DisconnectAsync().ConfigureAwait(false);
|
||||||
|
await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
_serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendAsync(VoiceOpCode opCode, object payload, RequestOptions options = null)
|
||||||
|
{
|
||||||
|
byte[] bytes = null;
|
||||||
|
payload = new WebSocketMessage { Operation = (int)opCode, Payload = payload };
|
||||||
|
if (payload != null)
|
||||||
|
bytes = Encoding.UTF8.GetBytes(SerializeJson(payload));
|
||||||
|
//TODO: Send
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
//WebSocket
|
||||||
|
public async Task SendHeartbeatAsync(RequestOptions options = null)
|
||||||
|
{
|
||||||
|
await SendAsync(VoiceOpCode.Heartbeat, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), options: options).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public async Task SendIdentityAsync(ulong guildId, ulong userId, string sessionId, string token)
|
||||||
|
{
|
||||||
|
await SendAsync(VoiceOpCode.Identify, new IdentifyParams
|
||||||
|
{
|
||||||
|
GuildId = guildId,
|
||||||
|
UserId = userId,
|
||||||
|
SessionId = sessionId,
|
||||||
|
Token = token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConnectAsync(string url)
|
||||||
|
{
|
||||||
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ConnectInternalAsync(url).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally { _connectionLock.Release(); }
|
||||||
|
}
|
||||||
|
private async Task ConnectInternalAsync(string url)
|
||||||
|
{
|
||||||
|
ConnectionState = ConnectionState.Connecting;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_connectCancelToken = new CancellationTokenSource();
|
||||||
|
_gatewayClient.SetCancelToken(_connectCancelToken.Token);
|
||||||
|
await _gatewayClient.ConnectAsync(url).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await SendIdentityAsync(GuildId, _userId, SessionId, _token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ConnectionState = ConnectionState.Connected;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
await DisconnectInternalAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DisconnectInternalAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally { _connectionLock.Release(); }
|
||||||
|
}
|
||||||
|
private async Task DisconnectInternalAsync()
|
||||||
|
{
|
||||||
|
if (ConnectionState == ConnectionState.Disconnected) return;
|
||||||
|
ConnectionState = ConnectionState.Disconnecting;
|
||||||
|
|
||||||
|
try { _connectCancelToken?.Cancel(false); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
await _gatewayClient.DisconnectAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
ConnectionState = ConnectionState.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Helpers
|
||||||
|
private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2);
|
||||||
|
private string SerializeJson(object value)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(256);
|
||||||
|
using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture))
|
||||||
|
using (JsonWriter writer = new JsonTextWriter(text))
|
||||||
|
_serializer.Serialize(writer, value);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
private T DeserializeJson<T>(Stream jsonStream)
|
||||||
|
{
|
||||||
|
using (TextReader text = new StreamReader(jsonStream))
|
||||||
|
using (JsonReader reader = new JsonTextReader(text))
|
||||||
|
return _serializer.Deserialize<T>(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
using Discord.API;
|
using Discord.API.Voice;
|
||||||
using Discord.API.Voice;
|
using Discord.Logging;
|
||||||
using Discord.Net.Converters;
|
using Discord.Net.Converters;
|
||||||
using Discord.Net.WebSockets;
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -15,60 +11,115 @@ namespace Discord.Audio
|
|||||||
{
|
{
|
||||||
public class AudioClient
|
public class AudioClient
|
||||||
{
|
{
|
||||||
public const int MaxBitrate = 128;
|
public event Func<Task> Connected
|
||||||
|
|
||||||
private const string Mode = "xsalsa20_poly1305";
|
|
||||||
|
|
||||||
private readonly JsonSerializer _serializer;
|
|
||||||
private readonly IWebSocketClient _gatewayClient;
|
|
||||||
private readonly SemaphoreSlim _connectionLock;
|
|
||||||
private CancellationTokenSource _connectCancelToken;
|
|
||||||
|
|
||||||
public ConnectionState ConnectionState { get; private set; }
|
|
||||||
|
|
||||||
internal AudioClient(WebSocketProvider provider, JsonSerializer serializer = null)
|
|
||||||
{
|
{
|
||||||
|
add { _connectedEvent.Add(value); }
|
||||||
|
remove { _connectedEvent.Remove(value); }
|
||||||
|
}
|
||||||
|
private readonly AsyncEvent<Func<Task>> _connectedEvent = new AsyncEvent<Func<Task>>();
|
||||||
|
public event Func<Task> Disconnected
|
||||||
|
{
|
||||||
|
add { _disconnectedEvent.Add(value); }
|
||||||
|
remove { _disconnectedEvent.Remove(value); }
|
||||||
|
}
|
||||||
|
private readonly AsyncEvent<Func<Task>> _disconnectedEvent = new AsyncEvent<Func<Task>>();
|
||||||
|
public event Func<int, int, Task> LatencyUpdated
|
||||||
|
{
|
||||||
|
add { _latencyUpdatedEvent.Add(value); }
|
||||||
|
remove { _latencyUpdatedEvent.Remove(value); }
|
||||||
|
}
|
||||||
|
private readonly AsyncEvent<Func<int, int, Task>> _latencyUpdatedEvent = new AsyncEvent<Func<int, int, Task>>();
|
||||||
|
|
||||||
|
private readonly ILogger _webSocketLogger;
|
||||||
|
#if BENCHMARK
|
||||||
|
private readonly ILogger _benchmarkLogger;
|
||||||
|
#endif
|
||||||
|
private readonly JsonSerializer _serializer;
|
||||||
|
private readonly int _connectionTimeout, _reconnectDelay, _failedReconnectDelay;
|
||||||
|
internal readonly SemaphoreSlim _connectionLock;
|
||||||
|
|
||||||
|
private TaskCompletionSource<bool> _connectTask;
|
||||||
|
private CancellationTokenSource _cancelToken;
|
||||||
|
private Task _heartbeatTask, _reconnectTask;
|
||||||
|
private long _heartbeatTime;
|
||||||
|
private bool _isReconnecting;
|
||||||
|
private string _url;
|
||||||
|
|
||||||
|
public AudioAPIClient ApiClient { get; private set; }
|
||||||
|
/// <summary> Gets the current connection state of this client. </summary>
|
||||||
|
public ConnectionState ConnectionState { get; private set; }
|
||||||
|
/// <summary> Gets the estimated round-trip latency, in milliseconds, to the gateway server. </summary>
|
||||||
|
public int Latency { get; private set; }
|
||||||
|
|
||||||
|
/// <summary> Creates a new REST/WebSocket discord client. </summary>
|
||||||
|
internal AudioClient(ulong guildId, ulong userId, string sessionId, string token, AudioConfig config, ILogManager logManager)
|
||||||
|
{
|
||||||
|
_connectionTimeout = config.ConnectionTimeout;
|
||||||
|
_reconnectDelay = config.ReconnectDelay;
|
||||||
|
_failedReconnectDelay = config.FailedReconnectDelay;
|
||||||
|
|
||||||
|
_webSocketLogger = logManager.CreateLogger("AudioWS");
|
||||||
|
#if BENCHMARK
|
||||||
|
_benchmarkLogger = logManager.CreateLogger("Benchmark");
|
||||||
|
#endif
|
||||||
|
|
||||||
_connectionLock = new SemaphoreSlim(1, 1);
|
_connectionLock = new SemaphoreSlim(1, 1);
|
||||||
|
|
||||||
_serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() };
|
_serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() };
|
||||||
|
_serializer.Error += (s, e) =>
|
||||||
|
{
|
||||||
|
_webSocketLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult();
|
||||||
|
e.ErrorContext.Handled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
var webSocketProvider = config.WebSocketProvider; //TODO: Clean this check
|
||||||
|
ApiClient = new AudioAPIClient(guildId, userId, sessionId, token, config.WebSocketProvider);
|
||||||
|
|
||||||
|
ApiClient.SentGatewayMessage += async opCode => await _webSocketLogger.DebugAsync($"Sent {(VoiceOpCode)opCode}").ConfigureAwait(false);
|
||||||
|
ApiClient.ReceivedEvent += ProcessMessageAsync;
|
||||||
|
ApiClient.Disconnected += async ex =>
|
||||||
|
{
|
||||||
|
if (ex != null)
|
||||||
|
{
|
||||||
|
await _webSocketLogger.WarningAsync($"Connection Closed: {ex.Message}").ConfigureAwait(false);
|
||||||
|
await StartReconnectAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await _webSocketLogger.WarningAsync($"Connection Closed").ConfigureAwait(false);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendAsync(VoiceOpCode opCode, object payload, RequestOptions options = null)
|
/// <inheritdoc />
|
||||||
{
|
|
||||||
byte[] bytes = null;
|
|
||||||
payload = new WebSocketMessage { Operation = (int)opCode, Payload = payload };
|
|
||||||
if (payload != null)
|
|
||||||
bytes = Encoding.UTF8.GetBytes(SerializeJson(payload));
|
|
||||||
//TODO: Send
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Gateway
|
|
||||||
public async Task SendHeartbeatAsync(int lastSeq, RequestOptions options = null)
|
|
||||||
{
|
|
||||||
await SendAsync(VoiceOpCode.Heartbeat, lastSeq, options: options).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public async Task ConnectAsync(string url)
|
public async Task ConnectAsync(string url)
|
||||||
{
|
{
|
||||||
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_isReconnecting = false;
|
||||||
await ConnectInternalAsync(url).ConfigureAwait(false);
|
await ConnectInternalAsync(url).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally { _connectionLock.Release(); }
|
finally { _connectionLock.Release(); }
|
||||||
}
|
}
|
||||||
private async Task ConnectInternalAsync(string url)
|
private async Task ConnectInternalAsync(string url)
|
||||||
{
|
{
|
||||||
|
var state = ConnectionState;
|
||||||
|
if (state == ConnectionState.Connecting || state == ConnectionState.Connected)
|
||||||
|
await DisconnectInternalAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
ConnectionState = ConnectionState.Connecting;
|
ConnectionState = ConnectionState.Connecting;
|
||||||
|
await _webSocketLogger.InfoAsync("Connecting").ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_connectCancelToken = new CancellationTokenSource();
|
_url = url;
|
||||||
_gatewayClient.SetCancelToken(_connectCancelToken.Token);
|
_connectTask = new TaskCompletionSource<bool>();
|
||||||
await _gatewayClient.ConnectAsync(url).ConfigureAwait(false);
|
_cancelToken = new CancellationTokenSource();
|
||||||
|
await ApiClient.ConnectAsync(url).ConfigureAwait(false);
|
||||||
|
await _connectedEvent.InvokeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
await _connectTask.Task.ConfigureAwait(false);
|
||||||
|
|
||||||
ConnectionState = ConnectionState.Connected;
|
ConnectionState = ConnectionState.Connected;
|
||||||
|
await _webSocketLogger.InfoAsync("Connected").ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
@@ -76,12 +127,13 @@ namespace Discord.Audio
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task DisconnectAsync()
|
public async Task DisconnectAsync()
|
||||||
{
|
{
|
||||||
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_isReconnecting = false;
|
||||||
await DisconnectInternalAsync().ConfigureAwait(false);
|
await DisconnectInternalAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally { _connectionLock.Release(); }
|
finally { _connectionLock.Release(); }
|
||||||
@@ -90,30 +142,163 @@ namespace Discord.Audio
|
|||||||
{
|
{
|
||||||
if (ConnectionState == ConnectionState.Disconnected) return;
|
if (ConnectionState == ConnectionState.Disconnected) return;
|
||||||
ConnectionState = ConnectionState.Disconnecting;
|
ConnectionState = ConnectionState.Disconnecting;
|
||||||
|
await _webSocketLogger.InfoAsync("Disconnecting").ConfigureAwait(false);
|
||||||
try { _connectCancelToken?.Cancel(false); }
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
await _gatewayClient.DisconnectAsync().ConfigureAwait(false);
|
//Signal tasks to complete
|
||||||
|
try { _cancelToken.Cancel(); } catch { }
|
||||||
|
|
||||||
|
//Disconnect from server
|
||||||
|
await ApiClient.DisconnectAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
//Wait for tasks to complete
|
||||||
|
var heartbeatTask = _heartbeatTask;
|
||||||
|
if (heartbeatTask != null)
|
||||||
|
await heartbeatTask.ConfigureAwait(false);
|
||||||
|
_heartbeatTask = null;
|
||||||
|
|
||||||
ConnectionState = ConnectionState.Disconnected;
|
ConnectionState = ConnectionState.Disconnected;
|
||||||
|
await _webSocketLogger.InfoAsync("Disconnected").ConfigureAwait(false);
|
||||||
|
|
||||||
|
await _disconnectedEvent.InvokeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
//Helpers
|
private async Task StartReconnectAsync()
|
||||||
private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2);
|
|
||||||
private string SerializeJson(object value)
|
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(256);
|
//TODO: Is this thread-safe?
|
||||||
using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture))
|
if (_reconnectTask != null) return;
|
||||||
using (JsonWriter writer = new JsonTextWriter(text))
|
|
||||||
_serializer.Serialize(writer, value);
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
return sb.ToString();
|
try
|
||||||
|
{
|
||||||
|
if (_reconnectTask != null) return;
|
||||||
|
_isReconnecting = true;
|
||||||
|
_reconnectTask = ReconnectInternalAsync();
|
||||||
|
}
|
||||||
|
finally { _connectionLock.Release(); }
|
||||||
}
|
}
|
||||||
private T DeserializeJson<T>(Stream jsonStream)
|
private async Task ReconnectInternalAsync()
|
||||||
{
|
{
|
||||||
using (TextReader text = new StreamReader(jsonStream))
|
try
|
||||||
using (JsonReader reader = new JsonTextReader(text))
|
{
|
||||||
return _serializer.Deserialize<T>(reader);
|
int nextReconnectDelay = 1000;
|
||||||
|
while (_isReconnecting)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(nextReconnectDelay).ConfigureAwait(false);
|
||||||
|
nextReconnectDelay *= 2;
|
||||||
|
if (nextReconnectDelay > 30000)
|
||||||
|
nextReconnectDelay = 30000;
|
||||||
|
|
||||||
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ConnectInternalAsync(_url).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally { _connectionLock.Release(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _webSocketLogger.WarningAsync("Reconnect failed", ex).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isReconnecting = false;
|
||||||
|
_reconnectTask = null;
|
||||||
|
}
|
||||||
|
finally { _connectionLock.Release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(VoiceOpCode opCode, object payload)
|
||||||
|
{
|
||||||
|
#if BENCHMARK
|
||||||
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#endif
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (opCode)
|
||||||
|
{
|
||||||
|
/*case VoiceOpCode.Ready:
|
||||||
|
{
|
||||||
|
await _webSocketLogger.DebugAsync("Received Ready").ConfigureAwait(false);
|
||||||
|
var data = (payload as JToken).ToObject<ReadyEvent>(_serializer);
|
||||||
|
|
||||||
|
_heartbeatTime = 0;
|
||||||
|
_heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _cancelToken.Token);
|
||||||
|
}
|
||||||
|
break;*/
|
||||||
|
case VoiceOpCode.HeartbeatAck:
|
||||||
|
{
|
||||||
|
await _webSocketLogger.DebugAsync("Received HeartbeatAck").ConfigureAwait(false);
|
||||||
|
|
||||||
|
var heartbeatTime = _heartbeatTime;
|
||||||
|
if (heartbeatTime != 0)
|
||||||
|
{
|
||||||
|
int latency = (int)(Environment.TickCount - _heartbeatTime);
|
||||||
|
_heartbeatTime = 0;
|
||||||
|
await _webSocketLogger.VerboseAsync($"Latency = {latency} ms").ConfigureAwait(false);
|
||||||
|
|
||||||
|
int before = Latency;
|
||||||
|
Latency = latency;
|
||||||
|
|
||||||
|
await _latencyUpdatedEvent.InvokeAsync(before, latency).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await _webSocketLogger.WarningAsync($"Unknown OpCode ({opCode})").ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await _webSocketLogger.ErrorAsync($"Error handling {opCode}", ex).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#if BENCHMARK
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
double millis = Math.Round(stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2);
|
||||||
|
await _benchmarkLogger.DebugAsync($"{millis} ms").ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken)
|
||||||
|
{
|
||||||
|
//Clean this up when Discord's session patch is live
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancelToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(intervalMillis, cancelToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (_heartbeatTime != 0) //Server never responded to our last heartbeat
|
||||||
|
{
|
||||||
|
if (ConnectionState == ConnectionState.Connected)
|
||||||
|
{
|
||||||
|
await _webSocketLogger.WarningAsync("Server missed last heartbeat").ConfigureAwait(false);
|
||||||
|
await StartReconnectAsync().ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_heartbeatTime = Environment.TickCount;
|
||||||
|
await ApiClient.SendHeartbeatAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/Discord.Net.Audio/AudioConfig.cs
Normal file
17
src/Discord.Net.Audio/AudioConfig.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using Discord.Net.WebSockets;
|
||||||
|
|
||||||
|
namespace Discord.Audio
|
||||||
|
{
|
||||||
|
public class AudioConfig
|
||||||
|
{
|
||||||
|
/// <summary> Gets or sets the time (in milliseconds) to wait for the websocket to connect and initialize. </summary>
|
||||||
|
public int ConnectionTimeout { get; set; } = 30000;
|
||||||
|
/// <summary> Gets or sets the time (in milliseconds) to wait after an unexpected disconnect before reconnecting. </summary>
|
||||||
|
public int ReconnectDelay { get; set; } = 1000;
|
||||||
|
/// <summary> Gets or sets the time (in milliseconds) to wait after an reconnect fails before retrying. </summary>
|
||||||
|
public int FailedReconnectDelay { get; set; } = 15000;
|
||||||
|
|
||||||
|
/// <summary> Gets or sets the provider used to generate new websocket connections. </summary>
|
||||||
|
public WebSocketProvider WebSocketProvider { get; set; } = () => new DefaultWebSocketClient();
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/Discord.Net.Audio/Logger.cs
Normal file
6
src/Discord.Net.Audio/Logger.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Discord.Audio
|
||||||
|
{
|
||||||
|
internal class Logger
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ namespace Discord.Audio.Opus
|
|||||||
{
|
{
|
||||||
if (channels != 1 && channels != 2)
|
if (channels != 1 && channels != 2)
|
||||||
throw new ArgumentOutOfRangeException(nameof(channels));
|
throw new ArgumentOutOfRangeException(nameof(channels));
|
||||||
if (bitrate != null && (bitrate < 1 || bitrate > AudioClient.MaxBitrate))
|
if (bitrate != null && (bitrate < 1 || bitrate > AudioAPIClient.MaxBitrate))
|
||||||
throw new ArgumentOutOfRangeException(nameof(bitrate));
|
throw new ArgumentOutOfRangeException(nameof(bitrate));
|
||||||
|
|
||||||
OpusError error;
|
OpusError error;
|
||||||
|
|||||||
74
src/Discord.Net.Audio/Utilities/AsyncEvent.cs
Normal file
74
src/Discord.Net.Audio/Utilities/AsyncEvent.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Discord
|
||||||
|
{
|
||||||
|
public class AsyncEvent<T>
|
||||||
|
{
|
||||||
|
private readonly object _subLock = new object();
|
||||||
|
internal ImmutableArray<T> _subscriptions;
|
||||||
|
|
||||||
|
public IReadOnlyList<T> Subscriptions => _subscriptions;
|
||||||
|
|
||||||
|
public AsyncEvent()
|
||||||
|
{
|
||||||
|
_subscriptions = ImmutableArray.Create<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(T subscriber)
|
||||||
|
{
|
||||||
|
lock (_subLock)
|
||||||
|
_subscriptions = _subscriptions.Add(subscriber);
|
||||||
|
}
|
||||||
|
public void Remove(T subscriber)
|
||||||
|
{
|
||||||
|
lock (_subLock)
|
||||||
|
_subscriptions = _subscriptions.Remove(subscriber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class EventExtensions
|
||||||
|
{
|
||||||
|
public static async Task InvokeAsync(this AsyncEvent<Func<Task>> eventHandler)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
if (subscribers.Count > 0)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static async Task InvokeAsync<T>(this AsyncEvent<Func<T, Task>> eventHandler, T arg)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke(arg).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public static async Task InvokeAsync<T1, T2>(this AsyncEvent<Func<T1, T2, Task>> eventHandler, T1 arg1, T2 arg2)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke(arg1, arg2).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public static async Task InvokeAsync<T1, T2, T3>(this AsyncEvent<Func<T1, T2, T3, Task>> eventHandler, T1 arg1, T2 arg2, T3 arg3)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke(arg1, arg2, arg3).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public static async Task InvokeAsync<T1, T2, T3, T4>(this AsyncEvent<Func<T1, T2, T3, T4, Task>> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke(arg1, arg2, arg3, arg4).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
public static async Task InvokeAsync<T1, T2, T3, T4, T5>(this AsyncEvent<Func<T1, T2, T3, T4, T5, Task>> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5)
|
||||||
|
{
|
||||||
|
var subscribers = eventHandler.Subscriptions;
|
||||||
|
for (int i = 0; i < subscribers.Count; i++)
|
||||||
|
await subscribers[i].Invoke(arg1, arg2, arg3, arg4, arg5).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{
|
{
|
||||||
public enum GatewayOpCode : byte
|
public enum GatewayOpCode : byte
|
||||||
{
|
{
|
||||||
/// <summary> S→C - Used to send most events. </summary>
|
/// <summary> C←S - Used to send most events. </summary>
|
||||||
Dispatch = 0,
|
Dispatch = 0,
|
||||||
/// <summary> C↔S - Used to keep the connection alive and measure latency. </summary>
|
/// <summary> C↔S - Used to keep the connection alive and measure latency. </summary>
|
||||||
Heartbeat = 1,
|
Heartbeat = 1,
|
||||||
@@ -16,15 +16,15 @@
|
|||||||
VoiceServerPing = 5,
|
VoiceServerPing = 5,
|
||||||
/// <summary> C→S - Used to resume a connection after a redirect occurs. </summary>
|
/// <summary> C→S - Used to resume a connection after a redirect occurs. </summary>
|
||||||
Resume = 6,
|
Resume = 6,
|
||||||
/// <summary> S→C - Used to notify a client that they must reconnect to another gateway. </summary>
|
/// <summary> C←S - Used to notify a client that they must reconnect to another gateway. </summary>
|
||||||
Reconnect = 7,
|
Reconnect = 7,
|
||||||
/// <summary> C→S - Used to request all members that were withheld by large_threshold </summary>
|
/// <summary> C→S - Used to request all members that were withheld by large_threshold </summary>
|
||||||
RequestGuildMembers = 8,
|
RequestGuildMembers = 8,
|
||||||
/// <summary> S→C - Used to notify the client that their session has expired and cannot be resumed. </summary>
|
/// <summary> C←S - Used to notify the client that their session has expired and cannot be resumed. </summary>
|
||||||
InvalidSession = 9,
|
InvalidSession = 9,
|
||||||
/// <summary> S→C - Used to provide information to the client immediately on connection. </summary>
|
/// <summary> C←S - Used to provide information to the client immediately on connection. </summary>
|
||||||
Hello = 10,
|
Hello = 10,
|
||||||
/// <summary> S→C - Used to reply to a client's heartbeat. </summary>
|
/// <summary> C←S - Used to reply to a client's heartbeat. </summary>
|
||||||
HeartbeatAck = 11
|
HeartbeatAck = 11
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/Discord.Net/API/Voice/IdentifyParams.cs
Normal file
16
src/Discord.Net/API/Voice/IdentifyParams.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Discord.API.Voice
|
||||||
|
{
|
||||||
|
public class IdentifyParams
|
||||||
|
{
|
||||||
|
[JsonProperty("server_id")]
|
||||||
|
public ulong GuildId { get; set; }
|
||||||
|
[JsonProperty("user_id")]
|
||||||
|
public ulong UserId { get; set; }
|
||||||
|
[JsonProperty("session_id")]
|
||||||
|
public string SessionId { get; set; }
|
||||||
|
[JsonProperty("token")]
|
||||||
|
public string Token { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@
|
|||||||
SelectProtocol = 1,
|
SelectProtocol = 1,
|
||||||
/// <summary> C←S - Used to notify that the voice connection was successful and informs the client of available protocols. </summary>
|
/// <summary> C←S - Used to notify that the voice connection was successful and informs the client of available protocols. </summary>
|
||||||
Ready = 2,
|
Ready = 2,
|
||||||
/// <summary> C↔S - Used to keep the connection alive and measure latency. </summary>
|
/// <summary> C→S - Used to keep the connection alive and measure latency. </summary>
|
||||||
Heartbeat = 3,
|
Heartbeat = 3,
|
||||||
|
/// <summary> C←S - Used to reply to a client's heartbeat. </summary>
|
||||||
|
HeartbeatAck = 3,
|
||||||
/// <summary> C←S - Used to provide an encryption key to the client. </summary>
|
/// <summary> C←S - Used to provide an encryption key to the client. </summary>
|
||||||
SessionDescription = 4,
|
SessionDescription = 4,
|
||||||
/// <summary> C↔S - Used to inform that a certain user is speaking. </summary>
|
/// <summary> C↔S - Used to inform that a certain user is speaking. </summary>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace Discord
|
|||||||
public event Func<Task> LoggedOut { add { _loggedOutEvent.Add(value); } remove { _loggedOutEvent.Remove(value); } }
|
public event Func<Task> LoggedOut { add { _loggedOutEvent.Add(value); } remove { _loggedOutEvent.Remove(value); } }
|
||||||
private readonly AsyncEvent<Func<Task>> _loggedOutEvent = new AsyncEvent<Func<Task>>();
|
private readonly AsyncEvent<Func<Task>> _loggedOutEvent = new AsyncEvent<Func<Task>>();
|
||||||
|
|
||||||
internal readonly Logger _discordLogger, _restLogger, _queueLogger;
|
internal readonly ILogger _discordLogger, _restLogger, _queueLogger;
|
||||||
internal readonly SemaphoreSlim _connectionLock;
|
internal readonly SemaphoreSlim _connectionLock;
|
||||||
internal readonly LogManager _log;
|
internal readonly LogManager _log;
|
||||||
internal readonly RequestQueue _requestQueue;
|
internal readonly RequestQueue _requestQueue;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
using Discord.Extensions;
|
using Discord.Extensions;
|
||||||
using Discord.Logging;
|
using Discord.Logging;
|
||||||
using Discord.Net.Converters;
|
using Discord.Net.Converters;
|
||||||
using Discord.Net.WebSockets;
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using System;
|
using System;
|
||||||
@@ -21,9 +20,9 @@ namespace Discord
|
|||||||
public partial class DiscordSocketClient : DiscordClient, IDiscordClient
|
public partial class DiscordSocketClient : DiscordClient, IDiscordClient
|
||||||
{
|
{
|
||||||
private readonly ConcurrentQueue<ulong> _largeGuilds;
|
private readonly ConcurrentQueue<ulong> _largeGuilds;
|
||||||
private readonly Logger _gatewayLogger;
|
private readonly ILogger _gatewayLogger;
|
||||||
#if BENCHMARK
|
#if BENCHMARK
|
||||||
private readonly Logger _benchmarkLogger;
|
private readonly ILogger _benchmarkLogger;
|
||||||
#endif
|
#endif
|
||||||
private readonly JsonSerializer _serializer;
|
private readonly JsonSerializer _serializer;
|
||||||
private readonly int _connectionTimeout, _reconnectDelay, _failedReconnectDelay;
|
private readonly int _connectionTimeout, _reconnectDelay, _failedReconnectDelay;
|
||||||
@@ -150,6 +149,11 @@ namespace Discord
|
|||||||
await ApiClient.ConnectAsync().ConfigureAwait(false);
|
await ApiClient.ConnectAsync().ConfigureAwait(false);
|
||||||
await _connectedEvent.InvokeAsync().ConfigureAwait(false);
|
await _connectedEvent.InvokeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (_sessionId != null)
|
||||||
|
await ApiClient.SendResumeAsync(_sessionId, _lastSeq).ConfigureAwait(false);
|
||||||
|
else
|
||||||
|
await ApiClient.SendIdentifyAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
await _connectTask.Task.ConfigureAwait(false);
|
await _connectTask.Task.ConfigureAwait(false);
|
||||||
|
|
||||||
ConnectionState = ConnectionState.Connected;
|
ConnectionState = ConnectionState.Connected;
|
||||||
@@ -205,6 +209,7 @@ namespace Discord
|
|||||||
|
|
||||||
await _disconnectedEvent.InvokeAsync().ConfigureAwait(false);
|
await _disconnectedEvent.InvokeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StartReconnectAsync()
|
private async Task StartReconnectAsync()
|
||||||
{
|
{
|
||||||
//TODO: Is this thread-safe?
|
//TODO: Is this thread-safe?
|
||||||
@@ -416,10 +421,6 @@ namespace Discord
|
|||||||
await _gatewayLogger.DebugAsync("Received Hello").ConfigureAwait(false);
|
await _gatewayLogger.DebugAsync("Received Hello").ConfigureAwait(false);
|
||||||
var data = (payload as JToken).ToObject<HelloEvent>(_serializer);
|
var data = (payload as JToken).ToObject<HelloEvent>(_serializer);
|
||||||
|
|
||||||
if (_sessionId != null)
|
|
||||||
await ApiClient.SendResumeAsync(_sessionId, _lastSeq).ConfigureAwait(false);
|
|
||||||
else
|
|
||||||
await ApiClient.SendIdentifyAsync().ConfigureAwait(false);
|
|
||||||
_heartbeatTime = 0;
|
_heartbeatTime = 0;
|
||||||
_heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _cancelToken.Token);
|
_heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _cancelToken.Token);
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/Discord.Net/Logging/ILogManager.cs
Normal file
36
src/Discord.Net/Logging/ILogManager.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Discord.Logging
|
||||||
|
{
|
||||||
|
public interface ILogManager
|
||||||
|
{
|
||||||
|
LogSeverity Level { get; }
|
||||||
|
|
||||||
|
Task LogAsync(LogSeverity severity, string source, string message, Exception ex = null);
|
||||||
|
Task LogAsync(LogSeverity severity, string source, FormattableString message, Exception ex = null);
|
||||||
|
Task LogAsync(LogSeverity severity, string source, Exception ex);
|
||||||
|
|
||||||
|
Task ErrorAsync(string source, string message, Exception ex = null);
|
||||||
|
Task ErrorAsync(string source, FormattableString message, Exception ex = null);
|
||||||
|
Task ErrorAsync(string source, Exception ex);
|
||||||
|
|
||||||
|
Task WarningAsync(string source, string message, Exception ex = null);
|
||||||
|
Task WarningAsync(string source, FormattableString message, Exception ex = null);
|
||||||
|
Task WarningAsync(string source, Exception ex);
|
||||||
|
|
||||||
|
Task InfoAsync(string source, string message, Exception ex = null);
|
||||||
|
Task InfoAsync(string source, FormattableString message, Exception ex = null);
|
||||||
|
Task InfoAsync(string source, Exception ex);
|
||||||
|
|
||||||
|
Task VerboseAsync(string source, string message, Exception ex = null);
|
||||||
|
Task VerboseAsync(string source, FormattableString message, Exception ex = null);
|
||||||
|
Task VerboseAsync(string source, Exception ex);
|
||||||
|
|
||||||
|
Task DebugAsync(string source, string message, Exception ex = null);
|
||||||
|
Task DebugAsync(string source, FormattableString message, Exception ex = null);
|
||||||
|
Task DebugAsync(string source, Exception ex);
|
||||||
|
|
||||||
|
ILogger CreateLogger(string name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ using System.Threading.Tasks;
|
|||||||
|
|
||||||
namespace Discord.Logging
|
namespace Discord.Logging
|
||||||
{
|
{
|
||||||
internal class LogManager : ILogger
|
internal class LogManager : ILogManager, ILogger
|
||||||
{
|
{
|
||||||
public LogSeverity Level { get; }
|
public LogSeverity Level { get; }
|
||||||
|
|
||||||
@@ -111,6 +111,6 @@ namespace Discord.Logging
|
|||||||
Task ILogger.DebugAsync(Exception ex)
|
Task ILogger.DebugAsync(Exception ex)
|
||||||
=> LogAsync(LogSeverity.Debug, "Discord", ex);
|
=> LogAsync(LogSeverity.Debug, "Discord", ex);
|
||||||
|
|
||||||
public Logger CreateLogger(string name) => new Logger(this, name);
|
public ILogger CreateLogger(string name) => new Logger(this, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user