diff --git a/src/Discord.Net.Net45/Discord.Net.csproj b/src/Discord.Net.Net45/Discord.Net.csproj index 58aa7a35d..ecb4ff3f4 100644 --- a/src/Discord.Net.Net45/Discord.Net.csproj +++ b/src/Discord.Net.Net45/Discord.Net.csproj @@ -76,6 +76,9 @@ DiscordClient.Events.cs + + DiscordClientConfig.cs + DiscordWebSocket.cs diff --git a/src/Discord.Net/API/Models/Common.cs b/src/Discord.Net/API/Models/Common.cs index a630f37c4..79aa5a88e 100644 --- a/src/Discord.Net/API/Models/Common.cs +++ b/src/Discord.Net/API/Models/Common.cs @@ -211,7 +211,7 @@ namespace Discord.API.Models } public sealed class Embed { - public sealed class ProviderInfo + public sealed class Reference { [JsonProperty(PropertyName = "url")] public string Url; @@ -238,8 +238,10 @@ namespace Discord.API.Models public string Title; [JsonProperty(PropertyName = "description")] public string Description; + [JsonProperty(PropertyName = "author")] + public Reference Author; [JsonProperty(PropertyName = "provider")] - public ProviderInfo Provider; + public Reference Provider; [JsonProperty(PropertyName = "thumbnail")] public ThumbnailInfo Thumbnail; } diff --git a/src/Discord.Net/DiscordClient.Events.cs b/src/Discord.Net/DiscordClient.Events.cs index 0c34685e1..2a5745ddb 100644 --- a/src/Discord.Net/DiscordClient.Events.cs +++ b/src/Discord.Net/DiscordClient.Events.cs @@ -127,6 +127,12 @@ namespace Discord if (MessageRead != null) MessageRead(this, new MessageEventArgs(msg)); } + public event EventHandler MessageSent; + private void RaiseMessageSent(Message msg) + { + if (MessageSent != null) + MessageSent(this, new MessageEventArgs(msg)); + } //Role public sealed class RoleEventArgs : EventArgs diff --git a/src/Discord.Net/DiscordClient.cs b/src/Discord.Net/DiscordClient.cs index 39cb20bf2..bc686f376 100644 --- a/src/Discord.Net/DiscordClient.cs +++ b/src/Discord.Net/DiscordClient.cs @@ -3,6 +3,7 @@ using Discord.API.Models; using Discord.Helpers; using Newtonsoft.Json; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -16,8 +17,11 @@ namespace Discord /// Provides a connection to the DiscordApp service. public partial class DiscordClient { + private DiscordClientConfig _config; private DiscordWebSocket _webSocket; - private ManualResetEventSlim _isStopping; + private ManualResetEventSlim _blockEvent; + private volatile CancellationTokenSource _disconnectToken; + private volatile Task _tasks; private readonly Regex _userRegex, _channelRegex; private readonly MatchEvaluator _userRegexEvaluator, _channelRegexEvaluator; private readonly JsonSerializer _serializer; @@ -32,43 +36,40 @@ namespace Discord /// Returns a collection of all users the client can see across all servers. /// This collection does not guarantee any ordering. public IEnumerable Users => _users; - private AsyncCache _users; + private readonly AsyncCache _users; /// Returns a collection of all servers the client is a member of. /// This collection does not guarantee any ordering. public IEnumerable Servers => _servers; - private AsyncCache _servers; + private readonly AsyncCache _servers; /// Returns a collection of all channels the client can see across all servers. /// This collection does not guarantee any ordering. public IEnumerable Channels => _channels; - private AsyncCache _channels; + private readonly AsyncCache _channels; /// Returns a collection of all messages the client has in cache. /// This collection does not guarantee any ordering. public IEnumerable Messages => _messages; - private AsyncCache _messages; + private readonly AsyncCache _messages; + private readonly ConcurrentQueue _pendingMessages; /// Returns a collection of all roles the client can see across all servers. /// This collection does not guarantee any ordering. public IEnumerable Roles => _roles; - private AsyncCache _roles; + private readonly AsyncCache _roles; /// Returns true if the user has successfully logged in and the websocket connection has been established. - public bool IsConnected => _isReady; - private bool _isReady; - - /// Gets or sets the time (in milliseconds) to wait after an unexpected disconnect before reconnecting. - public int ReconnectDelay { get; set; } = 1000; - /// Gets or sets the time (in milliseconds) to wait after an reconnect fails before retrying. - public int FailedReconnectDelay { get; set; } = 10000; - + public bool IsConnected => _isConnected; + private bool _isConnected; /// Initializes a new instance of the DiscordClient class. - public DiscordClient() + public DiscordClient(DiscordClientConfig config = null) { - _rand = new Random(); - _isStopping = new ManualResetEventSlim(false); + _blockEvent = new ManualResetEventSlim(false); + + _config = config ?? new DiscordClientConfig(); + _rand = new Random(); _serializer = new JsonSerializer(); #if TEST_RESPONSES @@ -79,7 +80,7 @@ namespace Discord _userRegex = new Regex(@"<@\d+?>", RegexOptions.Compiled); _channelRegex = new Regex(@"<#\d+?>", RegexOptions.Compiled); _userRegexEvaluator = new MatchEvaluator(e => - { + { string id = e.Value.Substring(2, e.Value.Length - 3); var user = _users[id]; if (user != null) @@ -125,7 +126,7 @@ namespace Discord server.UpdateMember(membership); foreach (var membership in extendedModel.Presences) server.UpdateMember(membership); - } + } }, server => { } ); @@ -162,7 +163,7 @@ namespace Discord channel.PermissionOverwrites = null; } }, - channel => + channel => { if (channel.IsPrivate) { @@ -206,12 +207,20 @@ namespace Discord }; if (x.Provider != null) { - embed.Provider = new Message.EmbedProvider + embed.Provider = new Message.EmbedReference { Url = x.Provider.Url, Name = x.Provider.Name }; } + if (x.Author != null) + { + embed.Author = new Message.EmbedReference + { + Url = x.Author.Url, + Name = x.Author.Name + }; + } if (x.Thumbnail != null) { embed.Thumbnail = new Message.File @@ -240,13 +249,14 @@ namespace Discord }, message => { } ); + _pendingMessages = new ConcurrentQueue(); _roles = new AsyncCache( (key, parentKey) => new Role(key, parentKey, this), (role, model) => { role.Name = model.Name; role.Permissions.RawValue = (uint)model.Permissions; - }, + }, role => { } ); _users = new AsyncCache( @@ -261,22 +271,22 @@ namespace Discord var extendedModel = model as SelfUserInfo; user.Email = extendedModel.Email; user.IsVerified = extendedModel.IsVerified; - } + } }, user => { } ); - _webSocket = new DiscordWebSocket(); + _webSocket = new DiscordWebSocket(_config.WebSocketInterval); _webSocket.Connected += (s, e) => RaiseConnected(); _webSocket.Disconnected += async (s, e) => { //Reconnect if we didn't cause the disconnect RaiseDisconnected(); - while (!_isStopping.IsSet) + while (!_disconnectToken.IsCancellationRequested) { try { - await Task.Delay(ReconnectDelay); + await Task.Delay(_config.ReconnectDelay); await _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, true); break; } @@ -284,7 +294,7 @@ namespace Discord { RaiseOnDebugMessage($"Reconnect Failed: {ex.Message}"); //Net is down? We can keep trying to reconnect until the user runs Disconnect() - await Task.Delay(FailedReconnectDelay); + await Task.Delay(_config.FailedReconnectDelay); } } }; @@ -308,7 +318,7 @@ namespace Discord _servers.Update(server.Id, server); foreach (var channel in data.PrivateChannels) _channels.Update(channel.Id, null, channel); - } + } break; //Servers @@ -446,8 +456,23 @@ namespace Discord case "MESSAGE_CREATE": { var data = e.Event.ToObject(_serializer); - var msg = _messages.Update(data.Id, data.ChannelId, data); + Message msg = null; + bool wasLocal = _config.UseMessageQueue && data.Author.Id == UserId && data.Nonce != null; + if (wasLocal) + { + msg = _messages.Remap("nonce" + data.Nonce, data.Id); + if (msg != null) + { + msg.IsQueued = false; + msg.Id = data.Id; + } + } + msg = _messages.Update(data.Id, data.ChannelId, data); msg.User.UpdateActivity(data.Timestamp); + if (wasLocal) + { + try { RaiseMessageSent(msg); } catch { } + } try { RaiseMessageCreated(msg); } catch { } } break; @@ -549,6 +574,50 @@ namespace Discord _webSocket.OnDebugMessage += (s, e) => RaiseOnDebugMessage(e.Message); } + private async Task SendAsync() + { + var cancelToken = _disconnectToken.Token; + try + { + Message msg; + while (!cancelToken.IsCancellationRequested) + { + while (_pendingMessages.TryDequeue(out msg)) + { + bool hasFailed = false; + APIResponses.SendMessage apiMsg = null; + try + { + apiMsg = await DiscordAPI.SendMessage(msg.ChannelId, msg.RawText, msg.MentionIds, msg.Nonce); + } + catch (WebException) { break; } + catch (HttpException) { hasFailed = true; } + + if (!hasFailed) + { + _messages.Remap("nonce_", apiMsg.Id); + _messages.Update(msg.Id, msg.ChannelId, apiMsg); + } + msg.IsQueued = false; + msg.HasFailed = hasFailed; + try { RaiseMessageSent(msg); } catch { } + } + await Task.Delay(_config.MessageQueueInterval); + } + } + catch { } + finally { _disconnectToken.Cancel(); } + } + private async Task EmptyAsync() + { + var cancelToken = _disconnectToken.Token; + try + { + await Task.Delay(-1, cancelToken); + } + catch { } + } + /// Returns the user with the specified id, or null if none was found. public User GetUser(string id) => _users[id]; @@ -727,77 +796,109 @@ namespace Discord //Auth /// Connects to the Discord server with the provided token. - public async Task Connect(string token) - { - _isStopping.Reset(); - - Http.Token = token; - await _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, true); - - _isReady = true; - } + public Task Connect(string token) + => ConnectInternal(null, null, token); /// Connects to the Discord server with the provided email and password. /// Returns a token for future connections. - public async Task Connect(string email, string password) - { - _isStopping.Reset(); - - //Open websocket while we wait for login response - Task socketTask = _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, false); - var response = await DiscordAPI.Login(email, password); - Http.Token = response.Token; - - //Wait for websocket to finish connecting, then send token - await socketTask; - _webSocket.Login(); - - _isReady = true; - return response.Token; - } + public Task Connect(string email, string password) + => ConnectInternal(email, password, null); /// Connects to the Discord server with the provided token, and will fall back to username and password. /// Returns a token for future connections. - public async Task Connect(string email, string password, string token) + public Task Connect(string email, string password, string token) + => ConnectInternal(email, password, token); + /// Connects to the Discord server as an anonymous user with the provided username. + /// Returns a token for future connections. + public Task ConnectAnonymous(string username) + => ConnectInternal(username, null, null); + public async Task ConnectInternal(string emailOrUsername, string password, string token) { - try + bool success = false; + await Disconnect(); + _blockEvent.Reset(); + _disconnectToken = new CancellationTokenSource(); + + //Connect by Token + if (token != null) { - await Connect(token); - return token; + try + { + Http.Token = token; + await _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, true); + success = true; + } + catch (InvalidOperationException) //Bad Token + { + if (password == null) //If we don't have an alternate login, throw this error + throw; + } } - catch (InvalidOperationException) //Bad Token + if (!success && password != null) //Email/Password login { - return await Connect(email, password); + //Open websocket while we wait for login response + Task socketTask = _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, false); + var response = await DiscordAPI.Login(emailOrUsername, password); + await socketTask; + + //Wait for websocket to finish connecting, then send token + token = response.Token; + Http.Token = token; + _webSocket.Login(); + success = true; } - } - /// Connects to the Discord server as an anonymous user with the provided username. - /// Returns a token for future connections. - public async Task ConnectAnonymous(string username) - { - _isStopping.Reset(); - - //Open websocket while we wait for login response - Task socketTask = _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, false); - var response = await DiscordAPI.LoginAnonymous(username); - Http.Token = response.Token; - - //Wait for websocket to finish connecting, then send token - await socketTask; - _webSocket.Login(); - - _isReady = true; - return response.Token; - } + if (!success && password == null) //Anonymous login + { + //Open websocket while we wait for login response + Task socketTask = _webSocket.ConnectAsync(Endpoints.WebSocket_Hub, false); + var response = await DiscordAPI.LoginAnonymous(emailOrUsername); + await socketTask; + + //Wait for websocket to finish connecting, then send token + token = response.Token; + Http.Token = token; + _webSocket.Login(); + success = true; + } + if (success) + { + var cancelToken = _disconnectToken.Token; + if (_config.UseMessageQueue) + _tasks = Task.WhenAll(await Task.Factory.StartNew(SendAsync, cancelToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)); + else + _tasks = Task.WhenAll(await Task.Factory.StartNew(EmptyAsync, cancelToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)); + _tasks = _tasks.ContinueWith(async x => + { + await _webSocket.DisconnectAsync(); + + //Do not clean up until all tasks have ended + _webSocket.Dispose(); + _webSocket = null; + _blockEvent.Dispose(); + _blockEvent = null; + _tasks = null; + + //Clear send queue + Message ignored; + while (_pendingMessages.TryDequeue(out ignored)) { } + + _channels.Clear(); + _messages.Clear(); + _roles.Clear(); + _servers.Clear(); + _users.Clear(); + }); + _isConnected = true; + } + else + token = null; + return token; + } /// Disconnects from the Discord server, canceling any pending requests. public async Task Disconnect() { - _isReady = false; - _isStopping.Set(); - await _webSocket.DisconnectAsync(); - - _channels.Clear(); - _messages.Clear(); - _roles.Clear(); - _servers.Clear(); - _users.Clear(); + _blockEvent.Set(); + + if (_tasks != null) + await _tasks; } //Servers @@ -1004,26 +1105,36 @@ namespace Discord { CheckReady(); - if (text.Length <= 2000) + int blockCount = (int)Math.Ceiling(text.Length / (double)DiscordAPI.MaxMessageSize); + Message[] result = new Message[blockCount]; + for (int i = 0; i < blockCount; i++) { - var nonce = GenerateNonce(); - var msg = await DiscordAPI.SendMessage(channelId, text, mentions, nonce); - return new Message[] { _messages.Update(msg.Id, channelId, msg) }; - } - else - { - int blockCount = (int)Math.Ceiling(text.Length / (double)DiscordAPI.MaxMessageSize); - Message[] result = new Message[blockCount]; - for (int i = 0; i < blockCount; i++) + int index = i * DiscordAPI.MaxMessageSize; + string blockText = text.Substring(index, Math.Min(2000, text.Length - index)); + var nonce = GenerateNonce(); + if (_config.UseMessageQueue) + { + var msg = _messages.Update("nonce_" + nonce, channelId, new API.Models.Message + { + Content = blockText, + Timestamp = DateTime.UtcNow, + Author = new UserReference { Avatar = User.AvatarId, Discriminator = User.Discriminator, Id = User.Id, Username = User.Name }, + ChannelId = channelId + }); + msg.IsQueued = true; + msg.Nonce = nonce; + _pendingMessages.Enqueue(msg); + } + else { - int index = i * DiscordAPI.MaxMessageSize; - var nonce = GenerateNonce(); - var msg = await DiscordAPI.SendMessage(channelId, text.Substring(index, Math.Min(2000, text.Length - index)), mentions, nonce); + var msg = await DiscordAPI.SendMessage(channelId, blockText, mentions, nonce); result[i] = _messages.Update(msg.Id, channelId, msg); - await Task.Delay(1000); + result[i].Nonce = nonce; + try { RaiseMessageSent(result[i]); } catch { } } - return result; + await Task.Delay(1000); } + return result; } /// Edits a message the provided message. @@ -1180,7 +1291,9 @@ namespace Discord //Helpers private void CheckReady() { - if (!_isReady) + if (_blockEvent.IsSet) + throw new InvalidOperationException("The client is currently disconnecting."); + else if (!_isConnected) throw new InvalidOperationException("The client is not currently connected to Discord"); } internal string CleanMessageText(string text) @@ -1198,7 +1311,7 @@ namespace Discord /// Blocking call that will not return until client has been stopped. This is mainly intended for use in console applications. public void Block() { - _isStopping.Wait(); + _blockEvent.Wait(); } } } diff --git a/src/Discord.Net/DiscordClientConfig.cs b/src/Discord.Net/DiscordClientConfig.cs new file mode 100644 index 000000000..abd0f2e28 --- /dev/null +++ b/src/Discord.Net/DiscordClientConfig.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + public class DiscordClientConfig + { + /// Gets or sets the time (in milliseconds) to wait after an unexpected disconnect before reconnecting. + public int ReconnectDelay { get; set; } = 1000; + /// Gets or sets the time (in milliseconds) to wait after an reconnect fails before retrying. + public int FailedReconnectDelay { get; set; } = 10000; + /// Gets or sets the time (in milliseconds) to wait when the websocket's message queue is empty before checking again. + public int WebSocketInterval { get; set; } = 100; + /// Enables or disables the internal message queue. This will allow SendMessage to return immediately and handle messages internally. Messages will set the IsQueued and HasFailed properties to show their progress. + public bool UseMessageQueue { get; set; } = false; + /// Gets or sets the time (in milliseconds) to wait when the message queue is empty before checking again. + public int MessageQueueInterval { get; set; } = 100; + + public DiscordClientConfig() { } + } +} diff --git a/src/Discord.Net/DiscordWebSocket.cs b/src/Discord.Net/DiscordWebSocket.cs index 22a26ee59..81b6f868a 100644 --- a/src/Discord.Net/DiscordWebSocket.cs +++ b/src/Discord.Net/DiscordWebSocket.cs @@ -21,13 +21,14 @@ namespace Discord private volatile CancellationTokenSource _disconnectToken; private volatile Task _tasks; private ConcurrentQueue _sendQueue; - private int _heartbeatInterval; + private int _heartbeatInterval, _sendInterval; private DateTime _lastHeartbeat; private ManualResetEventSlim _connectWaitOnLogin, _connectWaitOnLogin2; private bool _isConnected; - public DiscordWebSocket() + public DiscordWebSocket(int interval) { + _sendInterval = interval; _connectWaitOnLogin = new ManualResetEventSlim(false); _connectWaitOnLogin2 = new ManualResetEventSlim(false); @@ -186,7 +187,7 @@ namespace Discord } while (_sendQueue.TryDequeue(out bytes)) await SendMessage(bytes, cancelToken); - await Task.Delay(100); + await Task.Delay(_sendInterval); } } catch { } diff --git a/src/Discord.Net/Helpers/AsyncCache.cs b/src/Discord.Net/Helpers/AsyncCache.cs index 570c20879..46a4fa3b1 100644 --- a/src/Discord.Net/Helpers/AsyncCache.cs +++ b/src/Discord.Net/Helpers/AsyncCache.cs @@ -33,8 +33,20 @@ namespace Discord.Helpers return value; } } - - public TValue Update(string key, TModel model) + + public TValue Add(string key, TValue obj) + { + _dictionary[key] = obj; + return obj; + } + public TValue Remap(string oldKey, string newKey) + { + var obj = Remove(oldKey); + if (obj != null) + Add(newKey, obj); + return obj; + } + public TValue Update(string key, TModel model) { return Update(key, null, model); } diff --git a/src/Discord.Net/Message.cs b/src/Discord.Net/Message.cs index eef21ee28..f6b7464b4 100644 --- a/src/Discord.Net/Message.cs +++ b/src/Discord.Net/Message.cs @@ -26,12 +26,14 @@ namespace Discord public string Title { get; internal set; } /// Summary of this embed. public string Description { get; internal set; } + /// Returns information about the author of this embed. + public EmbedReference Author { get; internal set; } /// Returns information about the providing website of this embed. - public EmbedProvider Provider { get; internal set; } + public EmbedReference Provider { get; internal set; } /// Returns the thumbnail of this embed. public File Thumbnail { get; internal set; } } - public sealed class EmbedProvider + public sealed class EmbedReference { /// URL of this embed provider. public string Url { get; internal set; } @@ -53,8 +55,10 @@ namespace Discord private readonly DiscordClient _client; private string _cleanText; - /// Returns the unique identifier for this message. - public string Id { get; } + /// Returns the global unique identifier for this message. + public string Id { get; internal set; } + /// Returns the local unique identifier for this message. + public string Nonce { get; internal set; } /// Returns true if the logged-in user was mentioned. /// This is not set to true if the user was mentioned with @everyone (see IsMentioningEverone). @@ -63,6 +67,10 @@ namespace Discord public bool IsMentioningEveryone { get; internal set; } /// Returns true if the message was sent as text-to-speech by someone with permissions to do so. public bool IsTTS { get; internal set; } + /// Returns true if the message is still in the outgoing message queue. + public bool IsQueued { get; internal set; } + /// Returns true if the message was rejected by the server. + public bool HasFailed { get; internal set; } /// Returns the raw content of this message as it was received from the server.. public string RawText { get; internal set; } /// Returns the content of this message with any special references such as mentions converted. diff --git a/src/Discord.Net/PackedPermissions.cs b/src/Discord.Net/PackedPermissions.cs index 3b1bd4b50..1d6b7ba02 100644 --- a/src/Discord.Net/PackedPermissions.cs +++ b/src/Discord.Net/PackedPermissions.cs @@ -14,8 +14,7 @@ public bool General_BanMembers => ((_rawValue >> 1) & 0x1) == 1; /// If True, a user may kick users from the server. public bool General_KickMembers => ((_rawValue >> 2) & 0x1) == 1; - /// If True, a user adjust roles. - /// Having this permission effectively gives all the others as a user may add them to themselves. + /// If True, a user may adjust roles. This also bypasses all other permissions, granting all the others. public bool General_ManageRoles => ((_rawValue >> 3) & 0x1) == 1; /// If True, a user may create, delete and modify channels. public bool General_ManageChannels => ((_rawValue >> 4) & 0x1) == 1;