diff --git a/src/Discord.Net/API/DiscordRawClient.cs b/src/Discord.Net/API/DiscordRawClient.cs index 60b571f74..218964937 100644 --- a/src/Discord.Net/API/DiscordRawClient.cs +++ b/src/Discord.Net/API/DiscordRawClient.cs @@ -20,23 +20,22 @@ namespace Discord.API public class DiscordRawClient { internal event EventHandler SentRequest; - + private readonly RequestQueue _requestQueue; - private readonly IRestClient _restClient; - private readonly CancellationToken _cancelToken; private readonly JsonSerializer _serializer; - + private IRestClient _restClient; + private CancellationToken _cancelToken; + public TokenType AuthTokenType { get; private set; } public IRestClient RestClient { get; private set; } public IRequestQueue RequestQueue { get; private set; } - internal DiscordRawClient(RestClientProvider restClientProvider, CancellationToken cancelToken) + internal DiscordRawClient(RestClientProvider restClientProvider) { - _cancelToken = cancelToken; - - _restClient = restClientProvider(DiscordConfig.ClientAPIUrl, cancelToken); + _restClient = restClientProvider(DiscordConfig.ClientAPIUrl); _restClient.SetHeader("accept", "*/*"); _restClient.SetHeader("user-agent", DiscordConfig.UserAgent); + _requestQueue = new RequestQueue(_restClient); _serializer = new JsonSerializer(); @@ -53,29 +52,40 @@ namespace Discord.API _serializer.ContractResolver = new OptionalContractResolver(); } - public void SetToken(TokenType tokenType, string token) + public async Task Login(TokenType tokenType, string token, CancellationToken cancelToken) { AuthTokenType = tokenType; - - if (token != null) + _cancelToken = cancelToken; + await _requestQueue.SetCancelToken(cancelToken).ConfigureAwait(false); + + switch (tokenType) { - switch (tokenType) - { - case TokenType.Bot: - token = $"Bot {token}"; - break; - case TokenType.Bearer: - token = $"Bearer {token}"; - break; - case TokenType.User: - break; - default: - throw new ArgumentException("Unknown oauth token type", nameof(tokenType)); - } + case TokenType.Bot: + token = $"Bot {token}"; + break; + case TokenType.Bearer: + token = $"Bearer {token}"; + break; + case TokenType.User: + break; + default: + throw new ArgumentException("Unknown oauth token type", nameof(tokenType)); } _restClient.SetHeader("authorization", token); } + public async Task Login(LoginParams args, CancellationToken cancelToken) + { + var response = await Send("POST", "auth/login", args).ConfigureAwait(false); + + AuthTokenType = TokenType.User; + _restClient.SetHeader("authorization", response.Token); + } + public async Task Logout() + { + await _requestQueue.Clear().ConfigureAwait(false); + _restClient = null; + } //Core public Task Send(string method, string endpoint, GlobalBucket bucket = GlobalBucket.General) @@ -121,6 +131,8 @@ namespace Discord.API private async Task SendInternal(string method, string endpoint, object payload, bool headerOnly, BucketGroup group, int bucketId, ulong guildId) { + _cancelToken.ThrowIfCancellationRequested(); + var stopwatch = Stopwatch.StartNew(); string json = null; if (payload != null) @@ -136,6 +148,8 @@ namespace Discord.API } private async Task SendInternal(string method, string endpoint, IReadOnlyDictionary multipartArgs, bool headerOnly, BucketGroup group, int bucketId, ulong guildId) { + _cancelToken.ThrowIfCancellationRequested(); + var stopwatch = Stopwatch.StartNew(); var responseStream = await _requestQueue.Send(new RestRequest(method, endpoint, multipartArgs, headerOnly), group, bucketId, guildId).ConfigureAwait(false); int bytes = headerOnly ? 0 : (int)responseStream.Length; @@ -149,11 +163,6 @@ namespace Discord.API //Auth - public async Task Login(LoginParams args) - { - var response = await Send("POST", "auth/login", args).ConfigureAwait(false); - SetToken(TokenType.User, response.Token); - } public async Task ValidateToken() { await Send("GET", "auth/login").ConfigureAwait(false); diff --git a/src/Discord.Net/API/IWebSocketMessage.cs b/src/Discord.Net/API/IWebSocketMessage.cs new file mode 100644 index 000000000..526f3119f --- /dev/null +++ b/src/Discord.Net/API/IWebSocketMessage.cs @@ -0,0 +1,9 @@ +namespace Discord.API +{ + public interface IWebSocketMessage + { + int OpCode { get; } + object Payload { get; } + bool IsPrivate { get; } + } +} diff --git a/src/Discord.Net/API/WebSocketMessage.cs b/src/Discord.Net/API/WebSocketMessage.cs new file mode 100644 index 000000000..05d5e779d --- /dev/null +++ b/src/Discord.Net/API/WebSocketMessage.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + public class WebSocketMessage + { + [JsonProperty("op")] + public int? Operation { get; set; } + [JsonProperty("t", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + [JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] + public uint? Sequence { get; set; } + [JsonProperty("d")] + public object Payload { get; set; } + + public WebSocketMessage() { } + public WebSocketMessage(IWebSocketMessage msg) + { + Operation = msg.OpCode; + Payload = msg.Payload; + } + } +} diff --git a/src/Discord.Net/Common/Entities/Guilds/IGuildIntegration.cs b/src/Discord.Net/Common/Entities/Guilds/IGuildIntegration.cs index 0252382fd..e90d8ae76 100644 --- a/src/Discord.Net/Common/Entities/Guilds/IGuildIntegration.cs +++ b/src/Discord.Net/Common/Entities/Guilds/IGuildIntegration.cs @@ -12,10 +12,10 @@ namespace Discord ulong ExpireBehavior { get; } ulong ExpireGracePeriod { get; } DateTime SyncedAt { get; } + IntegrationAccount Account { get; } IGuild Guild { get; } IUser User { get; } IRole Role { get; } - IIntegrationAccount Account { get; } } } diff --git a/src/Discord.Net/Common/Entities/Guilds/IIntegrationAccount.cs b/src/Discord.Net/Common/Entities/Guilds/IIntegrationAccount.cs deleted file mode 100644 index 7e5052c1b..000000000 --- a/src/Discord.Net/Common/Entities/Guilds/IIntegrationAccount.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Discord -{ - public interface IIntegrationAccount : IEntity - { - string Name { get; } - } -} diff --git a/src/Discord.Net/Rest/Entities/Guilds/IntegrationAccount.cs b/src/Discord.Net/Common/Entities/Guilds/IntegrationAccount.cs similarity index 72% rename from src/Discord.Net/Rest/Entities/Guilds/IntegrationAccount.cs rename to src/Discord.Net/Common/Entities/Guilds/IntegrationAccount.cs index f28061955..7b49d9de7 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/IntegrationAccount.cs +++ b/src/Discord.Net/Common/Entities/Guilds/IntegrationAccount.cs @@ -1,6 +1,6 @@ -namespace Discord.Rest +namespace Discord { - public class IntegrationAccount : IIntegrationAccount + public struct IntegrationAccount { /// public string Id { get; } diff --git a/src/Discord.Net/Rest/Entities/Guilds/VoiceRegion.cs b/src/Discord.Net/Common/Entities/Guilds/VoiceRegion.cs similarity index 100% rename from src/Discord.Net/Rest/Entities/Guilds/VoiceRegion.cs rename to src/Discord.Net/Common/Entities/Guilds/VoiceRegion.cs diff --git a/src/Discord.Net/Rest/Entities/Invites/Invite.cs b/src/Discord.Net/Common/Entities/Invites/Invite.cs similarity index 93% rename from src/Discord.Net/Rest/Entities/Invites/Invite.cs rename to src/Discord.Net/Common/Entities/Invites/Invite.cs index 2e13b0542..9ea98fd18 100644 --- a/src/Discord.Net/Rest/Entities/Invites/Invite.cs +++ b/src/Discord.Net/Common/Entities/Invites/Invite.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; using Model = Discord.API.Invite; -namespace Discord.Rest +namespace Discord { public abstract class Invite : IInvite { @@ -17,7 +17,7 @@ namespace Discord.Rest /// public string XkcdUrl => XkcdCode != null ? $"{DiscordConfig.InviteUrl}/{XkcdCode}" : null; - internal abstract DiscordClient Discord { get; } + internal abstract IDiscordClient Discord { get; } internal Invite(Model model) { diff --git a/src/Discord.Net/Rest/Entities/Invites/PublicInvite.cs b/src/Discord.Net/Common/Entities/Invites/PublicInvite.cs similarity index 88% rename from src/Discord.Net/Rest/Entities/Invites/PublicInvite.cs rename to src/Discord.Net/Common/Entities/Invites/PublicInvite.cs index 8a767dc20..3a2f42394 100644 --- a/src/Discord.Net/Rest/Entities/Invites/PublicInvite.cs +++ b/src/Discord.Net/Common/Entities/Invites/PublicInvite.cs @@ -15,9 +15,9 @@ namespace Discord.Rest /// public ulong ChannelId => _channelId; - internal override DiscordClient Discord { get; } + internal override IDiscordClient Discord { get; } - internal PublicInvite(DiscordClient discord, Model model) + internal PublicInvite(IDiscordClient discord, Model model) : base(model) { Discord = discord; diff --git a/src/Discord.Net/Common/Entities/Permissions/ChannelPermissions.cs b/src/Discord.Net/Common/Entities/Permissions/ChannelPermissions.cs index ffcc403cf..4c0710f82 100644 --- a/src/Discord.Net/Common/Entities/Permissions/ChannelPermissions.cs +++ b/src/Discord.Net/Common/Entities/Permissions/ChannelPermissions.cs @@ -18,7 +18,7 @@ namespace Discord { case ITextChannel _: return _allText; case IVoiceChannel _: return _allVoice; - case IDMChannel _: return _allDM; + case IGuildChannel _: return _allDM; default: throw new ArgumentException("Unknown channel type", nameof(channel)); } diff --git a/src/Discord.Net/Rest/Entities/Users/Connection.cs b/src/Discord.Net/Common/Entities/Users/Connection.cs similarity index 63% rename from src/Discord.Net/Rest/Entities/Users/Connection.cs rename to src/Discord.Net/Common/Entities/Users/Connection.cs index 9795dc207..fc4524e90 100644 --- a/src/Discord.Net/Rest/Entities/Users/Connection.cs +++ b/src/Discord.Net/Common/Entities/Users/Connection.cs @@ -6,12 +6,11 @@ namespace Discord.Rest public class Connection : IConnection { public string Id { get; } + public string Type { get; } + public string Name { get; } + public bool IsRevoked { get; } - public string Type { get; private set; } - public string Name { get; private set; } - public bool IsRevoked { get; private set; } - - public IEnumerable Integrations { get; private set; } + public IEnumerable IntegrationIds { get; } public Connection(Model model) { @@ -21,7 +20,7 @@ namespace Discord.Rest Name = model.Name; IsRevoked = model.Revoked; - Integrations = model.Integrations; + IntegrationIds = model.Integrations; } public override string ToString() => $"{Name ?? Id.ToString()} ({Type})"; diff --git a/src/Discord.Net/Common/Entities/Users/IConnection.cs b/src/Discord.Net/Common/Entities/Users/IConnection.cs index 3c9b5a79e..6540c147e 100644 --- a/src/Discord.Net/Common/Entities/Users/IConnection.cs +++ b/src/Discord.Net/Common/Entities/Users/IConnection.cs @@ -9,6 +9,6 @@ namespace Discord string Name { get; } bool IsRevoked { get; } - IEnumerable Integrations { get; } + IEnumerable IntegrationIds { get; } } } diff --git a/src/Discord.Net/Discord.Net.csproj b/src/Discord.Net/Discord.Net.csproj index 48f2a4928..88794a5aa 100644 --- a/src/Discord.Net/Discord.Net.csproj +++ b/src/Discord.Net/Discord.Net.csproj @@ -67,6 +67,7 @@ + @@ -98,6 +99,7 @@ + @@ -111,7 +113,6 @@ - @@ -161,19 +162,19 @@ - - + + - - + + - + @@ -204,6 +205,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Discord.Net/DiscordConfig.cs b/src/Discord.Net/DiscordConfig.cs index eef0bc638..50847dbb3 100644 --- a/src/Discord.Net/DiscordConfig.cs +++ b/src/Discord.Net/DiscordConfig.cs @@ -3,6 +3,8 @@ using System.Reflection; namespace Discord { + //TODO: Add socket config items in their own class + public class DiscordConfig { public static string Version { get; } = typeof(DiscordConfig).GetTypeInfo().Assembly?.GetName().Version.ToString(3) ?? "Unknown"; @@ -26,6 +28,6 @@ namespace Discord public LogSeverity LogLevel { get; set; } = LogSeverity.Info; /// Gets or sets the provider used to generate new REST connections. - public RestClientProvider RestClientProvider { get; set; } = (url, ct) => new DefaultRestClient(url, ct); + public RestClientProvider RestClientProvider { get; set; } = url => new DefaultRestClient(url); } } diff --git a/src/Discord.Net/Net/Converters/UInt64ArrayConverter.cs b/src/Discord.Net/Net/Converters/UInt64ArrayConverter.cs index f57e3427b..8e94b51f5 100644 --- a/src/Discord.Net/Net/Converters/UInt64ArrayConverter.cs +++ b/src/Discord.Net/Net/Converters/UInt64ArrayConverter.cs @@ -5,7 +5,7 @@ using System.Globalization; namespace Discord.Net.Converters { - internal class UInt64ArrayConverter : JsonConverter + public class UInt64ArrayConverter : JsonConverter { public override bool CanConvert(Type objectType) => objectType == typeof(IEnumerable); public override bool CanRead => true; diff --git a/src/Discord.Net/Net/Rest/DefaultRestClient.cs b/src/Discord.Net/Net/Rest/DefaultRestClient.cs index a2b859197..86dee1150 100644 --- a/src/Discord.Net/Net/Rest/DefaultRestClient.cs +++ b/src/Discord.Net/Net/Rest/DefaultRestClient.cs @@ -17,13 +17,11 @@ namespace Discord.Net.Rest protected readonly HttpClient _client; protected readonly string _baseUrl; - protected readonly CancellationToken _cancelToken; protected bool _isDisposed; - public DefaultRestClient(string baseUrl, CancellationToken cancelToken) + public DefaultRestClient(string baseUrl) { _baseUrl = baseUrl; - _cancelToken = cancelToken; _client = new HttpClient(new HttpClientHandler { @@ -56,18 +54,18 @@ namespace Discord.Net.Rest _client.DefaultRequestHeaders.Add(key, value); } - public async Task Send(string method, string endpoint, string json = null, bool headerOnly = false) + public async Task Send(string method, string endpoint, CancellationToken cancelToken, string json = null, bool headerOnly = false) { string uri = Path.Combine(_baseUrl, endpoint); using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) { if (json != null) restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); - return await SendInternal(restRequest, _cancelToken, headerOnly).ConfigureAwait(false); + return await SendInternal(restRequest, cancelToken, headerOnly).ConfigureAwait(false); } } - public async Task Send(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false) + public async Task Send(string method, string endpoint, CancellationToken cancelToken, IReadOnlyDictionary multipartParams, bool headerOnly = false) { string uri = Path.Combine(_baseUrl, endpoint); using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) @@ -97,7 +95,7 @@ namespace Discord.Net.Rest } } restRequest.Content = content; - return await SendInternal(restRequest, _cancelToken, headerOnly).ConfigureAwait(false); + return await SendInternal(restRequest, cancelToken, headerOnly).ConfigureAwait(false); } } diff --git a/src/Discord.Net/Net/Rest/IRestClient.cs b/src/Discord.Net/Net/Rest/IRestClient.cs index 3f99a2f7e..93740fc95 100644 --- a/src/Discord.Net/Net/Rest/IRestClient.cs +++ b/src/Discord.Net/Net/Rest/IRestClient.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Discord.Net.Rest @@ -9,7 +10,7 @@ namespace Discord.Net.Rest { void SetHeader(string key, string value); - Task Send(string method, string endpoint, string json = null, bool headerOnly = false); - Task Send(string method, string endpoint, IReadOnlyDictionary multipartParams, bool headerOnly = false); + Task Send(string method, string endpoint, CancellationToken cancelToken, string json = null, bool headerOnly = false); + Task Send(string method, string endpoint, CancellationToken cancelToken, IReadOnlyDictionary multipartParams, bool headerOnly = false); } } diff --git a/src/Discord.Net/Net/Rest/RequestQueue/RequestQueue.cs b/src/Discord.Net/Net/Rest/RequestQueue/RequestQueue.cs index 155b683e7..39a657cb4 100644 --- a/src/Discord.Net/Net/Rest/RequestQueue/RequestQueue.cs +++ b/src/Discord.Net/Net/Rest/RequestQueue/RequestQueue.cs @@ -8,9 +8,12 @@ namespace Discord.Net.Rest { public class RequestQueue : IRequestQueue { - private SemaphoreSlim _lock; - private RequestQueueBucket[] _globalBuckets; - private Dictionary[] _guildBuckets; + private readonly SemaphoreSlim _lock; + private readonly RequestQueueBucket[] _globalBuckets; + private readonly Dictionary[] _guildBuckets; + private CancellationTokenSource _clearToken; + private CancellationToken? _parentToken; + private CancellationToken _cancelToken; public IRestClient RestClient { get; } @@ -21,12 +24,26 @@ namespace Discord.Net.Rest _lock = new SemaphoreSlim(1, 1); _globalBuckets = new RequestQueueBucket[Enum.GetValues(typeof(GlobalBucket)).Length]; _guildBuckets = new Dictionary[Enum.GetValues(typeof(GuildBucket)).Length]; + _clearToken = new CancellationTokenSource(); + _cancelToken = _clearToken.Token; + } + internal async Task SetCancelToken(CancellationToken cancelToken) + { + await Lock().ConfigureAwait(false); + try + { + _parentToken = cancelToken; + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _clearToken.Token).Token; + } + finally { Unlock(); } } internal async Task Send(RestRequest request, BucketGroup group, int bucketId, ulong guildId) { RequestQueueBucket bucket; + request.CancelToken = _cancelToken; + await Lock().ConfigureAwait(false); try { @@ -129,6 +146,20 @@ namespace Discord.Net.Rest _lock.Release(); } + public async Task Clear() + { + await Lock().ConfigureAwait(false); + try + { + _clearToken?.Cancel(); + _clearToken = new CancellationTokenSource(); + if (_parentToken != null) + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_clearToken.Token, _parentToken.Value).Token; + else + _cancelToken = _clearToken.Token; + } + finally { Unlock(); } + } public async Task Clear(GlobalBucket type) { var bucket = _globalBuckets[(int)type]; @@ -136,7 +167,7 @@ namespace Discord.Net.Rest { try { - await bucket.Lock(); + await bucket.Lock().ConfigureAwait(false); bucket.Clear(); } finally { bucket.Unlock(); } @@ -152,7 +183,7 @@ namespace Discord.Net.Rest { try { - await bucket.Lock(); + await bucket.Lock().ConfigureAwait(false); bucket.Clear(); } finally { bucket.Unlock(); } diff --git a/src/Discord.Net/Net/Rest/RequestQueue/RequestQueueBucket.cs b/src/Discord.Net/Net/Rest/RequestQueue/RequestQueueBucket.cs index 2d14bc367..708e3251c 100644 --- a/src/Discord.Net/Net/Rest/RequestQueue/RequestQueueBucket.cs +++ b/src/Discord.Net/Net/Rest/RequestQueue/RequestQueueBucket.cs @@ -16,7 +16,7 @@ namespace Discord.Net.Rest private readonly ConcurrentQueue _queue; private readonly SemaphoreSlim _lock; private Task _resetTask; - private bool _waitingToProcess, _destroyed; //TODO: Remove _destroyed + private bool _waitingToProcess; private int _id; public int WindowMaxCount { get; } @@ -49,11 +49,7 @@ namespace Discord.Net.Rest public void Queue(RestRequest request) { - if (_destroyed) throw new Exception(); - //Assume this obj's parent is under lock - _queue.Enqueue(request); - Debug($"Request queued ({WindowCount}/{WindowMaxCount} + {_queue.Count})"); } public async Task ProcessQueue(bool acquireLock = false) { @@ -81,12 +77,17 @@ namespace Discord.Net.Rest try { - Stream stream; - if (request.IsMultipart) - stream = await _parent.RestClient.Send(request.Method, request.Endpoint, request.MultipartParams, request.HeaderOnly).ConfigureAwait(false); + if (request.CancelToken.IsCancellationRequested) + request.Promise.SetException(new OperationCanceledException(request.CancelToken)); else - stream = await _parent.RestClient.Send(request.Method, request.Endpoint, request.Json, request.HeaderOnly).ConfigureAwait(false); - request.Promise.SetResult(stream); + { + Stream stream; + if (request.IsMultipart) + stream = await _parent.RestClient.Send(request.Method, request.Endpoint, request.CancelToken, request.MultipartParams, request.HeaderOnly).ConfigureAwait(false); + else + stream = await _parent.RestClient.Send(request.Method, request.Endpoint, request.CancelToken, request.Json, request.HeaderOnly).ConfigureAwait(false); + request.Promise.SetResult(stream); + } } catch (HttpRateLimitException ex) //Preemptive check failed, use Discord's time instead of our own { @@ -94,17 +95,13 @@ namespace Discord.Net.Rest var task = _resetTask; if (task != null) { - Debug($"External rate limit: Extended to {ex.RetryAfterMilliseconds} ms"); var retryAfter = DateTime.UtcNow.AddMilliseconds(ex.RetryAfterMilliseconds); await task.ConfigureAwait(false); int millis = (int)Math.Ceiling((DateTime.UtcNow - retryAfter).TotalMilliseconds); _resetTask = ResetAfter(millis); } else - { - Debug($"External rate limit: Reset in {ex.RetryAfterMilliseconds} ms"); _resetTask = ResetAfter(ex.RetryAfterMilliseconds); - } return; } catch (HttpException ex) @@ -128,13 +125,11 @@ namespace Discord.Net.Rest _queue.TryDequeue(out request); WindowCount++; nextRetry = 1000; - Debug($"Request succeeded ({WindowCount}/{WindowMaxCount} + {_queue.Count})"); if (WindowCount == 1 && WindowSeconds > 0) { //First request for this window, schedule a reset _resetTask = ResetAfter(WindowSeconds * 1000); - Debug($"Internal rate limit: Reset in {WindowSeconds * 1000} ms"); } } @@ -145,11 +140,7 @@ namespace Discord.Net.Rest { await _parent.Lock().ConfigureAwait(false); if (_queue.IsEmpty) //Double check, in case a request was queued before we got both locks - { - Debug($"Destroy"); _parent.DestroyGuildBucket((GuildBucket)_bucketId, _guildId); - _destroyed = true; - } } finally { @@ -179,8 +170,6 @@ namespace Discord.Net.Rest { await Lock().ConfigureAwait(false); - Debug($"Reset"); - //Reset the current window count and set our state back to normal WindowCount = 0; _resetTask = null; @@ -188,10 +177,7 @@ namespace Discord.Net.Rest //Wait is over, work through the current queue await ProcessQueue().ConfigureAwait(false); } - finally - { - Unlock(); - } + finally { Unlock(); } } public async Task Lock() @@ -202,24 +188,5 @@ namespace Discord.Net.Rest { _lock.Release(); } - - //TODO: Remove - private void Debug(string text) - { - string name; - switch (_bucketGroup) - { - case BucketGroup.Global: - name = ((GlobalBucket)_bucketId).ToString(); - break; - case BucketGroup.Guild: - name = ((GuildBucket)_bucketId).ToString(); - break; - default: - name = "Unknown"; - break; - } - System.Diagnostics.Debug.WriteLine($"[{name} {_id}] {text}"); - } } } diff --git a/src/Discord.Net/Net/Rest/RequestQueue/RestRequest.cs b/src/Discord.Net/Net/Rest/RequestQueue/RestRequest.cs index 098dccc8a..715333873 100644 --- a/src/Discord.Net/Net/Rest/RequestQueue/RestRequest.cs +++ b/src/Discord.Net/Net/Rest/RequestQueue/RestRequest.cs @@ -1,15 +1,17 @@ using System.Collections.Generic; using System.IO; +using System.Threading; using System.Threading.Tasks; namespace Discord.Net.Rest { - internal struct RestRequest + internal class RestRequest { public string Method { get; } public string Endpoint { get; } public string Json { get; } public bool HeaderOnly { get; } + public CancellationToken CancelToken { get; internal set; } public IReadOnlyDictionary MultipartParams { get; } public TaskCompletionSource Promise { get; } diff --git a/src/Discord.Net/Net/Rest/RestClientProvider.cs b/src/Discord.Net/Net/Rest/RestClientProvider.cs index cf3ee0846..341d18f02 100644 --- a/src/Discord.Net/Net/Rest/RestClientProvider.cs +++ b/src/Discord.Net/Net/Rest/RestClientProvider.cs @@ -2,5 +2,5 @@ namespace Discord.Net.Rest { - public delegate IRestClient RestClientProvider(string baseUrl, CancellationToken cancelToken); + public delegate IRestClient RestClientProvider(string baseUrl); } diff --git a/src/Discord.Net/Rest/DiscordClient.cs b/src/Discord.Net/Rest/DiscordClient.cs index c719fab3e..504ff86f9 100644 --- a/src/Discord.Net/Rest/DiscordClient.cs +++ b/src/Discord.Net/Rest/DiscordClient.cs @@ -1,13 +1,10 @@ using Discord.API.Rest; using Discord.Logging; -using Discord.Net; using Discord.Net.Rest; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; @@ -43,7 +40,7 @@ namespace Discord.Rest _connectionLock = new SemaphoreSlim(1, 1); _log = new LogManager(config.LogLevel); _userAgent = DiscordConfig.UserAgent; - BaseClient = new API.DiscordRawClient(_restClientProvider, _cancelTokenSource.Token); + BaseClient = new API.DiscordRawClient(_restClientProvider); _log.Message += (s,e) => Log.Raise(this, e); } @@ -69,38 +66,43 @@ namespace Discord.Rest private async Task LoginInternal(string email, string password) { if (IsLoggedIn) - LogoutInternal(); + await LogoutInternal().ConfigureAwait(false); try { - var cancelTokenSource = new CancellationTokenSource(); + _cancelTokenSource = new CancellationTokenSource(); var args = new LoginParams { Email = email, Password = password }; - await BaseClient.Login(args).ConfigureAwait(false); - await CompleteLogin(cancelTokenSource, false).ConfigureAwait(false); + await BaseClient.Login(args, _cancelTokenSource.Token).ConfigureAwait(false); + await CompleteLogin(false).ConfigureAwait(false); } - catch { LogoutInternal(); throw; } + catch { await LogoutInternal().ConfigureAwait(false); throw; } } private async Task LoginInternal(TokenType tokenType, string token, bool validateToken) { if (IsLoggedIn) - LogoutInternal(); + await LogoutInternal().ConfigureAwait(false); try { - var cancelTokenSource = new CancellationTokenSource(); + _cancelTokenSource = new CancellationTokenSource(); - BaseClient.SetToken(tokenType, token); - await CompleteLogin(cancelTokenSource, validateToken).ConfigureAwait(false); + await BaseClient.Login(tokenType, token, _cancelTokenSource.Token).ConfigureAwait(false); + await CompleteLogin(validateToken).ConfigureAwait(false); } - catch { LogoutInternal(); throw; } + catch { await LogoutInternal().ConfigureAwait(false); throw; } } - private async Task CompleteLogin(CancellationTokenSource cancelTokenSource, bool validateToken) + private async Task CompleteLogin(bool validateToken) { BaseClient.SentRequest += (s, e) => _log.Verbose("Rest", $"{e.Method} {e.Endpoint}: {e.Milliseconds} ms"); if (validateToken) - await BaseClient.ValidateToken().ConfigureAwait(false); - - _cancelTokenSource = cancelTokenSource; + { + try + { + await BaseClient.ValidateToken().ConfigureAwait(false); + } + catch { await BaseClient.Logout().ConfigureAwait(false); } + } + IsLoggedIn = true; LoggedIn.Raise(this); } @@ -111,11 +113,11 @@ namespace Discord.Rest await _connectionLock.WaitAsync().ConfigureAwait(false); try { - LogoutInternal(); + await LogoutInternal().ConfigureAwait(false); } finally { _connectionLock.Release(); } } - private void LogoutInternal() + private async Task LogoutInternal() { bool wasLoggedIn = IsLoggedIn; @@ -125,7 +127,7 @@ namespace Discord.Rest catch { } } - BaseClient.SetToken(TokenType.User, null); + await BaseClient.Logout().ConfigureAwait(false); _currentUser = null; if (wasLoggedIn) diff --git a/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs b/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs index 5d9220ade..d73d45a2e 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs +++ b/src/Discord.Net/Rest/Entities/Guilds/GuildEmbed.cs @@ -1,7 +1,7 @@ using System; using Model = Discord.API.GuildEmbed; -namespace Discord.Rest +namespace Discord { public class GuildEmbed : IGuildEmbed { @@ -12,14 +12,11 @@ namespace Discord.Rest /// public ulong? ChannelId { get; private set; } - internal DiscordClient Discord { get; } - /// public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); - internal GuildEmbed(DiscordClient discord, Model model) + internal GuildEmbed(Model model) { - Discord = discord; Update(model); } diff --git a/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs b/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs index c479f9f4d..5b2a83a78 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs +++ b/src/Discord.Net/Rest/Entities/Guilds/GuildIntegration.cs @@ -82,6 +82,6 @@ namespace Discord.Rest IGuild IGuildIntegration.Guild => Guild; IRole IGuildIntegration.Role => Role; IUser IGuildIntegration.User => User; - IIntegrationAccount IGuildIntegration.Account => Account; + IntegrationAccount IGuildIntegration.Account => Account; } } diff --git a/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs b/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs index cae71f5ae..596719d7b 100644 --- a/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs +++ b/src/Discord.Net/Rest/Entities/Guilds/UserGuild.cs @@ -2,7 +2,7 @@ using System.Threading.Tasks; using Model = Discord.API.UserGuild; -namespace Discord.Rest +namespace Discord { public class UserGuild : IUserGuild { @@ -10,7 +10,7 @@ namespace Discord.Rest /// public ulong Id { get; } - internal DiscordClient Discord { get; } + internal IDiscordClient Discord { get; } /// public string Name { get; private set; } @@ -22,7 +22,7 @@ namespace Discord.Rest /// public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); - internal UserGuild(DiscordClient discord, Model model) + internal UserGuild(IDiscordClient discord, Model model) { Discord = discord; Id = model.Id; @@ -40,15 +40,11 @@ namespace Discord.Rest /// public async Task Leave() { - if (IsOwner) - throw new InvalidOperationException("Unable to leave a guild the current user owns."); await Discord.BaseClient.LeaveGuild(Id).ConfigureAwait(false); } /// public async Task Delete() { - if (!IsOwner) - throw new InvalidOperationException("Unable to delete a guild the current user does not own."); await Discord.BaseClient.DeleteGuild(Id).ConfigureAwait(false); } diff --git a/src/Discord.Net/Rest/Entities/Invites/GuildInvite.cs b/src/Discord.Net/Rest/Entities/Invites/GuildInvite.cs index 98087d694..71b0541e2 100644 --- a/src/Discord.Net/Rest/Entities/Invites/GuildInvite.cs +++ b/src/Discord.Net/Rest/Entities/Invites/GuildInvite.cs @@ -21,7 +21,7 @@ namespace Discord.Rest /// public int Uses { get; private set; } - internal override DiscordClient Discord => Guild.Discord; + internal override IDiscordClient Discord => Guild.Discord; internal GuildInvite(Guild guild, Model model) : base(model) diff --git a/src/Discord.Net/Rest/Entities/Message.cs b/src/Discord.Net/Rest/Entities/Message.cs index 24c3eb4df..fb74970bf 100644 --- a/src/Discord.Net/Rest/Entities/Message.cs +++ b/src/Discord.Net/Rest/Entities/Message.cs @@ -135,7 +135,6 @@ namespace Discord.Rest await Discord.BaseClient.DeleteMessage(Channel.Id, Id).ConfigureAwait(false); } - public override string ToString() => $"{Author.ToString()}: {Text}"; IUser IMessage.Author => Author; diff --git a/src/Discord.Net/Rest/Entities/Users/User.cs b/src/Discord.Net/Rest/Entities/Users/User.cs index 9572c6620..eca5ff5cd 100644 --- a/src/Discord.Net/Rest/Entities/Users/User.cs +++ b/src/Discord.Net/Rest/Entities/Users/User.cs @@ -45,10 +45,7 @@ namespace Discord.Rest public async Task CreateDMChannel() { - var args = new CreateDMChannelParams - { - RecipientId = Id - }; + var args = new CreateDMChannelParams { RecipientId = Id }; var model = await Discord.BaseClient.CreateDMChannel(args).ConfigureAwait(false); return new DMChannel(Discord, model); diff --git a/src/Discord.Net/WebSocket/Caches/ChannelPermissionsCache.cs b/src/Discord.Net/WebSocket/Caches/ChannelPermissionsCache.cs new file mode 100644 index 000000000..4a79d23fc --- /dev/null +++ b/src/Discord.Net/WebSocket/Caches/ChannelPermissionsCache.cs @@ -0,0 +1,71 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.WebSocket +{ + internal struct ChannelMember + { + public GuildUser User { get; } + public ChannelPermissions Permissions { get; } + + public ChannelMember(GuildUser user, ChannelPermissions permissions) + { + User = user; + Permissions = permissions; + } + } + + internal class ChannelPermissionsCache + { + private readonly GuildChannel _channel; + private readonly ConcurrentDictionary _users; + + public IEnumerable Members => _users.Select(x => x.Value); + + public ChannelPermissionsCache(GuildChannel channel) + { + _channel = channel; + _users = new ConcurrentDictionary(1, (int)(_channel.Guild.UserCount * 1.05)); + } + + public ChannelMember? Get(ulong id) + { + ChannelMember member; + if (_users.TryGetValue(id, out member)) + return member; + return null; + } + public void Add(GuildUser user) + { + _users[user.Id] = new ChannelMember(user, new ChannelPermissions(PermissionHelper.Resolve(user, _channel))); + } + public void Remove(GuildUser user) + { + ChannelMember member; + _users.TryRemove(user.Id, out member); + } + + public void UpdateAll() + { + foreach (var pair in _users) + { + var member = pair.Value; + var newPerms = PermissionHelper.Resolve(member.User, _channel); + if (newPerms != member.Permissions.RawValue) + _users[pair.Key] = new ChannelMember(member.User, new ChannelPermissions(newPerms)); + } + } + public void Update(GuildUser user) + { + ChannelMember member; + if (_users.TryGetValue(user.Id, out member)) + { + var newPerms = PermissionHelper.Resolve(user, _channel); + if (newPerms != member.Permissions.RawValue) + _users[user.Id] = new ChannelMember(user, new ChannelPermissions(newPerms)); + } + } + } +} diff --git a/src/Discord.Net/WebSocket/Caches/MessageCache.cs b/src/Discord.Net/WebSocket/Caches/MessageCache.cs new file mode 100644 index 000000000..f3ef2b58b --- /dev/null +++ b/src/Discord.Net/WebSocket/Caches/MessageCache.cs @@ -0,0 +1,98 @@ +using Discord.API.Rest; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + internal class MessageCache + { + private readonly DiscordClient _discord; + private readonly IMessageChannel _channel; + private readonly ConcurrentDictionary _messages; + private readonly ConcurrentQueue _orderedMessages; + private readonly int _size; + + public MessageCache(DiscordClient discord, IMessageChannel channel) + { + _discord = discord; + _channel = channel; + _size = discord.MessageCacheSize; + _messages = new ConcurrentDictionary(1, (int)(_size * 1.05)); + _orderedMessages = new ConcurrentQueue(); + } + + internal void Add(Message message) + { + if (_messages.TryAdd(message.Id, message)) + { + _orderedMessages.Enqueue(message.Id); + + ulong msgId; + Message msg; + while (_orderedMessages.Count > _size && _orderedMessages.TryDequeue(out msgId)) + _messages.TryRemove(msgId, out msg); + } + } + + internal void Remove(ulong id) + { + Message msg; + _messages.TryRemove(id, out msg); + } + + public Message Get(ulong id) + { + Message result; + if (_messages.TryGetValue(id, out result)) + return result; + return null; + } + public async Task> GetMany(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + //TODO: Test heavily + + if (limit < 0) throw new ArgumentOutOfRangeException(nameof(limit)); + if (limit == 0) return ImmutableArray.Empty; + + IEnumerable cachedMessageIds; + if (fromMessageId == null) + cachedMessageIds = _orderedMessages; + else if (dir == Direction.Before) + cachedMessageIds = _orderedMessages.Where(x => x < fromMessageId.Value); + else + cachedMessageIds = _orderedMessages.Where(x => x > fromMessageId.Value); + + var cachedMessages = cachedMessageIds + .Take(limit) + .Select(x => + { + Message msg; + if (_messages.TryGetValue(x, out msg)) + return msg; + return null; + }) + .Where(x => x != null) + .ToArray(); + + if (cachedMessages.Length == limit) + return cachedMessages; + else if (cachedMessages.Length > limit) + return cachedMessages.Skip(cachedMessages.Length - limit); + else + { + var args = new GetChannelMessagesParams + { + Limit = limit - cachedMessages.Length, + RelativeDirection = dir, + RelativeMessageId = dir == Direction.Before ? cachedMessages[0].Id : cachedMessages[cachedMessages.Length - 1].Id + }; + var downloadedMessages = await _discord.BaseClient.GetChannelMessages(_channel.Id, args).ConfigureAwait(false); + return cachedMessages.AsEnumerable().Concat(downloadedMessages.Select(x => new Message(_channel, x))).ToImmutableArray(); + } + } + } +} diff --git a/src/Discord.Net/WebSocket/DiscordClient.cs b/src/Discord.Net/WebSocket/DiscordClient.cs new file mode 100644 index 000000000..4032a5539 --- /dev/null +++ b/src/Discord.Net/WebSocket/DiscordClient.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Discord.API; +using Discord.Net.Rest; + +namespace Discord.WebSocket +{ + public class DiscordClient : IDiscordClient + { + internal int MessageCacheSize { get; } = 100; + + public SelfUser CurrentUser + { + get + { + throw new NotImplementedException(); + } + } + + public TokenType AuthTokenType + { + get + { + throw new NotImplementedException(); + } + } + + public DiscordRawClient BaseClient + { + get + { + throw new NotImplementedException(); + } + } + + public IRequestQueue RequestQueue + { + get + { + throw new NotImplementedException(); + } + } + + public IRestClient RestClient + { + get + { + throw new NotImplementedException(); + } + } + + public Task CreateGuild(string name, IVoiceRegion region, Stream jpegIcon = null) + { + throw new NotImplementedException(); + } + + public Task GetChannel(ulong id) + { + throw new NotImplementedException(); + } + + public Task> GetConnections() + { + throw new NotImplementedException(); + } + + public Task GetCurrentUser() + { + throw new NotImplementedException(); + } + + public Task> GetDMChannels() + { + throw new NotImplementedException(); + } + + public Task GetGuild(ulong id) + { + throw new NotImplementedException(); + } + + public Task> GetGuilds() + { + throw new NotImplementedException(); + } + + public Task GetInvite(string inviteIdOrXkcd) + { + throw new NotImplementedException(); + } + + public Task GetOptimalVoiceRegion() + { + throw new NotImplementedException(); + } + + public Task GetUser(ulong id) + { + throw new NotImplementedException(); + } + + public Task GetUser(string username, ushort discriminator) + { + throw new NotImplementedException(); + } + + public Task GetVoiceRegion(string id) + { + throw new NotImplementedException(); + } + + public Task> GetVoiceRegions() + { + throw new NotImplementedException(); + } + + public Task Login(string email, string password) + { + throw new NotImplementedException(); + } + + public Task Login(TokenType tokenType, string token, bool validateToken = true) + { + throw new NotImplementedException(); + } + + public Task Logout() + { + throw new NotImplementedException(); + } + + public Task> QueryUsers(string query, int limit) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs new file mode 100644 index 000000000..45d484d2a --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Channels/DMChannel.cs @@ -0,0 +1,141 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + public class DMChannel : IDMChannel + { + private readonly MessageCache _messages; + + /// + public ulong Id { get; } + internal DiscordClient Discord { get; } + + /// + public DMUser Recipient { get; private set; } + + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + /// + public IEnumerable Users => ImmutableArray.Create(Discord.CurrentUser, Recipient); + + internal DMChannel(DiscordClient discord, Model model) + { + Id = model.Id; + Discord = discord; + _messages = new MessageCache(Discord, this); + + Update(model); + } + private void Update(Model model) + { + if (Recipient == null) + Recipient = new DMUser(this, model.Recipient); + else + Recipient.Update(model.Recipient); + } + + /// + public IUser GetUser(ulong id) + { + if (id == Recipient.Id) + return Recipient; + else if (id == Discord.CurrentUser.Id) + return Discord.CurrentUser; + else + return null; + } + + /// + public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) + { + return await _messages.GetMany(null, Direction.Before, limit).ConfigureAwait(false); + } + /// + public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + return await _messages.GetMany(fromMessageId, dir, limit).ConfigureAwait(false); + } + + /// + public async Task SendMessage(string text, bool isTTS = false) + { + var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.CreateMessage(Id, args).ConfigureAwait(false); + return new Message(this, model); + } + /// + public async Task SendFile(string filePath, string text = null, bool isTTS = false) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.UploadFile(Id, file, args).ConfigureAwait(false); + return new Message(this, model); + } + } + /// + public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.UploadFile(Id, stream, args).ConfigureAwait(false); + return new Message(this, model); + } + + /// + public async Task DeleteMessages(IEnumerable messages) + { + await Discord.BaseClient.DeleteMessages(Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); + } + + /// + public async Task TriggerTyping() + { + await Discord.BaseClient.TriggerTypingIndicator(Id).ConfigureAwait(false); + } + + /// + public async Task Close() + { + await Discord.BaseClient.DeleteChannel(Id).ConfigureAwait(false); + } + + /// + public async Task Update() + { + var model = await Discord.BaseClient.GetChannel(Id).ConfigureAwait(false); + Update(model); + } + + /// + public override string ToString() => $"@{Recipient} [DM]"; + + IDMUser IDMChannel.Recipient => Recipient; + + Task> IChannel.GetUsers() + => Task.FromResult(Users); + Task IChannel.GetUser(ulong id) + => Task.FromResult(GetUser(id)); + Task IMessageChannel.GetMessage(ulong id) + => throw new NotSupportedException(); + async Task> IMessageChannel.GetMessages(int limit) + => await GetMessages(limit).ConfigureAwait(false); + async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) + => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); + async Task IMessageChannel.SendMessage(string text, bool isTTS) + => await SendMessage(text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) + => await SendFile(filePath, text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) + => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.TriggerTyping() + => await TriggerTyping().ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs new file mode 100644 index 000000000..25d4ba619 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Channels/GuildChannel.cs @@ -0,0 +1,171 @@ +using Discord.API.Rest; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + public abstract class GuildChannel : IGuildChannel + { + private ConcurrentDictionary _overwrites; + private ChannelPermissionsCache _permissions; + + /// + public ulong Id { get; } + /// Gets the guild this channel is a member of. + public Guild Guild { get; } + + /// + public string Name { get; private set; } + /// + public int Position { get; private set; } + + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + /// + public IReadOnlyDictionary PermissionOverwrites => _overwrites; + internal DiscordClient Discord => Guild.Discord; + + internal GuildChannel(Guild guild, Model model) + { + Id = model.Id; + Guild = guild; + + Update(model); + } + internal virtual void Update(Model model) + { + Name = model.Name; + Position = model.Position; + + var newOverwrites = new ConcurrentDictionary(); + for (int i = 0; i < model.PermissionOverwrites.Length; i++) + { + var overwrite = model.PermissionOverwrites[i]; + newOverwrites[overwrite.TargetId] = new Overwrite(overwrite); + } + _overwrites = newOverwrites; + } + + public async Task Modify(Action func) + { + if (func != null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildChannelParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); + Update(model); + } + + /// Gets a user in this channel with the given id. + public async Task GetUser(ulong id) + { + var model = await Discord.BaseClient.GetGuildMember(Guild.Id, id).ConfigureAwait(false); + if (model != null) + return new GuildUser(Guild, model); + return null; + } + protected abstract Task> GetUsers(); + + /// Gets the permission overwrite for a specific user, or null if one does not exist. + public OverwritePermissions? GetPermissionOverwrite(IUser user) + { + Overwrite value; + if (_overwrites.TryGetValue(Id, out value)) + return value.Permissions; + return null; + } + /// Gets the permission overwrite for a specific role, or null if one does not exist. + public OverwritePermissions? GetPermissionOverwrite(IRole role) + { + Overwrite value; + if (_overwrites.TryGetValue(Id, out value)) + return value.Permissions; + return null; + } + /// Downloads a collection of all invites to this channel. + public async Task> GetInvites() + { + var models = await Discord.BaseClient.GetChannelInvites(Id).ConfigureAwait(false); + return models.Select(x => new GuildInvite(Guild, x)); + } + + /// Adds or updates the permission overwrite for the given user. + public async Task AddPermissionOverwrite(IUser user, OverwritePermissions perms) + { + var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; + await Discord.BaseClient.ModifyChannelPermissions(Id, user.Id, args).ConfigureAwait(false); + _overwrites[user.Id] = new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = user.Id, TargetType = PermissionTarget.User }); + } + /// Adds or updates the permission overwrite for the given role. + public async Task AddPermissionOverwrite(IRole role, OverwritePermissions perms) + { + var args = new ModifyChannelPermissionsParams { Allow = perms.AllowValue, Deny = perms.DenyValue }; + await Discord.BaseClient.ModifyChannelPermissions(Id, role.Id, args).ConfigureAwait(false); + _overwrites[role.Id] = new Overwrite(new API.Overwrite { Allow = perms.AllowValue, Deny = perms.DenyValue, TargetId = role.Id, TargetType = PermissionTarget.Role }); + } + /// Removes the permission overwrite for the given user, if one exists. + public async Task RemovePermissionOverwrite(IUser user) + { + await Discord.BaseClient.DeleteChannelPermission(Id, user.Id).ConfigureAwait(false); + + Overwrite value; + _overwrites.TryRemove(user.Id, out value); + } + /// Removes the permission overwrite for the given role, if one exists. + public async Task RemovePermissionOverwrite(IRole role) + { + await Discord.BaseClient.DeleteChannelPermission(Id, role.Id).ConfigureAwait(false); + + Overwrite value; + _overwrites.TryRemove(role.Id, out value); + } + + /// Creates a new invite to this channel. + /// Time (in seconds) until the invite expires. Set to null to never expire. + /// The max amount of times this invite may be used. Set to null to have unlimited uses. + /// If true, a user accepting this invite will be kicked from the guild after closing their client. + /// If true, creates a human-readable link. Not supported if maxAge is set to null. + public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) + { + var args = new CreateChannelInviteParams + { + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + Temporary = isTemporary, + XkcdPass = withXkcd + }; + var model = await Discord.BaseClient.CreateChannelInvite(Id, args).ConfigureAwait(false); + return new GuildInvite(Guild, model); + } + + /// + public async Task Delete() + { + await Discord.BaseClient.DeleteChannel(Id).ConfigureAwait(false); + } + /// + public async Task Update() + { + var model = await Discord.BaseClient.GetChannel(Id).ConfigureAwait(false); + Update(model); + } + + IGuild IGuildChannel.Guild => Guild; + async Task IGuildChannel.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) + => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); + async Task> IGuildChannel.GetInvites() + => await GetInvites().ConfigureAwait(false); + async Task> IGuildChannel.GetUsers() + => await GetUsers().ConfigureAwait(false); + async Task IGuildChannel.GetUser(ulong id) + => await GetUser(id).ConfigureAwait(false); + async Task> IChannel.GetUsers() + => await GetUsers().ConfigureAwait(false); + async Task IChannel.GetUser(ulong id) + => await GetUser(id).ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs new file mode 100644 index 000000000..2d1b0b818 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Channels/TextChannel.cs @@ -0,0 +1,122 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + public class TextChannel : GuildChannel, ITextChannel + { + private readonly MessageCache _messages; + + /// + public string Topic { get; private set; } + + /// + public string Mention => MentionHelper.Mention(this); + + internal TextChannel(Guild guild, Model model) + : base(guild, model) + { + _messages = new MessageCache(Discord, this); + } + + internal override void Update(Model model) + { + Topic = model.Topic; + base.Update(model); + } + + public async Task Modify(Action func) + { + if (func != null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyTextChannelParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); + Update(model); + } + + protected override async Task> GetUsers() + { + var users = await Guild.GetUsers().ConfigureAwait(false); + return users.Where(x => PermissionUtilities.GetValue(PermissionHelper.Resolve(x, this), ChannelPermission.ReadMessages)); + } + + /// + public Task GetMessage(ulong id) { throw new NotSupportedException(); } //Not implemented + /// + public async Task> GetMessages(int limit = DiscordConfig.MaxMessagesPerBatch) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.BaseClient.GetChannelMessages(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, x)); + } + /// + public async Task> GetMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + var args = new GetChannelMessagesParams { Limit = limit }; + var models = await Discord.BaseClient.GetChannelMessages(Id, args).ConfigureAwait(false); + return models.Select(x => new Message(this, x)); + } + + /// + public async Task SendMessage(string text, bool isTTS = false) + { + var args = new CreateMessageParams { Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.CreateMessage(Guild.Id, Id, args).ConfigureAwait(false); + return new Message(this, model); + } + /// + public async Task SendFile(string filePath, string text = null, bool isTTS = false) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.UploadFile(Guild.Id, Id, file, args).ConfigureAwait(false); + return new Message(this, model); + } + } + /// + public async Task SendFile(Stream stream, string filename, string text = null, bool isTTS = false) + { + var args = new UploadFileParams { Filename = filename, Content = text, IsTTS = isTTS }; + var model = await Discord.BaseClient.UploadFile(Guild.Id, Id, stream, args).ConfigureAwait(false); + return new Message(this, model); + } + + /// + public async Task DeleteMessages(IEnumerable messages) + { + await Discord.BaseClient.DeleteMessages(Guild.Id, Id, new DeleteMessagesParam { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); + } + + /// + public async Task TriggerTyping() + { + await Discord.BaseClient.TriggerTypingIndicator(Id).ConfigureAwait(false); + } + + /// + public override string ToString() => $"{base.ToString()} [Text]"; + + async Task IMessageChannel.GetMessage(ulong id) + => await GetMessage(id).ConfigureAwait(false); + async Task> IMessageChannel.GetMessages(int limit) + => await GetMessages(limit).ConfigureAwait(false); + async Task> IMessageChannel.GetMessages(ulong fromMessageId, Direction dir, int limit) + => await GetMessages(fromMessageId, dir, limit).ConfigureAwait(false); + async Task IMessageChannel.SendMessage(string text, bool isTTS) + => await SendMessage(text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.SendFile(string filePath, string text, bool isTTS) + => await SendFile(filePath, text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.SendFile(Stream stream, string filename, string text, bool isTTS) + => await SendFile(stream, filename, text, isTTS).ConfigureAwait(false); + async Task IMessageChannel.TriggerTyping() + => await TriggerTyping().ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs b/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs new file mode 100644 index 000000000..e1819f9a9 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Channels/VoiceChannel.cs @@ -0,0 +1,45 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + public class VoiceChannel : GuildChannel, IVoiceChannel + { + /// + public int Bitrate { get; private set; } + + internal VoiceChannel(Guild guild, Model model) + : base(guild, model) + { + } + internal override void Update(Model model) + { + base.Update(model); + Bitrate = model.Bitrate; + } + + /// + public async Task Modify(Action func) + { + if (func != null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyVoiceChannelParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuildChannel(Id, args).ConfigureAwait(false); + Update(model); + } + + protected override async Task> GetUsers() + { + var users = await Guild.GetUsers().ConfigureAwait(false); + return users.Where(x => PermissionUtilities.GetValue(PermissionHelper.Resolve(x, this), ChannelPermission.Connect)); + } + + /// + public override string ToString() => $"{base.ToString()} [Voice]"; + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs b/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs new file mode 100644 index 000000000..24c97e2d3 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Guilds/Guild.cs @@ -0,0 +1,374 @@ +using Discord.API.Rest; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Guild; +using EmbedModel = Discord.API.GuildEmbed; +using RoleModel = Discord.API.Role; + +namespace Discord.WebSocket +{ + /// Represents a Discord guild (called a server in the official client). + public class Guild : IGuild + { + private ConcurrentDictionary _roles; + private string _iconId, _splashId; + + /// + public ulong Id { get; } + internal DiscordClient Discord { get; } + + /// + public string Name { get; private set; } + /// + public int AFKTimeout { get; private set; } + /// + public bool IsEmbeddable { get; private set; } + /// + public int VerificationLevel { get; private set; } + public int UserCount { get; private set; } + + /// + public ulong? AFKChannelId { get; private set; } + /// + public ulong? EmbedChannelId { get; private set; } + /// + public ulong OwnerId { get; private set; } + /// + public string VoiceRegionId { get; private set; } + /// + public IReadOnlyList Emojis { get; private set; } + /// + public IReadOnlyList Features { get; private set; } + + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + /// + public string IconUrl => API.CDN.GetGuildIconUrl(Id, _iconId); + /// + public string SplashUrl => API.CDN.GetGuildSplashUrl(Id, _splashId); + /// + public ulong DefaultChannelId => Id; + /// + public Role EveryoneRole => GetRole(Id); + /// Gets a collection of all roles in this guild. + public IEnumerable Roles => _roles?.Select(x => x.Value) ?? Enumerable.Empty(); + + internal Guild(DiscordClient discord, Model model) + { + Id = model.Id; + Discord = discord; + + Update(model); + } + private void Update(Model model) + { + AFKChannelId = model.AFKChannelId; + AFKTimeout = model.AFKTimeout; + EmbedChannelId = model.EmbedChannelId; + IsEmbeddable = model.EmbedEnabled; + Features = model.Features; + _iconId = model.Icon; + Name = model.Name; + OwnerId = model.OwnerId; + VoiceRegionId = model.Region; + _splashId = model.Splash; + VerificationLevel = model.VerificationLevel; + + if (model.Emojis != null) + { + var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emojis.Add(new Emoji(model.Emojis[i])); + Emojis = emojis.ToArray(); + } + else + Emojis = Array.Empty(); + + var roles = new ConcurrentDictionary(1, model.Roles?.Length ?? 0); + if (model.Roles != null) + { + for (int i = 0; i < model.Roles.Length; i++) + roles[model.Roles[i].Id] = new Role(this, model.Roles[i]); + } + _roles = roles; + } + private void Update(EmbedModel model) + { + IsEmbeddable = model.Enabled; + EmbedChannelId = model.ChannelId; + } + private void Update(IEnumerable models) + { + Role role; + foreach (var model in models) + { + if (_roles.TryGetValue(model.Id, out role)) + role.Update(model); + } + } + + /// + public async Task Update() + { + var response = await Discord.BaseClient.GetGuild(Id).ConfigureAwait(false); + Update(response); + } + /// + public async Task Modify(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuild(Id, args).ConfigureAwait(false); + Update(model); + } + /// + public async Task ModifyEmbed(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildEmbedParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuildEmbed(Id, args).ConfigureAwait(false); + + Update(model); + } + /// + public async Task ModifyChannels(IEnumerable args) + { + if (args == null) throw new NullReferenceException(nameof(args)); + + await Discord.BaseClient.ModifyGuildChannels(Id, args).ConfigureAwait(false); + } + /// + public async Task ModifyRoles(IEnumerable args) + { + if (args == null) throw new NullReferenceException(nameof(args)); + + var models = await Discord.BaseClient.ModifyGuildRoles(Id, args).ConfigureAwait(false); + Update(models); + } + /// + public async Task Leave() + { + await Discord.BaseClient.LeaveGuild(Id).ConfigureAwait(false); + } + /// + public async Task Delete() + { + await Discord.BaseClient.DeleteGuild(Id).ConfigureAwait(false); + } + + /// + public async Task> GetBans() + { + var models = await Discord.BaseClient.GetGuildBans(Id).ConfigureAwait(false); + return models.Select(x => new PublicUser(Discord, x)); + } + /// + public Task AddBan(IUser user, int pruneDays = 0) => AddBan(user, pruneDays); + /// + public async Task AddBan(ulong userId, int pruneDays = 0) + { + var args = new CreateGuildBanParams() + { + PruneDays = pruneDays + }; + await Discord.BaseClient.CreateGuildBan(Id, userId, args).ConfigureAwait(false); + } + /// + public Task RemoveBan(IUser user) => RemoveBan(user.Id); + /// + public async Task RemoveBan(ulong userId) + { + await Discord.BaseClient.RemoveGuildBan(Id, userId).ConfigureAwait(false); + } + + /// Gets the channel in this guild with the provided id, or null if not found. + public async Task GetChannel(ulong id) + { + var model = await Discord.BaseClient.GetChannel(Id, id).ConfigureAwait(false); + if (model != null) + return ToChannel(model); + return null; + } + /// Gets a collection of all channels in this guild. + public async Task> GetChannels() + { + var models = await Discord.BaseClient.GetGuildChannels(Id).ConfigureAwait(false); + return models.Select(x => ToChannel(x)); + } + /// Creates a new text channel. + public async Task CreateTextChannel(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var args = new CreateGuildChannelParams() { Name = name, Type = ChannelType.Text }; + var model = await Discord.BaseClient.CreateGuildChannel(Id, args).ConfigureAwait(false); + return new TextChannel(this, model); + } + /// Creates a new voice channel. + public async Task CreateVoiceChannel(string name) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var args = new CreateGuildChannelParams { Name = name, Type = ChannelType.Voice }; + var model = await Discord.BaseClient.CreateGuildChannel(Id, args).ConfigureAwait(false); + return new VoiceChannel(this, model); + } + + /// Gets a collection of all integrations attached to this guild. + public async Task> GetIntegrations() + { + var models = await Discord.BaseClient.GetGuildIntegrations(Id).ConfigureAwait(false); + return models.Select(x => new GuildIntegration(this, x)); + } + /// Creates a new integration for this guild. + public async Task CreateIntegration(ulong id, string type) + { + var args = new CreateGuildIntegrationParams { Id = id, Type = type }; + var model = await Discord.BaseClient.CreateGuildIntegration(Id, args).ConfigureAwait(false); + return new GuildIntegration(this, model); + } + + /// Gets a collection of all invites to this guild. + public async Task> GetInvites() + { + var models = await Discord.BaseClient.GetGuildInvites(Id).ConfigureAwait(false); + return models.Select(x => new GuildInvite(this, x)); + } + /// Creates a new invite to this guild. + public async Task CreateInvite(int? maxAge = 1800, int? maxUses = null, bool isTemporary = false, bool withXkcd = false) + { + if (maxAge <= 0) throw new ArgumentOutOfRangeException(nameof(maxAge)); + if (maxUses <= 0) throw new ArgumentOutOfRangeException(nameof(maxUses)); + + var args = new CreateChannelInviteParams() + { + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + Temporary = isTemporary, + XkcdPass = withXkcd + }; + var model = await Discord.BaseClient.CreateChannelInvite(DefaultChannelId, args).ConfigureAwait(false); + return new GuildInvite(this, model); + } + + /// Gets the role in this guild with the provided id, or null if not found. + public Role GetRole(ulong id) + { + Role result = null; + if (_roles?.TryGetValue(id, out result) == true) + return result; + return null; + } + + /// Creates a new role. + public async Task CreateRole(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false) + { + if (name == null) throw new ArgumentNullException(nameof(name)); + + var model = await Discord.BaseClient.CreateGuildRole(Id).ConfigureAwait(false); + var role = new Role(this, model); + + await role.Modify(x => + { + x.Name = name; + x.Permissions = (permissions ?? role.Permissions).RawValue; + x.Color = (color ?? Color.Default).RawValue; + x.Hoist = isHoisted; + }).ConfigureAwait(false); + + return role; + } + + /// Gets a collection of all users in this guild. + public async Task> GetUsers() + { + var args = new GetGuildMembersParams(); + var models = await Discord.BaseClient.GetGuildMembers(Id, args).ConfigureAwait(false); + return models.Select(x => new GuildUser(this, x)); + } + /// Gets a paged collection of all users in this guild. + public async Task> GetUsers(int limit, int offset) + { + var args = new GetGuildMembersParams { Limit = limit, Offset = offset }; + var models = await Discord.BaseClient.GetGuildMembers(Id, args).ConfigureAwait(false); + return models.Select(x => new GuildUser(this, x)); + } + /// Gets the user in this guild with the provided id, or null if not found. + public async Task GetUser(ulong id) + { + var model = await Discord.BaseClient.GetGuildMember(Id, id).ConfigureAwait(false); + if (model != null) + return new GuildUser(this, model); + return null; + } + /// Gets a the current user. + public async Task GetCurrentUser() + { + var currentUser = await Discord.GetCurrentUser().ConfigureAwait(false); + return await GetUser(currentUser.Id).ConfigureAwait(false); + } + public async Task PruneUsers(int days = 30, bool simulate = false) + { + var args = new GuildPruneParams() { Days = days }; + GetGuildPruneCountResponse model; + if (simulate) + model = await Discord.BaseClient.GetGuildPruneCount(Id, args).ConfigureAwait(false); + else + model = await Discord.BaseClient.BeginGuildPrune(Id, args).ConfigureAwait(false); + return model.Pruned; + } + + internal GuildChannel ToChannel(API.Channel model) + { + switch (model.Type) + { + case ChannelType.Text: + default: + return new TextChannel(this, model); + case ChannelType.Voice: + return new VoiceChannel(this, model); + } + } + + public override string ToString() => Name ?? Id.ToString(); + + IEnumerable IGuild.Emojis => Emojis; + ulong IGuild.EveryoneRoleId => EveryoneRole.Id; + IEnumerable IGuild.Features => Features; + + async Task> IGuild.GetBans() + => await GetBans().ConfigureAwait(false); + async Task IGuild.GetChannel(ulong id) + => await GetChannel(id).ConfigureAwait(false); + async Task> IGuild.GetChannels() + => await GetChannels().ConfigureAwait(false); + async Task IGuild.CreateInvite(int? maxAge, int? maxUses, bool isTemporary, bool withXkcd) + => await CreateInvite(maxAge, maxUses, isTemporary, withXkcd).ConfigureAwait(false); + async Task IGuild.CreateRole(string name, GuildPermissions? permissions, Color? color, bool isHoisted) + => await CreateRole(name, permissions, color, isHoisted).ConfigureAwait(false); + async Task IGuild.CreateTextChannel(string name) + => await CreateTextChannel(name).ConfigureAwait(false); + async Task IGuild.CreateVoiceChannel(string name) + => await CreateVoiceChannel(name).ConfigureAwait(false); + async Task> IGuild.GetInvites() + => await GetInvites().ConfigureAwait(false); + Task IGuild.GetRole(ulong id) + => Task.FromResult(GetRole(id)); + Task> IGuild.GetRoles() + => Task.FromResult>(Roles); + async Task IGuild.GetUser(ulong id) + => await GetUser(id).ConfigureAwait(false); + async Task IGuild.GetCurrentUser() + => await GetCurrentUser().ConfigureAwait(false); + async Task> IGuild.GetUsers() + => await GetUsers().ConfigureAwait(false); + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs b/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs new file mode 100644 index 000000000..d140618ed --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Guilds/GuildIntegration.cs @@ -0,0 +1,87 @@ +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using Model = Discord.API.Integration; + +namespace Discord.WebSocket +{ + public class GuildIntegration : IGuildIntegration + { + /// + public ulong Id { get; private set; } + /// + public string Name { get; private set; } + /// + public string Type { get; private set; } + /// + public bool IsEnabled { get; private set; } + /// + public bool IsSyncing { get; private set; } + /// + public ulong ExpireBehavior { get; private set; } + /// + public ulong ExpireGracePeriod { get; private set; } + /// + public DateTime SyncedAt { get; private set; } + + /// + public Guild Guild { get; private set; } + /// + public Role Role { get; private set; } + /// + public User User { get; private set; } + /// + public IntegrationAccount Account { get; private set; } + internal DiscordClient Discord => Guild.Discord; + + internal GuildIntegration(Guild guild, Model model) + { + Guild = guild; + Update(model); + } + + private void Update(Model model) + { + Id = model.Id; + Name = model.Name; + Type = model.Type; + IsEnabled = model.Enabled; + IsSyncing = model.Syncing; + ExpireBehavior = model.ExpireBehavior; + ExpireGracePeriod = model.ExpireGracePeriod; + SyncedAt = model.SyncedAt; + + Role = Guild.GetRole(model.RoleId); + User = new PublicUser(Discord, model.User); + } + + /// + public async Task Delete() + { + await Discord.BaseClient.DeleteGuildIntegration(Guild.Id, Id).ConfigureAwait(false); + } + /// + public async Task Modify(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildIntegrationParams(); + func(args); + var model = await Discord.BaseClient.ModifyGuildIntegration(Guild.Id, Id, args).ConfigureAwait(false); + + Update(model); + } + /// + public async Task Sync() + { + await Discord.BaseClient.SyncGuildIntegration(Guild.Id, Id).ConfigureAwait(false); + } + + public override string ToString() => $"{Name ?? Id.ToString()} ({(IsEnabled ? "Enabled" : "Disabled")})"; + + IGuild IGuildIntegration.Guild => Guild; + IRole IGuildIntegration.Role => Role; + IUser IGuildIntegration.User => User; + IntegrationAccount IGuildIntegration.Account => Account; + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Invites/GuildInvite.cs b/src/Discord.Net/WebSocket/Entities/Invites/GuildInvite.cs new file mode 100644 index 000000000..c78c7a9e9 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Invites/GuildInvite.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Model = Discord.API.InviteMetadata; + +namespace Discord.WebSocket +{ + public class GuildInvite : Invite, IGuildInvite + { + /// Gets the guild this invite is linked to. + public Guild Guild { get; private set; } + /// + public ulong ChannelId { get; private set; } + + /// + public bool IsRevoked { get; private set; } + /// + public bool IsTemporary { get; private set; } + /// + public int? MaxAge { get; private set; } + /// + public int? MaxUses { get; private set; } + /// + public int Uses { get; private set; } + + internal override IDiscordClient Discord => Guild.Discord; + + internal GuildInvite(Guild guild, Model model) + : base(model) + { + Guild = guild; + + Update(model); //Causes base.Update(Model) to be run twice, but that's fine. + } + private void Update(Model model) + { + base.Update(model); + IsRevoked = model.Revoked; + IsTemporary = model.Temporary; + MaxAge = model.MaxAge != 0 ? model.MaxAge : (int?)null; + MaxUses = model.MaxUses; + Uses = model.Uses; + } + + /// + public async Task Delete() + { + await Discord.BaseClient.DeleteInvite(Code).ConfigureAwait(false); + } + + IGuild IGuildInvite.Guild => Guild; + ulong IInvite.GuildId => Guild.Id; + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Message.cs b/src/Discord.Net/WebSocket/Entities/Message.cs new file mode 100644 index 000000000..1b7d39732 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Message.cs @@ -0,0 +1,146 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord.WebSocket +{ + public class Message : IMessage + { + /// + public ulong Id { get; } + + /// + public DateTime? EditedTimestamp { get; private set; } + /// + public bool IsTTS { get; private set; } + /// + public string RawText { get; private set; } + /// + public string Text { get; private set; } + /// + public DateTime Timestamp { get; private set; } + + /// + public IMessageChannel Channel { get; } + /// + public User Author { get; } + + /// + public IReadOnlyList Attachments { get; private set; } + /// + public IReadOnlyList Embeds { get; private set; } + /// + public IReadOnlyList MentionedUsers { get; private set; } + /// + public IReadOnlyList MentionedChannelIds { get; private set; } + /// + public IReadOnlyList MentionedRoleIds { get; private set; } + + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + internal DiscordClient Discord => (Channel as TextChannel)?.Discord ?? (Channel as DMChannel).Discord; + + internal Message(IMessageChannel channel, Model model) + { + Id = model.Id; + Channel = channel; + Author = new PublicUser(Discord, model.Author); + + Update(model); + } + private void Update(Model model) + { + IsTTS = model.IsTextToSpeech; + Timestamp = model.Timestamp; + EditedTimestamp = model.EditedTimestamp; + RawText = model.Content; + + if (model.Attachments.Length > 0) + { + var attachments = new Attachment[model.Attachments.Length]; + for (int i = 0; i < attachments.Length; i++) + attachments[i] = new Attachment(model.Attachments[i]); + Attachments = ImmutableArray.Create(attachments); + } + else + Attachments = Array.Empty(); + + if (model.Embeds.Length > 0) + { + var embeds = new Embed[model.Attachments.Length]; + for (int i = 0; i < embeds.Length; i++) + embeds[i] = new Embed(model.Embeds[i]); + Embeds = ImmutableArray.Create(embeds); + } + else + Embeds = Array.Empty(); + + if (model.Mentions.Length > 0) + { + var discord = Discord; + var builder = ImmutableArray.CreateBuilder(model.Mentions.Length); + for (int i = 0; i < model.Mentions.Length; i++) + builder.Add(new PublicUser(discord, model.Mentions[i])); + MentionedUsers = builder.ToArray(); + } + else + MentionedUsers = Array.Empty(); + MentionedChannelIds = MentionHelper.GetChannelMentions(model.Content); + MentionedRoleIds = MentionHelper.GetRoleMentions(model.Content); + if (model.IsMentioningEveryone) + { + ulong? guildId = (Channel as IGuildChannel)?.Guild.Id; + if (guildId != null) + { + if (MentionedRoleIds.Count == 0) + MentionedRoleIds = ImmutableArray.Create(guildId.Value); + else + { + var builder = ImmutableArray.CreateBuilder(MentionedRoleIds.Count + 1); + builder.AddRange(MentionedRoleIds); + builder.Add(guildId.Value); + MentionedRoleIds = builder.ToImmutable(); + } + } + } + + Text = MentionHelper.CleanUserMentions(model.Content, model.Mentions); + + Author.Update(model.Author); + } + + /// + public async Task Modify(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyMessageParams(); + func(args); + var guildChannel = Channel as GuildChannel; + + Model model; + if (guildChannel != null) + model = await Discord.BaseClient.ModifyMessage(guildChannel.Guild.Id, Channel.Id, Id, args).ConfigureAwait(false); + else + model = await Discord.BaseClient.ModifyMessage(Channel.Id, Id, args).ConfigureAwait(false); + Update(model); + } + + /// + public async Task Delete() + { + await Discord.BaseClient.DeleteMessage(Channel.Id, Id).ConfigureAwait(false); + } + + public override string ToString() => $"{Author.ToString()}: {Text}"; + + IUser IMessage.Author => Author; + IReadOnlyList IMessage.Attachments => Attachments; + IReadOnlyList IMessage.Embeds => Embeds; + IReadOnlyList IMessage.MentionedChannelIds => MentionedChannelIds; + IReadOnlyList IMessage.MentionedUsers => MentionedUsers; + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Role.cs b/src/Discord.Net/WebSocket/Entities/Role.cs new file mode 100644 index 000000000..a48d8fee8 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Role.cs @@ -0,0 +1,80 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Role; + +namespace Discord.WebSocket +{ + public class Role : IRole, IMentionable + { + /// + public ulong Id { get; } + /// Returns the guild this role belongs to. + public Guild Guild { get; } + + /// + public Color Color { get; private set; } + /// + public bool IsHoisted { get; private set; } + /// + public bool IsManaged { get; private set; } + /// + public string Name { get; private set; } + /// + public GuildPermissions Permissions { get; private set; } + /// + public int Position { get; private set; } + + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + /// + public bool IsEveryone => Id == Guild.Id; + /// + public string Mention => MentionHelper.Mention(this); + internal DiscordClient Discord => Guild.Discord; + + internal Role(Guild guild, Model model) + { + Id = model.Id; + Guild = guild; + + Update(model); + } + internal void Update(Model model) + { + Name = model.Name; + IsHoisted = model.Hoist.Value; + IsManaged = model.Managed.Value; + Position = model.Position.Value; + Color = new Color(model.Color.Value); + Permissions = new GuildPermissions(model.Permissions.Value); + } + /// Modifies the properties of this role. + public async Task Modify(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildRoleParams(); + func(args); + var response = await Discord.BaseClient.ModifyGuildRole(Guild.Id, Id, args).ConfigureAwait(false); + Update(response); + } + /// Deletes this message. + public async Task Delete() + => await Discord.BaseClient.DeleteGuildRole(Guild.Id, Id).ConfigureAwait(false); + + /// + public override string ToString() => Name ?? Id.ToString(); + + ulong IRole.GuildId => Guild.Id; + + async Task> IRole.GetUsers() + { + //A tad hacky, but it works + var models = await Discord.BaseClient.GetGuildMembers(Guild.Id, new GetGuildMembersParams()).ConfigureAwait(false); + return models.Where(x => x.Roles.Contains(Id)).Select(x => new GuildUser(Guild, x)); + } + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Users/DMUser.cs b/src/Discord.Net/WebSocket/Entities/Users/DMUser.cs new file mode 100644 index 000000000..4b300ce76 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Users/DMUser.cs @@ -0,0 +1,20 @@ +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + public class DMUser : User, IDMUser + { + /// + public DMChannel Channel { get; } + + internal override DiscordClient Discord => Channel.Discord; + + internal DMUser(DMChannel channel, Model model) + : base(model) + { + Channel = channel; + } + + IDMChannel IDMUser.Channel => Channel; + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs b/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs new file mode 100644 index 000000000..40d47d2f3 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Users/GuildUser.cs @@ -0,0 +1,117 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.GuildMember; + +namespace Discord.WebSocket +{ + public class GuildUser : User, IGuildUser + { + private ImmutableArray _roles; + + public Guild Guild { get; } + + /// + public bool IsDeaf { get; private set; } + /// + public bool IsMute { get; private set; } + /// + public DateTime JoinedAt { get; private set; } + /// + public string Nickname { get; private set; } + + /// + public IReadOnlyList Roles => _roles; + internal override DiscordClient Discord => Guild.Discord; + + internal GuildUser(Guild guild, Model model) + : base(model.User) + { + Guild = guild; + } + internal void Update(Model model) + { + IsDeaf = model.Deaf; + IsMute = model.Mute; + JoinedAt = model.JoinedAt.Value; + Nickname = model.Nick; + + var roles = ImmutableArray.CreateBuilder(model.Roles.Length + 1); + roles.Add(Guild.EveryoneRole); + for (int i = 0; i < model.Roles.Length; i++) + roles.Add(Guild.GetRole(model.Roles[i])); + _roles = roles.ToImmutable(); + } + + public async Task Update() + { + var model = await Discord.BaseClient.GetGuildMember(Guild.Id, Id).ConfigureAwait(false); + Update(model); + } + + public bool HasRole(IRole role) + { + for (int i = 0; i < _roles.Length; i++) + { + if (_roles[i].Id == role.Id) + return true; + } + return false; + } + + public async Task Kick() + { + await Discord.BaseClient.RemoveGuildMember(Guild.Id, Id).ConfigureAwait(false); + } + + public GuildPermissions GetGuildPermissions() + { + return new GuildPermissions(PermissionHelper.Resolve(this)); + } + public ChannelPermissions GetPermissions(IGuildChannel channel) + { + if (channel == null) throw new ArgumentNullException(nameof(channel)); + return new ChannelPermissions(PermissionHelper.Resolve(this, channel)); + } + + public async Task Modify(Action func) + { + if (func == null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyGuildMemberParams(); + func(args); + + bool isCurrentUser = (await Discord.GetCurrentUser().ConfigureAwait(false)).Id == Id; + if (isCurrentUser && args.Nickname.IsSpecified) + { + var nickArgs = new ModifyCurrentUserNickParams { Nickname = args.Nickname.Value }; + await Discord.BaseClient.ModifyCurrentUserNick(Guild.Id, nickArgs).ConfigureAwait(false); + args.Nickname = new API.Optional(); //Remove + } + + if (!isCurrentUser || args.Deaf.IsSpecified || args.Mute.IsSpecified || args.Roles.IsSpecified) + { + await Discord.BaseClient.ModifyGuildMember(Guild.Id, Id, args).ConfigureAwait(false); + if (args.Deaf.IsSpecified) + IsDeaf = args.Deaf; + if (args.Mute.IsSpecified) + IsMute = args.Mute; + if (args.Nickname.IsSpecified) + Nickname = args.Nickname; + if (args.Roles.IsSpecified) + _roles = args.Roles.Value.Select(x => Guild.GetRole(x)).Where(x => x != null).ToImmutableArray(); + } + } + + + IGuild IGuildUser.Guild => Guild; + IReadOnlyList IGuildUser.Roles => Roles; + ulong? IGuildUser.VoiceChannelId => null; + + ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) + => GetPermissions(channel); + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Users/PublicUser.cs b/src/Discord.Net/WebSocket/Entities/Users/PublicUser.cs new file mode 100644 index 000000000..f1816077e --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Users/PublicUser.cs @@ -0,0 +1,15 @@ +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + public class PublicUser : User + { + internal override DiscordClient Discord { get; } + + internal PublicUser(DiscordClient discord, Model model) + : base(model) + { + Discord = discord; + } + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs b/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs new file mode 100644 index 000000000..f7e27b130 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Users/SelfUser.cs @@ -0,0 +1,48 @@ +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + public class SelfUser : User, ISelfUser + { + internal override DiscordClient Discord { get; } + + /// + public string Email { get; private set; } + /// + public bool IsVerified { get; private set; } + + internal SelfUser(DiscordClient discord, Model model) + : base(model) + { + Discord = discord; + } + internal override void Update(Model model) + { + base.Update(model); + + Email = model.Email; + IsVerified = model.IsVerified; + } + + /// + public async Task Update() + { + var model = await Discord.BaseClient.GetCurrentUser().ConfigureAwait(false); + Update(model); + } + + /// + public async Task Modify(Action func) + { + if (func != null) throw new NullReferenceException(nameof(func)); + + var args = new ModifyCurrentUserParams(); + func(args); + var model = await Discord.BaseClient.ModifyCurrentUser(args).ConfigureAwait(false); + Update(model); + } + } +} diff --git a/src/Discord.Net/WebSocket/Entities/Users/User.cs b/src/Discord.Net/WebSocket/Entities/Users/User.cs new file mode 100644 index 000000000..6cda730c4 --- /dev/null +++ b/src/Discord.Net/WebSocket/Entities/Users/User.cs @@ -0,0 +1,65 @@ +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + public abstract class User : IUser + { + private string _avatarId; + + /// + public ulong Id { get; } + internal abstract DiscordClient Discord { get; } + + /// + public ushort Discriminator { get; private set; } + /// + public bool IsBot { get; private set; } + /// + public string Username { get; private set; } + + /// + public string AvatarUrl => API.CDN.GetUserAvatarUrl(Id, _avatarId); + /// + public DateTime CreatedAt => DateTimeHelper.FromSnowflake(Id); + /// + public string Mention => MentionHelper.Mention(this, false); + /// + public string NicknameMention => MentionHelper.Mention(this, true); + + internal User(Model model) + { + Id = model.Id; + + Update(model); + } + internal virtual void Update(Model model) + { + _avatarId = model.Avatar; + Discriminator = model.Discriminator; + IsBot = model.Bot; + Username = model.Username; + } + + public async Task CreateDMChannel() + { + var args = new CreateDMChannelParams { RecipientId = Id }; + var model = await Discord.BaseClient.CreateDMChannel(args).ConfigureAwait(false); + + return new DMChannel(Discord, model); + } + + public override string ToString() => $"{Username ?? Id.ToString()}"; + + /// + string IUser.CurrentGame => null; + /// + UserStatus IUser.Status => UserStatus.Unknown; + + /// + async Task IUser.CreateDMChannel() + => await CreateDMChannel().ConfigureAwait(false); + } +}