diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5a1d48082..2fe5abfe8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,25 +14,20 @@ trigger: jobs: - job: Linux pool: - vmImage: 'ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - - task: UseDotNet@2 - displayName: 'Use .NET Core sdk' - inputs: - packageType: 'sdk' - version: '3.x' - template: azure/build.yml - job: Windows_build pool: - vmImage: 'windows-2019' + vmImage: 'windows-latest' condition: ne(variables['Build.SourceBranch'], 'refs/heads/dev') steps: - template: azure/build.yml - job: Windows_deploy pool: - vmImage: 'windows-2019' + vmImage: 'windows-latest' condition: | and ( succeeded(), diff --git a/azure/build.yml b/azure/build.yml index 412e4a823..63ba93964 100644 --- a/azure/build.yml +++ b/azure/build.yml @@ -1,5 +1,10 @@ steps: -- script: dotnet restore -v minimal Discord.Net.sln +- task: DotNetCoreCLI@2 + inputs: + command: 'restore' + projects: 'Discord.Net.sln' + feedsToUse: 'select' + verbosityRestore: 'Minimal' displayName: Restore packages - script: dotnet build "Discord.Net.sln" --no-restore -v minimal -c $(buildConfiguration) /p:BuildNumber=$(buildNumber) /p:IsTagBuild=$(buildTag) diff --git a/samples/01_basic_ping_bot/01_basic_ping_bot.csproj b/samples/01_basic_ping_bot/01_basic_ping_bot.csproj index 4b4e35e3f..128082edb 100644 --- a/samples/01_basic_ping_bot/01_basic_ping_bot.csproj +++ b/samples/01_basic_ping_bot/01_basic_ping_bot.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.0 + netcoreapp3.1 diff --git a/samples/02_commands_framework/02_commands_framework.csproj b/samples/02_commands_framework/02_commands_framework.csproj index 84b30aa99..151e546a2 100644 --- a/samples/02_commands_framework/02_commands_framework.csproj +++ b/samples/02_commands_framework/02_commands_framework.csproj @@ -2,11 +2,11 @@ Exe - netcoreapp3.0 + netcoreapp3.1 - + diff --git a/samples/03_sharded_client/03_sharded_client.csproj b/samples/03_sharded_client/03_sharded_client.csproj index a6599c117..24f9942f9 100644 --- a/samples/03_sharded_client/03_sharded_client.csproj +++ b/samples/03_sharded_client/03_sharded_client.csproj @@ -2,12 +2,12 @@ Exe - netcoreapp3.0 + netcoreapp3.1 _03_sharded_client - + diff --git a/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs index aec8dcbe3..28037b0fa 100644 --- a/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs +++ b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs @@ -135,7 +135,8 @@ namespace Discord.Commands if (builder.Name == null) builder.Name = typeInfo.Name; - var validCommands = typeInfo.DeclaredMethods.Where(IsValidCommandDefinition); + // Get all methods (including from inherited members), that are valid commands + var validCommands = typeInfo.GetMethods().Where(IsValidCommandDefinition); foreach (var method in validCommands) { diff --git a/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs index 9552b0a60..93ca2e59a 100644 --- a/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs +++ b/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Discord { /// @@ -30,5 +32,9 @@ namespace Discord /// is set. /// public Optional CategoryId { get; set; } + /// + /// Gets or sets the permission overwrites for this channel. + /// + public Optional> PermissionOverwrites { get; set; } } } diff --git a/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs index f5b986295..030a278bc 100644 --- a/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs @@ -59,11 +59,15 @@ namespace Discord /// The to be sent. /// The options to be used when sending the request. /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Sends a file to this message channel with an optional caption. /// @@ -88,11 +92,15 @@ namespace Discord /// The to be sent. /// The options to be used when sending the request. /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Gets a message from this message channel. diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index b39a49776..81b5e8dd9 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -683,6 +683,9 @@ namespace Discord /// /// Downloads all users for this guild if the current list is incomplete. /// + /// + /// This method downloads all users found within this guild throught the Gateway and caches them. + /// /// /// A task that represents the asynchronous download operation. /// @@ -707,6 +710,22 @@ namespace Discord /// be or has been removed from this guild. /// Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null); + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); /// /// Gets the specified number of audit log entries for this guild. diff --git a/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs b/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs index 3ce6531b7..ecd872d83 100644 --- a/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs +++ b/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs @@ -8,17 +8,27 @@ namespace Discord [Flags] public enum AllowedMentionTypes { + /// + /// No flag is set. + /// + /// + /// This flag is not used to control mentions. + /// + /// It will always be present and does not mean mentions will not be allowed. + /// + /// + None = 0, /// /// Controls role mentions. /// - Roles, + Roles = 1, /// /// Controls user mentions. /// - Users, + Users = 2, /// /// Controls @everyone and @here mentions. /// - Everyone, + Everyone = 4, } } diff --git a/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs b/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs index 9b168bbd0..d52feaa7d 100644 --- a/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs +++ b/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs @@ -39,7 +39,7 @@ namespace Discord /// flag of the property. If the flag is set, the value of this property /// must be null or empty. /// - public List RoleIds { get; set; } + public List RoleIds { get; set; } = new List(); /// /// Gets or sets the list of all user ids that will be mentioned. @@ -47,7 +47,7 @@ namespace Discord /// flag of the property. If the flag is set, the value of this property /// must be null or empty. /// - public List UserIds { get; set; } + public List UserIds { get; set; } = new List(); /// /// Initializes a new instance of the class. diff --git a/src/Discord.Net.Core/Entities/Messages/IMessage.cs b/src/Discord.Net.Core/Entities/Messages/IMessage.cs index aac526831..530c1cd82 100644 --- a/src/Discord.Net.Core/Entities/Messages/IMessage.cs +++ b/src/Discord.Net.Core/Entities/Messages/IMessage.cs @@ -215,6 +215,15 @@ namespace Discord /// A task that represents the asynchronous removal operation. /// Task RemoveAllReactionsAsync(RequestOptions options = null); + /// + /// Removes all reactions with a specific emoji from this message. + /// + /// The emoji used to react to this message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null); /// /// Gets all users that reacted to a message with a given emote. diff --git a/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs b/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs index bc52dd01c..e2fb25aae 100644 --- a/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs +++ b/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs @@ -57,6 +57,21 @@ namespace Discord /// Task UnpinAsync(RequestOptions options = null); + /// + /// Publishes (crossposts) this message. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for publishing this message. + /// + /// + /// + /// This call will throw an if attempted in a non-news channel. + /// + /// This method will publish (crosspost) the message. Please note, publishing (crossposting), is only available in news channels. + /// + Task CrosspostAsync(RequestOptions options = null); + /// /// Transforms this message's text into a human-readable form by resolving its tags. /// diff --git a/src/Discord.Net.Core/Entities/Users/IGuildUser.cs b/src/Discord.Net.Core/Entities/Users/IGuildUser.cs index 60fa06cbd..92b146e05 100644 --- a/src/Discord.Net.Core/Entities/Users/IGuildUser.cs +++ b/src/Discord.Net.Core/Entities/Users/IGuildUser.cs @@ -73,7 +73,7 @@ namespace Discord /// /// /// The following example checks if the current user has the ability to send a message with attachment in - /// this channel; if so, uploads a file via . + /// this channel; if so, uploads a file via . /// /// if (currentUser?.GetPermissions(targetChannel)?.AttachFiles) /// await targetChannel.SendFileAsync("fortnite.png"); diff --git a/src/Discord.Net.Core/Entities/Users/IPresence.cs b/src/Discord.Net.Core/Entities/Users/IPresence.cs index 620eb907c..a17ac0df2 100644 --- a/src/Discord.Net.Core/Entities/Users/IPresence.cs +++ b/src/Discord.Net.Core/Entities/Users/IPresence.cs @@ -19,5 +19,9 @@ namespace Discord /// Gets the set of clients where this user is currently active. /// IImmutableSet ActiveClients { get; } + /// + /// Gets the list of activities that this user currently has available. + /// + IImmutableList Activities { get; } } } diff --git a/src/Discord.Net.Core/GatewayIntents.cs b/src/Discord.Net.Core/GatewayIntents.cs new file mode 100644 index 000000000..f3dc5ceb9 --- /dev/null +++ b/src/Discord.Net.Core/GatewayIntents.cs @@ -0,0 +1,43 @@ +using System; + +namespace Discord +{ + [Flags] + public enum GatewayIntents + { + /// This intent includes no events + None = 0, + /// This intent includes GUILD_CREATE, GUILD_UPDATE, GUILD_DELETE, GUILD_ROLE_CREATE, GUILD_ROLE_UPDATE, GUILD_ROLE_DELETE, CHANNEL_CREATE, CHANNEL_UPDATE, CHANNEL_DELETE, CHANNEL_PINS_UPDATE + Guilds = 1 << 0, + /// This intent includes GUILD_MEMBER_ADD, GUILD_MEMBER_UPDATE, GUILD_MEMBER_REMOVE + /// This is a privileged intent and must be enabled in the Developer Portal. + GuildMembers = 1 << 1, + /// This intent includes GUILD_BAN_ADD, GUILD_BAN_REMOVE + GuildBans = 1 << 2, + /// This intent includes GUILD_EMOJIS_UPDATE + GuildEmojis = 1 << 3, + /// This intent includes GUILD_INTEGRATIONS_UPDATE + GuildIntegrations = 1 << 4, + /// This intent includes WEBHOOKS_UPDATE + GuildWebhooks = 1 << 5, + /// This intent includes INVITE_CREATE, INVITE_DELETE + GuildInvites = 1 << 6, + /// This intent includes VOICE_STATE_UPDATE + GuildVoiceStates = 1 << 7, + /// This intent includes PRESENCE_UPDATE + /// This is a privileged intent and must be enabled in the Developer Portal. + GuildPresences = 1 << 8, + /// This intent includes MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, MESSAGE_DELETE_BULK + GuildMessages = 1 << 9, + /// This intent includes MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI + GuildMessageReactions = 1 << 10, + /// This intent includes TYPING_START + GuildMessageTyping = 1 << 11, + /// This intent includes CHANNEL_CREATE, MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, CHANNEL_PINS_UPDATE + DirectMessages = 1 << 12, + /// This intent includes MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI + DirectMessageReactions = 1 << 13, + /// This intent includes TYPING_START + DirectMessageTyping = 1 << 14, + } +} diff --git a/src/Discord.Net.Core/Net/BucketId.cs b/src/Discord.Net.Core/Net/BucketId.cs new file mode 100644 index 000000000..96281a0ed --- /dev/null +++ b/src/Discord.Net.Core/Net/BucketId.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Net +{ + /// + /// Represents a ratelimit bucket. + /// + public class BucketId : IEquatable + { + /// + /// Gets the http method used to make the request if available. + /// + public string HttpMethod { get; } + /// + /// Gets the endpoint that is going to be requested if available. + /// + public string Endpoint { get; } + /// + /// Gets the major parameters of the route. + /// + public IOrderedEnumerable> MajorParameters { get; } + /// + /// Gets the hash of this bucket. + /// + /// + /// The hash is provided by Discord to group ratelimits. + /// + public string BucketHash { get; } + /// + /// Gets if this bucket is a hash type. + /// + public bool IsHashBucket { get => BucketHash != null; } + + private BucketId(string httpMethod, string endpoint, IEnumerable> majorParameters, string bucketHash) + { + HttpMethod = httpMethod; + Endpoint = endpoint; + MajorParameters = majorParameters.OrderBy(x => x.Key); + BucketHash = bucketHash; + } + + /// + /// Creates a new based on the + /// and . + /// + /// Http method used to make the request. + /// Endpoint that is going to receive requests. + /// Major parameters of the route of this endpoint. + /// + /// A based on the + /// and the with the provided data. + /// + public static BucketId Create(string httpMethod, string endpoint, Dictionary majorParams) + { + Preconditions.NotNullOrWhitespace(endpoint, nameof(endpoint)); + majorParams ??= new Dictionary(); + return new BucketId(httpMethod, endpoint, majorParams, null); + } + + /// + /// Creates a new based on a + /// and a previous . + /// + /// Bucket hash provided by Discord. + /// that is going to be upgraded to a hash type. + /// + /// A based on the + /// and . + /// + public static BucketId Create(string hash, BucketId oldBucket) + { + Preconditions.NotNullOrWhitespace(hash, nameof(hash)); + Preconditions.NotNull(oldBucket, nameof(oldBucket)); + return new BucketId(null, null, oldBucket.MajorParameters, hash); + } + + /// + /// Gets the string that will define this bucket as a hash based one. + /// + /// + /// A that defines this bucket as a hash based one. + /// + public string GetBucketHash() + => IsHashBucket ? $"{BucketHash}:{string.Join("/", MajorParameters.Select(x => x.Value))}" : null; + + /// + /// Gets the string that will define this bucket as an endpoint based one. + /// + /// + /// A that defines this bucket as an endpoint based one. + /// + public string GetUniqueEndpoint() + => HttpMethod != null ? $"{HttpMethod} {Endpoint}" : Endpoint; + + public override bool Equals(object obj) + => Equals(obj as BucketId); + + public override int GetHashCode() + => IsHashBucket ? (BucketHash, string.Join("/", MajorParameters.Select(x => x.Value))).GetHashCode() : (HttpMethod, Endpoint).GetHashCode(); + + public override string ToString() + => GetBucketHash() ?? GetUniqueEndpoint(); + + public bool Equals(BucketId other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + return ToString() == other.ToString(); + } + } +} diff --git a/src/Discord.Net.Core/RequestOptions.cs b/src/Discord.Net.Core/RequestOptions.cs index 02161e64d..dbb240273 100644 --- a/src/Discord.Net.Core/RequestOptions.cs +++ b/src/Discord.Net.Core/RequestOptions.cs @@ -1,3 +1,4 @@ +using Discord.Net; using System.Threading; namespace Discord @@ -57,7 +58,7 @@ namespace Discord public bool? UseSystemClock { get; set; } internal bool IgnoreState { get; set; } - internal string BucketId { get; set; } + internal BucketId BucketId { get; set; } internal bool IsClientBucket { get; set; } internal bool IsReactionBucket { get; set; } internal bool IsGatewayBucket { get; set; } diff --git a/src/Discord.Net.Core/Utils/Comparers.cs b/src/Discord.Net.Core/Utils/Comparers.cs index 7ec9f5c74..3c7b8aa3c 100644 --- a/src/Discord.Net.Core/Utils/Comparers.cs +++ b/src/Discord.Net.Core/Utils/Comparers.cs @@ -41,16 +41,13 @@ namespace Discord { public override bool Equals(TEntity x, TEntity y) { - bool xNull = x == null; - bool yNull = y == null; - - if (xNull && yNull) - return true; - - if (xNull ^ yNull) - return false; - - return x.Id.Equals(y.Id); + return (x, y) switch + { + (null, null) => true, + (null, _) => false, + (_, null) => false, + var (l, r) => l.Id.Equals(r.Id) + }; } public override int GetHashCode(TEntity obj) diff --git a/src/Discord.Net.Rest/API/Common/Presence.cs b/src/Discord.Net.Rest/API/Common/Presence.cs index 22526e8ac..b37ad4229 100644 --- a/src/Discord.Net.Rest/API/Common/Presence.cs +++ b/src/Discord.Net.Rest/API/Common/Presence.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 using Newtonsoft.Json; +using System; using System.Collections.Generic; namespace Discord.API @@ -26,5 +27,9 @@ namespace Discord.API // "client_status": { "desktop": "dnd", "mobile": "dnd" } [JsonProperty("client_status")] public Optional> ClientStatus { get; set; } + [JsonProperty("activities")] + public List Activities { get; set; } + [JsonProperty("premium_since")] + public Optional PremiumSince { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs index a102bd38d..aec43dbef 100644 --- a/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs @@ -14,12 +14,16 @@ namespace Discord.API.Rest public Optional CategoryId { get; set; } [JsonProperty("position")] public Optional Position { get; set; } + [JsonProperty("permission_overwrites")] + public Optional Overwrites { get; set; } //Text channels [JsonProperty("topic")] public Optional Topic { get; set; } [JsonProperty("nsfw")] public Optional IsNsfw { get; set; } + [JsonProperty("rate_limit_per_user")] + public Optional SlowModeInterval { get; set; } //Voice channels [JsonProperty("bitrate")] diff --git a/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs b/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs new file mode 100644 index 000000000..7c933ff82 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs @@ -0,0 +1,9 @@ +#pragma warning disable CS1591 +namespace Discord.API.Rest +{ + internal class SearchGuildMembersParams + { + public string Query { get; set; } + public Optional Limit { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs index 7ba21d012..64535e6d7 100644 --- a/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs +++ b/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs @@ -19,6 +19,7 @@ namespace Discord.API.Rest public Optional Nonce { get; set; } public Optional IsTTS { get; set; } public Optional Embed { get; set; } + public Optional AllowedMentions { get; set; } public bool IsSpoiler { get; set; } = false; public UploadFileParams(Stream file) @@ -43,6 +44,8 @@ namespace Discord.API.Rest payload["nonce"] = Nonce.Value; if (Embed.IsSpecified) payload["embed"] = Embed.Value; + if (AllowedMentions.IsSpecified) + payload["allowed_mentions"] = AllowedMentions.Value; if (IsSpoiler) payload["hasSpoiler"] = IsSpoiler.ToString(); diff --git a/src/Discord.Net.Rest/BaseDiscordClient.cs b/src/Discord.Net.Rest/BaseDiscordClient.cs index 399299173..ffbb1dbba 100644 --- a/src/Discord.Net.Rest/BaseDiscordClient.cs +++ b/src/Discord.Net.Rest/BaseDiscordClient.cs @@ -49,9 +49,9 @@ namespace Discord.Rest ApiClient.RequestQueue.RateLimitTriggered += async (id, info) => { if (info == null) - await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {id ?? "null"}").ConfigureAwait(false); + await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {id?.ToString() ?? "null"}").ConfigureAwait(false); else - await _restLogger.WarningAsync($"Rate limit triggered: {id ?? "null"}").ConfigureAwait(false); + await _restLogger.WarningAsync($"Rate limit triggered: {id?.ToString() ?? "null"}").ConfigureAwait(false); }; ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false); } diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 5ef635870..50df83e30 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -24,7 +24,7 @@ namespace Discord.API { internal class DiscordRestApiClient : IDisposable { - private static readonly ConcurrentDictionary> _bucketIdGenerators = new ConcurrentDictionary>(); + private static readonly ConcurrentDictionary> _bucketIdGenerators = new ConcurrentDictionary>(); public event Func SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } private readonly AsyncEvent> _sentRequestEvent = new AsyncEvent>(); @@ -80,17 +80,13 @@ namespace Discord.API /// Unknown OAuth token type. internal static string GetPrefixedToken(TokenType tokenType, string token) { - switch (tokenType) + return tokenType switch { - case default(TokenType): - return token; - case TokenType.Bot: - return $"Bot {token}"; - case TokenType.Bearer: - return $"Bearer {token}"; - default: - throw new ArgumentException(message: "Unknown OAuth token type.", paramName: nameof(tokenType)); - } + default(TokenType) => token, + TokenType.Bot => $"Bot {token}", + TokenType.Bearer => $"Bearer {token}", + _ => throw new ArgumentException(message: "Unknown OAuth token type.", paramName: nameof(tokenType)), + }; } internal virtual void Dispose(bool disposing) { @@ -133,7 +129,7 @@ namespace Discord.API RestClient.SetCancelToken(_loginCancelToken.Token); AuthTokenType = tokenType; - AuthToken = token; + AuthToken = token?.TrimEnd(); if (tokenType != TokenType.Webhook) RestClient.SetHeader("authorization", GetPrefixedToken(AuthTokenType, AuthToken)); @@ -180,9 +176,9 @@ namespace Discord.API //Core internal Task SendAsync(string method, Expression> endpointExpr, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) - => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendAsync(string method, string endpoint, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) { options = options ?? new RequestOptions(); options.HeaderOnly = true; @@ -194,9 +190,9 @@ namespace Discord.API internal Task SendJsonAsync(string method, Expression> endpointExpr, object payload, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) - => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendJsonAsync(string method, string endpoint, object payload, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) { options = options ?? new RequestOptions(); options.HeaderOnly = true; @@ -209,9 +205,9 @@ namespace Discord.API internal Task SendMultipartAsync(string method, Expression> endpointExpr, IReadOnlyDictionary multipartArgs, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) - => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendMultipartAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) { options = options ?? new RequestOptions(); options.HeaderOnly = true; @@ -223,9 +219,9 @@ namespace Discord.API internal Task SendAsync(string method, Expression> endpointExpr, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) where TResponse : class - => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendAsync(string method, string endpoint, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class { options = options ?? new RequestOptions(); options.BucketId = bucketId; @@ -236,9 +232,9 @@ namespace Discord.API internal Task SendJsonAsync(string method, Expression> endpointExpr, object payload, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) where TResponse : class - => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendJsonAsync(string method, string endpoint, object payload, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class { options = options ?? new RequestOptions(); options.BucketId = bucketId; @@ -250,9 +246,9 @@ namespace Discord.API internal Task SendMultipartAsync(string method, Expression> endpointExpr, IReadOnlyDictionary multipartArgs, BucketIds ids, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) - => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(ids, endpointExpr, funcName), clientBucket, options); + => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); public async Task SendMultipartAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, - string bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) { options = options ?? new RequestOptions(); options.BucketId = bucketId; @@ -524,7 +520,8 @@ namespace Discord.API throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); options = RequestOptions.CreateOrClone(options); - return await SendJsonAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?wait=true", args, new BucketIds(), clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); + var ids = new BucketIds(webhookId: webhookId); + return await SendJsonAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?wait=true", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); } /// Message content is too long, length must be less or equal to . public async Task UploadFileAsync(ulong channelId, UploadFileParams args, RequestOptions options = null) @@ -563,7 +560,8 @@ namespace Discord.API throw new ArgumentOutOfRangeException($"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", nameof(args.Content)); } - return await SendMultipartAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?wait=true", args.ToDictionary(), new BucketIds(), clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); + var ids = new BucketIds(webhookId: webhookId); + return await SendMultipartAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?wait=true", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); } public async Task DeleteMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) { @@ -660,6 +658,18 @@ namespace Discord.API await SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}/reactions", ids, options: options).ConfigureAwait(false); } + public async Task RemoveAllReactionsForEmoteAsync(ulong channelId, ulong messageId, string emoji, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNullOrWhitespace(emoji, nameof(emoji)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + + await SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}/reactions/{emoji}", ids, options: options).ConfigureAwait(false); + } public async Task> GetReactionUsersAsync(ulong channelId, ulong messageId, string emoji, GetReactionUsersParams args, RequestOptions options = null) { Preconditions.NotEqual(channelId, 0, nameof(channelId)); @@ -695,6 +705,15 @@ namespace Discord.API var ids = new BucketIds(channelId: channelId); await SendAsync("POST", () => $"channels/{channelId}/typing", ids, options: options).ConfigureAwait(false); } + public async Task CrosspostAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + await SendAsync("POST", () => $"channels/{channelId}/messages/{messageId}/crosspost", ids, options: options).ConfigureAwait(false); + } //Channel Permissions public async Task ModifyChannelPermissionsAsync(ulong channelId, ulong targetId, ModifyChannelPermissionsParams args, RequestOptions options = null) @@ -1127,6 +1146,22 @@ namespace Discord.API await SendJsonAsync("PATCH", () => $"guilds/{guildId}/members/{userId}", args, ids, options: options).ConfigureAwait(false); } } + public async Task> SearchGuildMembersAsync(ulong guildId, SearchGuildMembersParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxUsersPerBatch, nameof(args.Limit)); + Preconditions.NotNullOrEmpty(args.Query, nameof(args.Query)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxUsersPerBatch); + string query = args.Query; + + var ids = new BucketIds(guildId: guildId); + Expression> endpoint = () => $"guilds/{guildId}/members/search?limit={limit}&query={query}"; + return await SendAsync>("GET", endpoint, ids, options: options).ConfigureAwait(false); + } //Guild Roles public async Task> GetGuildRolesAsync(ulong guildId, RequestOptions options = null) @@ -1137,13 +1172,13 @@ namespace Discord.API var ids = new BucketIds(guildId: guildId); return await SendAsync>("GET", () => $"guilds/{guildId}/roles", ids, options: options).ConfigureAwait(false); } - public async Task CreateGuildRoleAsync(ulong guildId, RequestOptions options = null) + public async Task CreateGuildRoleAsync(ulong guildId, Rest.ModifyGuildRoleParams args, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); options = RequestOptions.CreateOrClone(options); var ids = new BucketIds(guildId: guildId); - return await SendAsync("POST", () => $"guilds/{guildId}/roles", ids, options: options).ConfigureAwait(false); + return await SendJsonAsync("POST", () => $"guilds/{guildId}/roles", args, ids, options: options).ConfigureAwait(false); } public async Task DeleteGuildRoleAsync(ulong guildId, ulong roleId, RequestOptions options = null) { @@ -1433,21 +1468,39 @@ namespace Discord.API { public ulong GuildId { get; internal set; } public ulong ChannelId { get; internal set; } + public ulong WebhookId { get; internal set; } + public string HttpMethod { get; internal set; } - internal BucketIds(ulong guildId = 0, ulong channelId = 0) + internal BucketIds(ulong guildId = 0, ulong channelId = 0, ulong webhookId = 0) { GuildId = guildId; ChannelId = channelId; + WebhookId = webhookId; } + internal object[] ToArray() - => new object[] { GuildId, ChannelId }; + => new object[] { HttpMethod, GuildId, ChannelId, WebhookId }; + + internal Dictionary ToMajorParametersDictionary() + { + var dict = new Dictionary(); + if (GuildId != 0) + dict["GuildId"] = GuildId.ToString(); + if (ChannelId != 0) + dict["ChannelId"] = ChannelId.ToString(); + if (WebhookId != 0) + dict["WebhookId"] = WebhookId.ToString(); + return dict; + } internal static int? GetIndex(string name) { switch (name) { - case "guildId": return 0; - case "channelId": return 1; + case "httpMethod": return 0; + case "guildId": return 1; + case "channelId": return 2; + case "webhookId": return 3; default: return null; } @@ -1458,18 +1511,19 @@ namespace Discord.API { return endpointExpr.Compile()(); } - private static string GetBucketId(BucketIds ids, Expression> endpointExpr, string callingMethod) + private static BucketId GetBucketId(string httpMethod, BucketIds ids, Expression> endpointExpr, string callingMethod) { + ids.HttpMethod ??= httpMethod; return _bucketIdGenerators.GetOrAdd(callingMethod, x => CreateBucketId(endpointExpr))(ids); } - private static Func CreateBucketId(Expression> endpoint) + private static Func CreateBucketId(Expression> endpoint) { try { //Is this a constant string? if (endpoint.Body.NodeType == ExpressionType.Constant) - return x => (endpoint.Body as ConstantExpression).Value.ToString(); + return x => BucketId.Create(x.HttpMethod, (endpoint.Body as ConstantExpression).Value.ToString(), x.ToMajorParametersDictionary()); var builder = new StringBuilder(); var methodCall = endpoint.Body as MethodCallExpression; @@ -1506,7 +1560,7 @@ namespace Discord.API var mappedId = BucketIds.GetIndex(fieldName); - if(!mappedId.HasValue && rightIndex != endIndex && format.Length > rightIndex + 1 && format[rightIndex + 1] == '/') //Ignore the next slash + if (!mappedId.HasValue && rightIndex != endIndex && format.Length > rightIndex + 1 && format[rightIndex + 1] == '/') //Ignore the next slash rightIndex++; if (mappedId.HasValue) @@ -1519,7 +1573,7 @@ namespace Discord.API format = builder.ToString(); - return x => string.Format(format, x.ToArray()); + return x => BucketId.Create(x.HttpMethod, string.Format(format, x.ToArray()), x.ToMajorParametersDictionary()); } catch (Exception ex) { diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs index 215a3c164..b177b2435 100644 --- a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs @@ -36,13 +36,17 @@ namespace Discord.Rest var maxAge = maxAgeModel.NewValue.ToObject(discord.ApiClient.Serializer); var code = codeModel.NewValue.ToObject(discord.ApiClient.Serializer); var temporary = temporaryModel.NewValue.ToObject(discord.ApiClient.Serializer); - var inviterId = inviterIdModel.NewValue.ToObject(discord.ApiClient.Serializer); var channelId = channelIdModel.NewValue.ToObject(discord.ApiClient.Serializer); var uses = usesModel.NewValue.ToObject(discord.ApiClient.Serializer); var maxUses = maxUsesModel.NewValue.ToObject(discord.ApiClient.Serializer); - var inviterInfo = log.Users.FirstOrDefault(x => x.Id == inviterId); - var inviter = RestUser.Create(discord, inviterInfo); + RestUser inviter = null; + if (inviterIdModel != null) + { + var inviterId = inviterIdModel.NewValue.ToObject(discord.ApiClient.Serializer); + var inviterInfo = log.Users.FirstOrDefault(x => x.Id == inviterId); + inviter = RestUser.Create(discord, inviterInfo); + } return new InviteCreateAuditLogData(maxAge, code, temporary, inviter, channelId, uses, maxUses); } @@ -70,10 +74,10 @@ namespace Discord.Rest /// public bool Temporary { get; } /// - /// Gets the user that created this invite. + /// Gets the user that created this invite if available. /// /// - /// A user that created this invite. + /// A user that created this invite or . /// public IUser Creator { get; } /// diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs index 5e49bb641..9d0aed12b 100644 --- a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs @@ -36,13 +36,17 @@ namespace Discord.Rest var maxAge = maxAgeModel.OldValue.ToObject(discord.ApiClient.Serializer); var code = codeModel.OldValue.ToObject(discord.ApiClient.Serializer); var temporary = temporaryModel.OldValue.ToObject(discord.ApiClient.Serializer); - var inviterId = inviterIdModel.OldValue.ToObject(discord.ApiClient.Serializer); var channelId = channelIdModel.OldValue.ToObject(discord.ApiClient.Serializer); var uses = usesModel.OldValue.ToObject(discord.ApiClient.Serializer); var maxUses = maxUsesModel.OldValue.ToObject(discord.ApiClient.Serializer); - var inviterInfo = log.Users.FirstOrDefault(x => x.Id == inviterId); - var inviter = RestUser.Create(discord, inviterInfo); + RestUser inviter = null; + if (inviterIdModel != null) + { + var inviterId = inviterIdModel.OldValue.ToObject(discord.ApiClient.Serializer); + var inviterInfo = log.Users.FirstOrDefault(x => x.Id == inviterId); + inviter = RestUser.Create(discord, inviterInfo); + } return new InviteDeleteAuditLogData(maxAge, code, temporary, inviter, channelId, uses, maxUses); } @@ -70,10 +74,10 @@ namespace Discord.Rest /// public bool Temporary { get; } /// - /// Gets the user that created this invite. + /// Gets the user that created this invite if available. /// /// - /// A user that created this invite. + /// A user that created this invite or . /// public IUser Creator { get; } /// diff --git a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs index aa90b2eee..09507f1d7 100644 --- a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs +++ b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs @@ -46,6 +46,15 @@ namespace Discord.Rest Topic = args.Topic, IsNsfw = args.IsNsfw, SlowModeInterval = args.SlowModeInterval, + Overwrites = args.PermissionOverwrites.IsSpecified + ? args.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue, + Deny = overwrite.Permissions.DenyValue + }).ToArray() + : Optional.Create(), }; return await client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options).ConfigureAwait(false); } @@ -109,12 +118,19 @@ namespace Discord.Rest public static IAsyncEnumerable> GetMessagesAsync(IMessageChannel channel, BaseDiscordClient client, ulong? fromMessageId, Direction dir, int limit, RequestOptions options) { - if (dir == Direction.Around) - throw new NotImplementedException(); //TODO: Impl - var guildId = (channel as IGuildChannel)?.GuildId; var guild = guildId != null ? (client as IDiscordClient).GetGuildAsync(guildId.Value, CacheMode.CacheOnly).Result : null; + if (dir == Direction.Around && limit > DiscordConfig.MaxMessagesPerBatch) + { + int around = limit / 2; + if (fromMessageId.HasValue) + return GetMessagesAsync(channel, client, fromMessageId.Value + 1, Direction.Before, around + 1, options) //Need to include the message itself + .Concat(GetMessagesAsync(channel, client, fromMessageId, Direction.After, around, options)); + else //Shouldn't happen since there's no public overload for ulong? and Direction + return GetMessagesAsync(channel, client, null, Direction.Before, around + 1, options); + } + return new PagedAsyncEnumerable( DiscordConfig.MaxMessagesPerBatch, async (info, ct) => @@ -218,18 +234,37 @@ namespace Discord.Rest /// An I/O error occurred while opening the file. /// Message content is too long, length must be less or equal to . public static async Task SendFileAsync(IMessageChannel channel, BaseDiscordClient client, - string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) + string filePath, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler) { string filename = Path.GetFileName(filePath); using (var file = File.OpenRead(filePath)) - return await SendFileAsync(channel, client, file, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + return await SendFileAsync(channel, client, file, filename, text, isTTS, embed, allowedMentions, options, isSpoiler).ConfigureAwait(false); } /// Message content is too long, length must be less or equal to . public static async Task SendFileAsync(IMessageChannel channel, BaseDiscordClient client, - Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) + Stream stream, string filename, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler) { - var args = new UploadFileParams(stream) { Filename = filename, Content = text, IsTTS = isTTS, Embed = embed != null ? embed.ToModel() : Optional.Unspecified, IsSpoiler = isSpoiler }; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var args = new UploadFileParams(stream) { Filename = filename, Content = text, IsTTS = isTTS, Embed = embed?.ToModel() ?? Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, IsSpoiler = isSpoiler }; var model = await client.ApiClient.UploadFileAsync(channel.Id, args, options).ConfigureAwait(false); return RestUserMessage.Create(client, channel, client.CurrentUser, model); } @@ -387,7 +422,8 @@ namespace Discord.Rest var apiArgs = new ModifyGuildChannelParams { Overwrites = category.PermissionOverwrites - .Select(overwrite => new API.Overwrite{ + .Select(overwrite => new API.Overwrite + { TargetId = overwrite.TargetId, TargetType = overwrite.TargetType, Allow = overwrite.Permissions.AllowValue, diff --git a/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs index 195fa92df..d02b293ef 100644 --- a/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs @@ -34,7 +34,7 @@ namespace Discord.Rest /// /// /// This method follows the same behavior as described in - /// . Please visit + /// . Please visit /// its documentation for more details on this method. /// /// The file path of the file. @@ -42,16 +42,21 @@ namespace Discord.Rest /// Whether the message should be read aloud by Discord or not. /// The to be sent. /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Sends a file to this message channel with an optional caption. /// /// - /// This method follows the same behavior as described in . + /// This method follows the same behavior as described in . /// Please visit its documentation for more details on this method. /// /// The of the file to be sent. @@ -60,11 +65,16 @@ namespace Discord.Rest /// Whether the message should be read aloud by Discord or not. /// The to be sent. /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Gets a message from this message channel. diff --git a/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs index 732af2d81..0f29f9d77 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs @@ -121,12 +121,12 @@ namespace Discord.Rest /// is in an invalid format. /// An I/O error occurred while opening the file. /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) @@ -200,11 +200,11 @@ namespace Discord.Rest async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) => await GetPinnedMessagesAsync(options).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs index 3c21bd95f..4361fd281 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs @@ -123,12 +123,12 @@ namespace Discord.Rest /// is in an invalid format. /// An I/O error occurred while opening the file. /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task TriggerTypingAsync(RequestOptions options = null) @@ -178,11 +178,11 @@ namespace Discord.Rest async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) => await GetPinnedMessagesAsync(options).ConfigureAwait(false); - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs index cecd0a4d2..a85ef4f0b 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -42,7 +42,8 @@ namespace Discord.Rest base.Update(model); CategoryId = model.CategoryId; Topic = model.Topic.Value; - SlowModeInterval = model.SlowMode.Value; + if (model.SlowMode.IsSpecified) + SlowModeInterval = model.SlowMode.Value; IsNsfw = model.Nsfw.GetValueOrDefault(); } @@ -129,13 +130,13 @@ namespace Discord.Rest /// is in an invalid format. /// An I/O error occurred while opening the file. /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) @@ -266,12 +267,12 @@ namespace Discord.Rest => await GetPinnedMessagesAsync(options).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 790b1e5c3..ccc02466e 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -176,7 +176,17 @@ namespace Discord.Rest CategoryId = props.CategoryId, Topic = props.Topic, IsNsfw = props.IsNsfw, - Position = props.Position + Position = props.Position, + SlowModeInterval = props.SlowModeInterval, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue, + Deny = overwrite.Permissions.DenyValue + }).ToArray() + : Optional.Create(), }; var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); return RestTextChannel.Create(client, guild, model); @@ -195,7 +205,16 @@ namespace Discord.Rest CategoryId = props.CategoryId, Bitrate = props.Bitrate, UserLimit = props.UserLimit, - Position = props.Position + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue, + Deny = overwrite.Permissions.DenyValue + }).ToArray() + : Optional.Create(), }; var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); return RestVoiceChannel.Create(client, guild, model); @@ -211,7 +230,16 @@ namespace Discord.Rest var args = new CreateGuildChannelParams(name, ChannelType.Category) { - Position = props.Position + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue, + Deny = overwrite.Permissions.DenyValue + }).ToArray() + : Optional.Create(), }; var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); @@ -264,19 +292,18 @@ namespace Discord.Rest { if (name == null) throw new ArgumentNullException(paramName: nameof(name)); - var model = await client.ApiClient.CreateGuildRoleAsync(guild.Id, options).ConfigureAwait(false); - var role = RestRole.Create(client, guild, model); - - await role.ModifyAsync(x => + var createGuildRoleParams = new API.Rest.ModifyGuildRoleParams { - x.Name = name; - x.Permissions = (permissions ?? role.Permissions); - x.Color = (color ?? Color.Default); - x.Hoist = isHoisted; - x.Mentionable = isMentionable; - }, options).ConfigureAwait(false); + Color = color?.RawValue ?? Optional.Create(), + Hoist = isHoisted, + Mentionable = isMentionable, + Name = name, + Permissions = permissions?.RawValue ?? Optional.Create() + }; - return role; + var model = await client.ApiClient.CreateGuildRoleAsync(guild.Id, createGuildRoleParams, options).ConfigureAwait(false); + + return RestRole.Create(client, guild, model); } //Users @@ -387,6 +414,17 @@ namespace Discord.Rest model = await client.ApiClient.BeginGuildPruneAsync(guild.Id, args, options).ConfigureAwait(false); return model.Pruned; } + public static async Task> SearchUsersAsync(IGuild guild, BaseDiscordClient client, + string query, int? limit, RequestOptions options) + { + var apiArgs = new SearchGuildMembersParams + { + Query = query, + Limit = limit ?? Optional.Create() + }; + var models = await client.ApiClient.SearchGuildMembersAsync(guild.Id, apiArgs, options).ConfigureAwait(false); + return models.Select(x => RestGuildUser.Create(client, guild, x)).ToImmutableArray(); + } // Audit logs public static IAsyncEnumerable> GetAuditLogsAsync(IGuild guild, BaseDiscordClient client, diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 900f5045e..f0b5be0f7 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -634,6 +634,23 @@ namespace Discord.Rest public Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null) => GuildHelper.PruneUsersAsync(this, Discord, days, simulate, options); + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + public Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, RequestOptions options = null) + => GuildHelper.SearchUsersAsync(this, Discord, query, limit, options); + //Audit logs /// /// Gets the specified number of audit log entries for this guild. @@ -884,6 +901,14 @@ namespace Discord.Rest /// Downloading users is not supported for a REST-based guild. Task IGuild.DownloadUsersAsync() => throw new NotSupportedException(); + /// + async Task> IGuild.SearchUsersAsync(string query, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await SearchUsersAsync(query, limit, options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } async Task> IGuild.GetAuditLogsAsync(int limit, CacheMode cacheMode, RequestOptions options, ulong? beforeId, ulong? userId, ActionType? actionType) diff --git a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs index b29eca62e..d6a718b3a 100644 --- a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs +++ b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs @@ -44,8 +44,10 @@ namespace Discord.Rest }; return await client.ApiClient.ModifyMessageAsync(msg.Channel.Id, msg.Id, apiArgs, options).ConfigureAwait(false); } + public static Task DeleteAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) => DeleteAsync(msg.Channel.Id, msg.Id, client, options); + public static async Task DeleteAsync(ulong channelId, ulong msgId, BaseDiscordClient client, RequestOptions options) { @@ -76,6 +78,11 @@ namespace Discord.Rest await client.ApiClient.RemoveAllReactionsAsync(msg.Channel.Id, msg.Id, options).ConfigureAwait(false); } + public static async Task RemoveAllReactionsForEmoteAsync(IMessage msg, IEmote emote, BaseDiscordClient client, RequestOptions options) + { + await client.ApiClient.RemoveAllReactionsForEmoteAsync(msg.Channel.Id, msg.Id, emote is Emote e ? $"{e.Name}:{e.Id}" : emote.Name, options).ConfigureAwait(false); + } + public static IAsyncEnumerable> GetReactionUsersAsync(IMessage msg, IEmote emote, int? limit, BaseDiscordClient client, RequestOptions options) { @@ -115,6 +122,7 @@ namespace Discord.Rest { await client.ApiClient.AddPinAsync(msg.Channel.Id, msg.Id, options).ConfigureAwait(false); } + public static async Task UnpinAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) { @@ -240,6 +248,7 @@ namespace Discord.Rest return tags.ToImmutable(); } + private static int? FindIndex(IReadOnlyList tags, int index) { int i = 0; @@ -253,6 +262,7 @@ namespace Discord.Rest return null; //Overlaps tag before this return i; } + public static ImmutableArray FilterTagsByKey(TagType type, ImmutableArray tags) { return tags @@ -260,6 +270,7 @@ namespace Discord.Rest .Select(x => x.Key) .ToImmutableArray(); } + public static ImmutableArray FilterTagsByValue(TagType type, ImmutableArray tags) { return tags @@ -279,5 +290,14 @@ namespace Discord.Rest return MessageSource.Bot; return MessageSource.User; } + + public static Task CrosspostAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + => CrosspostAsync(msg.Channel.Id, msg.Id, client, options); + + public static async Task CrosspostAsync(ulong channelId, ulong msgId, BaseDiscordClient client, + RequestOptions options) + { + await client.ApiClient.CrosspostAsync(channelId, msgId, options).ConfigureAwait(false); + } } } diff --git a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs index f457f4f7a..809a55e9c 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs @@ -165,7 +165,7 @@ namespace Discord.Rest IReadOnlyCollection IMessage.Embeds => Embeds; /// IReadOnlyCollection IMessage.MentionedUserIds => MentionedUsers.Select(x => x.Id).ToImmutableArray(); - + /// public IReadOnlyDictionary Reactions => _reactions.ToDictionary(x => x.Emote, x => new ReactionMetadata { ReactionCount = x.Count, IsMe = x.Me }); @@ -182,6 +182,9 @@ namespace Discord.Rest public Task RemoveAllReactionsAsync(RequestOptions options = null) => MessageHelper.RemoveAllReactionsAsync(this, Discord, options); /// + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsForEmoteAsync(this, emote, Discord, options); + /// public IAsyncEnumerable> GetReactionUsersAsync(IEmote emote, int limit, RequestOptions options = null) => MessageHelper.GetReactionUsersAsync(this, emote, limit, Discord, options); } diff --git a/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs index 7d652687a..ad2a65615 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs @@ -148,6 +148,18 @@ namespace Discord.Rest TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) => MentionUtils.Resolve(this, 0, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + /// + /// This operation may only be called on a channel. + public async Task CrosspostAsync(RequestOptions options = null) + { + if (!(Channel is RestNewsChannel)) + { + throw new InvalidOperationException("Publishing (crossposting) is only valid in news channels."); + } + + await MessageHelper.CrosspostAsync(this, Discord, options); + } + private string DebuggerDisplay => $"{Author}: {Content} ({Id}{(Attachments.Count > 0 ? $", {Attachments.Count} Attachments" : "")})"; } } diff --git a/src/Discord.Net.Rest/Entities/Users/RestUser.cs b/src/Discord.Net.Rest/Entities/Users/RestUser.cs index d5fffca94..f5becd3ff 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestUser.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestUser.cs @@ -35,6 +35,8 @@ namespace Discord.Rest /// public virtual IImmutableSet ActiveClients => ImmutableHashSet.Empty; /// + public virtual IImmutableList Activities => ImmutableList.Empty; + /// public virtual bool IsWebhook => false; internal RestUser(BaseDiscordClient discord, ulong id) diff --git a/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs b/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs index cd9d8aa54..e726a08cf 100644 --- a/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs +++ b/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs @@ -10,14 +10,14 @@ namespace Discord.Net.Queue internal struct ClientBucket { private static readonly ImmutableDictionary DefsByType; - private static readonly ImmutableDictionary DefsById; + private static readonly ImmutableDictionary DefsById; static ClientBucket() { var buckets = new[] { - new ClientBucket(ClientBucketType.Unbucketed, "", 10, 10), - new ClientBucket(ClientBucketType.SendEdit, "", 10, 10) + new ClientBucket(ClientBucketType.Unbucketed, BucketId.Create(null, "", null), 10, 10), + new ClientBucket(ClientBucketType.SendEdit, BucketId.Create(null, "", null), 10, 10) }; var builder = ImmutableDictionary.CreateBuilder(); @@ -25,21 +25,21 @@ namespace Discord.Net.Queue builder.Add(bucket.Type, bucket); DefsByType = builder.ToImmutable(); - var builder2 = ImmutableDictionary.CreateBuilder(); + var builder2 = ImmutableDictionary.CreateBuilder(); foreach (var bucket in buckets) builder2.Add(bucket.Id, bucket); DefsById = builder2.ToImmutable(); } public static ClientBucket Get(ClientBucketType type) => DefsByType[type]; - public static ClientBucket Get(string id) => DefsById[id]; + public static ClientBucket Get(BucketId id) => DefsById[id]; public ClientBucketType Type { get; } - public string Id { get; } + public BucketId Id { get; } public int WindowCount { get; } public int WindowSeconds { get; } - public ClientBucket(ClientBucketType type, string id, int count, int seconds) + public ClientBucket(ClientBucketType type, BucketId id, int count, int seconds) { Type = type; Id = id; diff --git a/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs b/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs index 7596890ae..64581f44f 100644 --- a/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs +++ b/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs @@ -12,9 +12,9 @@ namespace Discord.Net.Queue { internal class RequestQueue : IDisposable { - public event Func RateLimitTriggered; + public event Func RateLimitTriggered; - private readonly ConcurrentDictionary _buckets; + private readonly ConcurrentDictionary _buckets; private readonly SemaphoreSlim _tokenLock; private readonly CancellationTokenSource _cancelTokenSource; //Dispose token private CancellationTokenSource _clearToken; @@ -38,7 +38,7 @@ namespace Discord.Net.Queue _requestCancelToken = CancellationToken.None; _parentToken = CancellationToken.None; - _buckets = new ConcurrentDictionary(); + _buckets = new ConcurrentDictionary(); _cleanupTask = RunCleanup(); } @@ -94,7 +94,7 @@ namespace Discord.Net.Queue else request.Options.CancelToken = _requestCancelToken; - var bucket = GetOrCreateBucket(request.Options.BucketId, request); + var bucket = GetOrCreateBucket(request.Options, request); var result = await bucket.SendAsync(request).ConfigureAwait(false); createdTokenSource?.Dispose(); return result; @@ -181,12 +181,30 @@ namespace Discord.Net.Queue private RequestBucket GetOrCreateBucket(string id, IRequest request) { - return _buckets.GetOrAdd(id, x => new RequestBucket(this, request, x)); + var bucketId = options.BucketId; + object obj = _buckets.GetOrAdd(bucketId, x => new RequestBucket(this, request, x)); + if (obj is BucketId hashBucket) + { + options.BucketId = hashBucket; + return (RequestBucket)_buckets.GetOrAdd(hashBucket, x => new RequestBucket(this, request, x)); + } + return (RequestBucket)obj; } - internal async Task RaiseRateLimitTriggered(string bucketId, RateLimitInfo? info) + internal async Task RaiseRateLimitTriggered(BucketId bucketId, RateLimitInfo? info) { await RateLimitTriggered(bucketId, info).ConfigureAwait(false); } + internal (RequestBucket, BucketId) UpdateBucketHash(BucketId id, string discordHash) + { + if (!id.IsHashBucket) + { + var bucket = BucketId.Create(discordHash, id); + var hashReqQueue = (RequestBucket)_buckets.GetOrAdd(bucket, _buckets[id]); + _buckets.AddOrUpdate(id, bucket, (oldBucket, oldObj) => bucket); + return (hashReqQueue, bucket); + } + return (null, null); + } private async Task RunCleanup() { @@ -195,10 +213,15 @@ namespace Discord.Net.Queue while (!_cancelTokenSource.IsCancellationRequested) { var now = DateTimeOffset.UtcNow; - foreach (var bucket in _buckets.Select(x => x.Value)) + foreach (var bucket in _buckets.Where(x => x.Value is RequestBucket).Select(x => (RequestBucket)x.Value)) { if ((now - bucket.LastAttemptAt).TotalMinutes > 1.0) + { + if (bucket.Id.IsHashBucket) + foreach (var redirectBucket in _buckets.Where(x => x.Value == bucket.Id).Select(x => (BucketId)x.Value)) + _buckets.TryRemove(redirectBucket, out _); //remove redirections if hash bucket _buckets.TryRemove(bucket.Id, out _); + } } await Task.Delay(60000, _cancelTokenSource.Token).ConfigureAwait(false); //Runs each minute } diff --git a/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs b/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs index b308e909f..45196578a 100644 --- a/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs +++ b/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs @@ -13,12 +13,15 @@ namespace Discord.Net.Queue { internal class RequestBucket { + private const int MinimumSleepTimeMs = 750; + private readonly object _lock; private readonly RequestQueue _queue; private int _semaphore; private DateTimeOffset? _resetTick; + private RequestBucket _redirectBucket; - public string Id { get; private set; } + public BucketId Id { get; private set; } public int WindowCount { get; private set; } public DateTimeOffset LastAttemptAt { get; private set; } @@ -52,6 +55,8 @@ namespace Discord.Net.Queue { await _queue.EnterGlobalAsync(id, request).ConfigureAwait(false); await EnterAsync(id, request).ConfigureAwait(false); + if (_redirectBucket != null) + return await _redirectBucket.SendAsync(request); #if DEBUG_LIMITS Debug.WriteLine($"[{id}] Sending..."); @@ -220,6 +225,9 @@ namespace Discord.Net.Queue while (true) { + if (_redirectBucket != null) + break; + if (DateTimeOffset.UtcNow > request.TimeoutAt || request.Options.CancelToken.IsCancellationRequested) { if (!isRateLimited) @@ -235,7 +243,8 @@ namespace Discord.Net.Queue } DateTimeOffset? timeoutAt = request.TimeoutAt; - if (windowCount > 0 && Interlocked.Decrement(ref _semaphore) < 0) + int semaphore = Interlocked.Decrement(ref _semaphore); + if (windowCount > 0 && semaphore < 0) { if (!isRateLimited) { @@ -245,10 +254,11 @@ namespace Discord.Net.Queue ThrowRetryLimit(request); - if (resetAt.HasValue) + if (resetAt.HasValue && resetAt > DateTimeOffset.UtcNow) { if (resetAt > timeoutAt) ThrowRetryLimit(request); + int millis = (int)Math.Ceiling((resetAt.Value - DateTimeOffset.UtcNow).TotalMilliseconds); #if DEBUG_LIMITS Debug.WriteLine($"[{id}] Sleeping {millis} ms (Pre-emptive)"); @@ -258,18 +268,18 @@ namespace Discord.Net.Queue } else { - if ((timeoutAt.Value - DateTimeOffset.UtcNow).TotalMilliseconds < 500.0) + if ((timeoutAt.Value - DateTimeOffset.UtcNow).TotalMilliseconds < MinimumSleepTimeMs) ThrowRetryLimit(request); #if DEBUG_LIMITS - Debug.WriteLine($"[{id}] Sleeping 500* ms (Pre-emptive)"); + Debug.WriteLine($"[{id}] Sleeping {MinimumSleepTimeMs}* ms (Pre-emptive)"); #endif - await Task.Delay(500, request.Options.CancelToken).ConfigureAwait(false); + await Task.Delay(MinimumSleepTimeMs, request.Options.CancelToken).ConfigureAwait(false); } continue; } #if DEBUG_LIMITS else - Debug.WriteLine($"[{id}] Entered Semaphore ({_semaphore}/{WindowCount} remaining)"); + Debug.WriteLine($"[{id}] Entered Semaphore ({semaphore}/{WindowCount} remaining)"); #endif break; } @@ -282,7 +292,39 @@ namespace Discord.Net.Queue lock (_lock) { + if (redirected) + { + Interlocked.Decrement(ref _semaphore); //we might still hit a real ratelimit if all tickets were already taken, can't do much about it since we didn't know they were the same +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Decrease Semaphore"); +#endif + } bool hasQueuedReset = _resetTick != null; + + if (info.Bucket != null && !redirected) + { + (RequestBucket, BucketId) hashBucket = _queue.UpdateBucketHash(Id, info.Bucket); + if (!(hashBucket.Item1 is null) && !(hashBucket.Item2 is null)) + { + if (hashBucket.Item1 == this) //this bucket got promoted to a hash queue + { + Id = hashBucket.Item2; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Promoted to Hash Bucket ({hashBucket.Item2})"); +#endif + } + else + { + _redirectBucket = hashBucket.Item1; //this request should be part of another bucket, this bucket will be disabled, redirect everything + _redirectBucket.UpdateRateLimit(id, request, info, is429, redirected: true); //update the hash bucket ratelimit +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Redirected to {_redirectBucket.Id}"); +#endif + return; + } + } + } + if (info.Limit.HasValue && WindowCount != info.Limit.Value) { WindowCount = info.Limit.Value; @@ -292,7 +334,6 @@ namespace Discord.Net.Queue #endif } - var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); DateTimeOffset? resetTick = null; //Using X-RateLimit-Remaining causes a race condition @@ -309,16 +350,18 @@ namespace Discord.Net.Queue Debug.WriteLine($"[{id}] Retry-After: {info.RetryAfter.Value} ({info.RetryAfter.Value} ms)"); #endif } - else if (info.ResetAfter.HasValue && (request.Options.UseSystemClock.HasValue ? !request.Options.UseSystemClock.Value : false)) - { - resetTick = DateTimeOffset.Now.Add(info.ResetAfter.Value); - } + else if (info.ResetAfter.HasValue && (request.Options.UseSystemClock.HasValue ? !request.Options.UseSystemClock.Value : false)) + { + resetTick = DateTimeOffset.UtcNow.Add(info.ResetAfter.Value); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Reset-After: {info.ResetAfter.Value} ({info.ResetAfter?.TotalMilliseconds} ms)"); +#endif + } else if (info.Reset.HasValue) { resetTick = info.Reset.Value.AddSeconds(info.Lag?.TotalSeconds ?? 1.0); - /* millisecond precision makes this unnecessary, retaining in case of regression - + /* millisecond precision makes this unnecessary, retaining in case of regression if (request.Options.IsReactionBucket) resetTick = DateTimeOffset.Now.AddMilliseconds(250); */ @@ -328,11 +371,11 @@ namespace Discord.Net.Queue Debug.WriteLine($"[{id}] X-RateLimit-Reset: {info.Reset.Value.ToUnixTimeSeconds()} ({diff} ms, {info.Lag?.TotalMilliseconds} ms lag)"); #endif } - else if (request.Options.IsClientBucket && request.Options.BucketId != null) + else if (request.Options.IsClientBucket && Id != null) { - resetTick = DateTimeOffset.UtcNow.AddSeconds(ClientBucket.Get(request.Options.BucketId).WindowSeconds); + resetTick = DateTimeOffset.UtcNow.AddSeconds(ClientBucket.Get(Id).WindowSeconds); #if DEBUG_LIMITS - Debug.WriteLine($"[{id}] Client Bucket ({ClientBucket.Get(request.Options.BucketId).WindowSeconds * 1000} ms)"); + Debug.WriteLine($"[{id}] Client Bucket ({ClientBucket.Get(Id).WindowSeconds * 1000} ms)"); #endif } else if (request.Options.IsGatewayBucket && request.Options.BucketId != null) @@ -355,7 +398,7 @@ namespace Discord.Net.Queue if (resetTick == null) { - WindowCount = 0; //No rate limit info, disable limits on this bucket (should only ever happen with a user token) + WindowCount = 0; //No rate limit info, disable limits on this bucket #if DEBUG_LIMITS Debug.WriteLine($"[{id}] Disabled Semaphore"); #endif diff --git a/src/Discord.Net.Rest/Net/RateLimitInfo.cs b/src/Discord.Net.Rest/Net/RateLimitInfo.cs index 13e9e39a7..6a7df7b01 100644 --- a/src/Discord.Net.Rest/Net/RateLimitInfo.cs +++ b/src/Discord.Net.Rest/Net/RateLimitInfo.cs @@ -11,7 +11,8 @@ namespace Discord.Net public int? Remaining { get; } public int? RetryAfter { get; } public DateTimeOffset? Reset { get; } - public TimeSpan? ResetAfter { get; } + public TimeSpan? ResetAfter { get; } + public string Bucket { get; } public TimeSpan? Lag { get; } internal RateLimitInfo(Dictionary headers) @@ -26,8 +27,9 @@ namespace Discord.Net double.TryParse(temp, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var reset) ? DateTimeOffset.FromUnixTimeMilliseconds((long)(reset * 1000)) : (DateTimeOffset?)null; RetryAfter = headers.TryGetValue("Retry-After", out temp) && int.TryParse(temp, NumberStyles.None, CultureInfo.InvariantCulture, out var retryAfter) ? retryAfter : (int?)null; - ResetAfter = headers.TryGetValue("X-RateLimit-Reset-After", out temp) && - float.TryParse(temp, out var resetAfter) ? TimeSpan.FromMilliseconds((long)(resetAfter * 1000)) : (TimeSpan?)null; + ResetAfter = headers.TryGetValue("X-RateLimit-Reset-After", out temp) && + double.TryParse(temp, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var resetAfter) ? TimeSpan.FromMilliseconds((long)(resetAfter * 1000)) : (TimeSpan?)null; + Bucket = headers.TryGetValue("X-RateLimit-Bucket", out temp) ? temp : null; Lag = headers.TryGetValue("Date", out temp) && DateTimeOffset.TryParse(temp, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? DateTimeOffset.UtcNow - date : (TimeSpan?)null; } diff --git a/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs b/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs index 1e0bf71c2..e3e24491d 100644 --- a/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs +++ b/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using Newtonsoft.Json; using System.Collections.Generic; @@ -17,5 +17,7 @@ namespace Discord.API.Gateway public Optional ShardingParams { get; set; } [JsonProperty("guild_subscriptions")] public Optional GuildSubscriptions { get; set; } + [JsonProperty("intents")] + public Optional Intents { get; set; } } } diff --git a/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs new file mode 100644 index 000000000..7f804d3f5 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class RemoveAllReactionsForEmoteEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("message_id")] + public ulong MessageId { get; set; } + [JsonProperty("emoji")] + public Emoji Emoji { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 908314f6a..2cd62b3e8 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -234,6 +234,28 @@ namespace Discord.WebSocket remove { _reactionsClearedEvent.Remove(value); } } internal readonly AsyncEvent, ISocketMessageChannel, Task>> _reactionsClearedEvent = new AsyncEvent, ISocketMessageChannel, Task>>(); + /// + /// Fired when all reactions to a message with a specific emote are removed. + /// + /// + /// + /// This event is fired when all reactions to a message with a specific emote are removed. + /// The event handler must return a and accept a and + /// a as its parameters. + /// + /// + /// The channel where this message was sent will be passed into the parameter. + /// + /// + /// The emoji that all reactions had and were removed will be passed into the parameter. + /// + /// + public event Func, ISocketMessageChannel, IEmote, Task> ReactionsRemovedForEmote + { + add { _reactionsRemovedForEmoteEvent.Add(value); } + remove { _reactionsRemovedForEmoteEvent.Remove(value); } + } + internal readonly AsyncEvent, ISocketMessageChannel, IEmote, Task>> _reactionsRemovedForEmoteEvent = new AsyncEvent, ISocketMessageChannel, IEmote, Task>>(); //Roles /// Fired when a role is created. diff --git a/src/Discord.Net.WebSocket/ConnectionManager.cs b/src/Discord.Net.WebSocket/ConnectionManager.cs index 8c9c743cb..e009674e7 100644 --- a/src/Discord.Net.WebSocket/ConnectionManager.cs +++ b/src/Discord.Net.WebSocket/ConnectionManager.cs @@ -141,7 +141,16 @@ namespace Discord catch (OperationCanceledException) { } }); - await _onConnecting().ConfigureAwait(false); + try + { + await _onConnecting().ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + Exception innerEx = ex.InnerException ?? new OperationCanceledException("Failed to connect."); + Error(innerEx); + throw innerEx; + } await _logger.InfoAsync("Connected").ConfigureAwait(false); State = ConnectionState.Connected; diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 497572fd6..83499c8de 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -333,6 +333,7 @@ namespace Discord.WebSocket client.ReactionAdded += (cache, channel, reaction) => _reactionAddedEvent.InvokeAsync(cache, channel, reaction); client.ReactionRemoved += (cache, channel, reaction) => _reactionRemovedEvent.InvokeAsync(cache, channel, reaction); client.ReactionsCleared += (cache, channel) => _reactionsClearedEvent.InvokeAsync(cache, channel); + client.ReactionsRemovedForEmote += (cache, channel, emote) => _reactionsRemovedForEmoteEvent.InvokeAsync(cache, channel, emote); client.RoleCreated += (role) => _roleCreatedEvent.InvokeAsync(role); client.RoleDeleted += (role) => _roleDeletedEvent.InvokeAsync(role); diff --git a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs index 01c2d7f2a..1fe042811 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs @@ -214,7 +214,7 @@ namespace Discord.API await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); } - public async Task SendIdentifyAsync(int largeThreshold = 100, int shardID = 0, int totalShards = 1, bool guildSubscriptions = true, RequestOptions options = null) + public async Task SendIdentifyAsync(int largeThreshold = 100, int shardID = 0, int totalShards = 1, bool guildSubscriptions = true, GatewayIntents? gatewayIntents = null, RequestOptions options = null) { options = RequestOptions.CreateOrClone(options); var props = new Dictionary @@ -225,13 +225,18 @@ namespace Discord.API { Token = AuthToken, Properties = props, - LargeThreshold = largeThreshold, - GuildSubscriptions = guildSubscriptions + LargeThreshold = largeThreshold }; if (totalShards > 1) msg.ShardingParams = new int[] { shardID, totalShards }; options.BucketId = GatewayBucket.Get(GatewayBucketType.Identify).Id; + + if (gatewayIntents.HasValue) + msg.Intents = (int)gatewayIntents.Value; + else + msg.GuildSubscriptions = guildSubscriptions; + await SendGatewayAsync(GatewayOpCode.Identify, msg, options: options).ConfigureAwait(false); } public async Task SendResumeAsync(string sessionId, int lastSeq, RequestOptions options = null) diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs index 51dea5f9f..0418727bf 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs @@ -21,7 +21,13 @@ namespace Discord.WebSocket remove { _disconnectedEvent.Remove(value); } } private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); - /// Fired when guild data has finished downloading. + /// + /// Fired when guild data has finished downloading. + /// + /// + /// It is possible that some guilds might be unsynced if + /// was not long enough to receive all GUILD_AVAILABLEs before READY. + /// public event Func Ready { add { _readyEvent.Add(value); } diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 4dce80399..a8b4933ff 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -44,6 +44,7 @@ namespace Discord.WebSocket private RestApplication _applicationInfo; private bool _isDisposed; private bool _guildSubscriptions; + private GatewayIntents? _gatewayIntents; /// /// Provides access to a REST-only client with a shared state from this client. @@ -137,6 +138,7 @@ namespace Discord.WebSocket Rest = new DiscordSocketRestClient(config, ApiClient); _heartbeatTimes = new ConcurrentQueue(); _guildSubscriptions = config.GuildSubscriptions; + _gatewayIntents = config.GatewayIntents; _stateLock = new SemaphoreSlim(1, 1); _gatewayLogger = LogManager.CreateLogger(ShardId == 0 && TotalShards == 1 ? "Gateway" : $"Shard #{ShardId}"); @@ -167,7 +169,7 @@ namespace Discord.WebSocket GuildAvailable += g => { - if (ConnectionState == ConnectionState.Connected && AlwaysDownloadUsers && !g.HasAllMembers) + if (_guildDownloadTask?.IsCompleted == true && ConnectionState == ConnectionState.Connected && AlwaysDownloadUsers && !g.HasAllMembers) { var _ = g.DownloadUsersAsync(); } @@ -243,7 +245,7 @@ namespace Discord.WebSocket else { await _gatewayLogger.DebugAsync("Identifying").ConfigureAwait(false); - await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, guildSubscriptions: _guildSubscriptions).ConfigureAwait(false); + await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, guildSubscriptions: _guildSubscriptions, gatewayIntents: _gatewayIntents).ConfigureAwait(false); } //Wait for READY @@ -338,7 +340,7 @@ namespace Discord.WebSocket { var user = SocketGlobalUser.Create(this, state, model); user.GlobalUser.AddRef(); - user.Presence = new SocketPresence(UserStatus.Online, null, null); + user.Presence = new SocketPresence(UserStatus.Online, null, null, null); return user; }); } @@ -366,7 +368,7 @@ namespace Discord.WebSocket { var cachedGuilds = guilds.ToImmutableArray(); - const short batchSize = 50; + const short batchSize = 100; //TODO: Gateway Intents will limit to a maximum of 1 guild_id ulong[] batchIds = new ulong[Math.Min(batchSize, cachedGuilds.Length)]; Task[] batchTasks = new Task[batchIds.Length]; int batchCount = (cachedGuilds.Length + (batchSize - 1)) / batchSize; @@ -374,7 +376,7 @@ namespace Discord.WebSocket for (int i = 0, k = 0; i < batchCount; i++) { bool isLast = i == batchCount - 1; - int count = isLast ? (batchIds.Length - (batchCount - 1) * batchSize) : batchSize; + int count = isLast ? (cachedGuilds.Length - (batchCount - 1) * batchSize) : batchSize; for (int j = 0; j < count; j++, k++) { @@ -446,7 +448,7 @@ namespace Discord.WebSocket return; var status = Status; var statusSince = _statusSince; - CurrentUser.Presence = new SocketPresence(status, Activity, null); + CurrentUser.Presence = new SocketPresence(status, Activity, null, null); var gameModel = new GameModel(); // Discord only accepts rich presence over RPC, don't even bother building a payload @@ -515,7 +517,7 @@ namespace Discord.WebSocket _sessionId = null; _lastSeq = 0; - await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards).ConfigureAwait(false); + await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, guildSubscriptions: _guildSubscriptions, gatewayIntents: _gatewayIntents).ConfigureAwait(false); } break; case GatewayOpCode.Reconnect: @@ -574,6 +576,9 @@ namespace Discord.WebSocket } else if (_connection.CancelToken.IsCancellationRequested) return; + + if (BaseConfig.AlwaysDownloadUsers) + _ = DownloadUsersAsync(Guilds.Where(x => x.IsAvailable && !x.HasAllMembers)); await TimedInvokeAsync(_readyEvent, nameof(Ready)).ConfigureAwait(false); await _gatewayLogger.InfoAsync("Ready").ConfigureAwait(false); @@ -1389,6 +1394,34 @@ namespace Discord.WebSocket } } break; + case "MESSAGE_REACTION_REMOVE_EMOJI": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_REACTION_REMOVE_EMOJI)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (State.GetChannel(data.ChannelId) is ISocketMessageChannel channel) + { + var cachedMsg = channel.GetCachedMessage(data.MessageId) as SocketUserMessage; + bool isCached = cachedMsg != null; + + var optionalMsg = !isCached + ? Optional.Create() + : Optional.Create(cachedMsg); + + var cacheable = new Cacheable(cachedMsg, data.MessageId, isCached, async () => await channel.GetMessageAsync(data.MessageId).ConfigureAwait(false) as IUserMessage); + var emote = data.Emoji.ToIEmote(); + + cachedMsg?.RemoveAllReactionsForEmoteAsync(emote); + + await TimedInvokeAsync(_reactionsRemovedForEmoteEvent, nameof(ReactionsRemovedForEmote), cacheable, channel, emote).ConfigureAwait(false); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + break; case "MESSAGE_DELETE_BULK": { await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_DELETE_BULK)").ConfigureAwait(false); @@ -1740,7 +1773,7 @@ namespace Discord.WebSocket try { await logger.DebugAsync("GuildDownloader Started").ConfigureAwait(false); - while ((_unavailableGuildCount != 0) && (Environment.TickCount - _lastGuildAvailableTime < 2000)) + while ((_unavailableGuildCount != 0) && (Environment.TickCount - _lastGuildAvailableTime < BaseConfig.MaxWaitBetweenGuildAvailablesBeforeReady)) await Task.Delay(500, cancelToken).ConfigureAwait(false); await logger.DebugAsync("GuildDownloader Stopped").ConfigureAwait(false); } @@ -1769,17 +1802,7 @@ namespace Discord.WebSocket return guild; } internal SocketGuild RemoveGuild(ulong id) - { - var guild = State.RemoveGuild(id); - if (guild != null) - { - foreach (var _ in guild.Channels) - State.RemoveChannel(id); - foreach (var user in guild.Users) - user.GlobalUser.RemoveRef(this); - } - return guild; - } + => State.RemoveGuild(id); /// Unexpected channel type is created. internal ISocketPrivateChannel AddPrivateChannel(API.Channel model, ClientState state) diff --git a/src/Discord.Net.WebSocket/DiscordSocketConfig.cs b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs index a87f57c6a..3665a3428 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketConfig.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs @@ -121,6 +121,7 @@ namespace Discord.WebSocket /// /// Gets or sets enabling dispatching of guild subscription events e.g. presence and typing events. + /// This is not used if are provided. /// public bool GuildSubscriptions { get; set; } = true; @@ -132,6 +133,40 @@ namespace Discord.WebSocket /// public int IdentifyMaxConcurrency { get; set; } = 1; + /// Gets or sets the maximum wait time in milliseconds between GUILD_AVAILABLE events before firing READY. + /// + /// If zero, READY will fire as soon as it is received and all guilds will be unavailable. + /// + /// + /// This property is measured in milliseconds, negative values will throw an exception. + /// If a guild is not received before READY, it will be unavailable. + /// + /// + /// The maximum wait time in milliseconds between GUILD_AVAILABLE events before firing READY. + /// + /// Value must be at least 0. + public int MaxWaitBetweenGuildAvailablesBeforeReady { + get + { + return _maxWaitForGuildAvailable; + } + set + { + Preconditions.AtLeast(value, 0, nameof(MaxWaitBetweenGuildAvailablesBeforeReady)); + _maxWaitForGuildAvailable = value; + } + } + private int _maxWaitForGuildAvailable = 10000; + + /// Gets or sets gateway intents to limit what events are sent from Discord. Allows for more granular control than the property. + /// + /// + /// For more information, please see + /// GatewayIntents + /// on the official Discord API documentation. + /// + public GatewayIntents? GatewayIntents { get; set; } + /// /// Initializes a default configuration. /// diff --git a/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs index 378478dcc..e8511f1f5 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs @@ -42,7 +42,7 @@ namespace Discord.WebSocket /// Sends a file to this message channel with an optional caption. /// /// - /// This method follows the same behavior as described in . + /// This method follows the same behavior as described in . /// Please visit its documentation for more details on this method. /// /// The file path of the file. @@ -51,16 +51,20 @@ namespace Discord.WebSocket /// The to be sent. /// The options to be used when sending the request. /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Sends a file to this message channel with an optional caption. /// /// - /// This method follows the same behavior as described in . + /// This method follows the same behavior as described in . /// Please visit its documentation for more details on this method. /// /// The of the file to be sent. @@ -70,11 +74,15 @@ namespace Discord.WebSocket /// The to be sent. /// The options to be used when sending the request. /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If null, all mentioned roles and users will be notified. + /// /// /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false); + new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null); /// /// Gets a cached message from this channel. diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs index e6339b6d9..5cfbcc1a8 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs @@ -11,23 +11,11 @@ namespace Discord.WebSocket public static IAsyncEnumerable> GetMessagesAsync(ISocketMessageChannel channel, DiscordSocketClient discord, MessageCache messages, ulong? fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) { - if (dir == Direction.Around) - throw new NotImplementedException(); //TODO: Impl - - IReadOnlyCollection cachedMessages = null; - IAsyncEnumerable> result = null; - if (dir == Direction.After && fromMessageId == null) return AsyncEnumerable.Empty>(); - if (dir == Direction.Before || mode == CacheMode.CacheOnly) - { - if (messages != null) //Cache enabled - cachedMessages = messages.GetMany(fromMessageId, dir, limit); - else - cachedMessages = ImmutableArray.Create(); - result = ImmutableArray.Create(cachedMessages).ToAsyncEnumerable>(); - } + var cachedMessages = GetCachedMessages(channel, discord, messages, fromMessageId, dir, limit); + var result = ImmutableArray.Create(cachedMessages).ToAsyncEnumerable>(); if (dir == Direction.Before) { @@ -38,18 +26,35 @@ namespace Discord.WebSocket //Download remaining messages ulong? minId = cachedMessages.Count > 0 ? cachedMessages.Min(x => x.Id) : fromMessageId; var downloadedMessages = ChannelHelper.GetMessagesAsync(channel, discord, minId, dir, limit, options); - return result.Concat(downloadedMessages); + if (cachedMessages.Count != 0) + return result.Concat(downloadedMessages); + else + return downloadedMessages; } - else + else if (dir == Direction.After) + { + limit -= cachedMessages.Count; + if (mode == CacheMode.CacheOnly || limit <= 0) + return result; + + //Download remaining messages + ulong maxId = cachedMessages.Count > 0 ? cachedMessages.Max(x => x.Id) : fromMessageId.Value; + var downloadedMessages = ChannelHelper.GetMessagesAsync(channel, discord, maxId, dir, limit, options); + if (cachedMessages.Count != 0) + return result.Concat(downloadedMessages); + else + return downloadedMessages; + } + else //Direction.Around { - if (mode == CacheMode.CacheOnly) + if (mode == CacheMode.CacheOnly || limit <= cachedMessages.Count) return result; - //Dont use cache in this case + //Cache isn't useful here since Discord will send them anyways return ChannelHelper.GetMessagesAsync(channel, discord, fromMessageId, dir, limit, options); } } - public static IReadOnlyCollection GetCachedMessages(SocketChannel channel, DiscordSocketClient discord, MessageCache messages, + public static IReadOnlyCollection GetCachedMessages(ISocketMessageChannel channel, DiscordSocketClient discord, MessageCache messages, ulong? fromMessageId, Direction dir, int limit) { if (messages != null) //Cache enabled diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs index 11259a31e..527685578 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs @@ -139,12 +139,12 @@ namespace Discord.WebSocket => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, options); /// - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); @@ -229,11 +229,11 @@ namespace Discord.WebSocket async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) => await GetPinnedMessagesAsync(options).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs index c57c37db2..b95bbffc1 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs @@ -167,11 +167,11 @@ namespace Discord.WebSocket => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, options); /// - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) @@ -293,11 +293,11 @@ namespace Discord.WebSocket => await GetPinnedMessagesAsync(options).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index 1b3b5bcd7..e49e3ed37 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -165,13 +165,13 @@ namespace Discord.WebSocket => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, options); /// - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, options, isSpoiler); /// /// Message content is too long, length must be less or equal to . - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options, isSpoiler); + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, options, isSpoiler); /// public Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) @@ -302,11 +302,11 @@ namespace Discord.WebSocket => await GetPinnedMessagesAsync(options).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler) - => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler).ConfigureAwait(false); + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions).ConfigureAwait(false); /// async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions).ConfigureAwait(false); diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index fb0a56c24..d2d759bb3 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -809,17 +809,37 @@ namespace Discord.WebSocket var members = Users; var self = CurrentUser; _members.Clear(); - _members.TryAdd(self.Id, self); + if (self != null) + _members.TryAdd(self.Id, self); DownloadedMemberCount = _members.Count; foreach (var member in members) { - if (member.Id != self.Id) + if (member.Id != self?.Id) member.GlobalUser.RemoveRef(Discord); } } + /// + /// Gets a collection of all users in this guild. + /// + /// + /// This method retrieves all users found within this guild throught REST. + /// Users returned by this method are not cached. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users found within this guild. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + { + if (HasAllMembers) + return ImmutableArray.Create(Users).ToAsyncEnumerable>(); + return GuildHelper.GetUsersAsync(this, Discord, null, null, options); + } + /// public async Task DownloadUsersAsync() { @@ -830,6 +850,23 @@ namespace Discord.WebSocket _downloaderPromise.TrySetResultAsync(true); } + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + public Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, RequestOptions options = null) + => GuildHelper.SearchUsersAsync(this, Discord, query, limit, options); + //Audit logs /// /// Gets the specified number of audit log entries for this guild. @@ -1184,8 +1221,13 @@ namespace Discord.WebSocket => await CreateRoleAsync(name, permissions, color, isHoisted, isMentionable, options).ConfigureAwait(false); /// - Task> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) - => Task.FromResult>(Users); + async Task> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload && !HasAllMembers) + return (await GetUsersAsync(options).FlattenAsync().ConfigureAwait(false)).ToImmutableArray(); + else + return Users; + } /// async Task IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action func, RequestOptions options) @@ -1199,6 +1241,14 @@ namespace Discord.WebSocket /// Task IGuild.GetOwnerAsync(CacheMode mode, RequestOptions options) => Task.FromResult(Owner); + /// + async Task> IGuild.SearchUsersAsync(string query, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await SearchUsersAsync(query, limit, options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } /// async Task> IGuild.GetAuditLogsAsync(int limit, CacheMode cacheMode, RequestOptions options, diff --git a/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs b/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs index 24e46df46..6baf56879 100644 --- a/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs +++ b/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs @@ -56,11 +56,23 @@ namespace Discord.WebSocket cachedMessageIds = _orderedMessages; else if (dir == Direction.Before) cachedMessageIds = _orderedMessages.Where(x => x < fromMessageId.Value); - else + else if (dir == Direction.After) cachedMessageIds = _orderedMessages.Where(x => x > fromMessageId.Value); + else //Direction.Around + { + if (!_messages.TryGetValue(fromMessageId.Value, out SocketMessage msg)) + return ImmutableArray.Empty; + int around = limit / 2; + var before = GetMany(fromMessageId, Direction.Before, around); + var after = GetMany(fromMessageId, Direction.After, around).Reverse(); + + return after.Concat(new SocketMessage[] { msg }).Concat(before).ToImmutableArray(); + } if (dir == Direction.Before) cachedMessageIds = cachedMessageIds.Reverse(); + if (dir == Direction.Around) //Only happens if fromMessageId is null, should only get "around" and itself (+1) + limit = limit / 2 + 1; return cachedMessageIds .Select(x => diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs index 7900b7ee7..f392614ad 100644 --- a/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs @@ -140,7 +140,7 @@ namespace Discord.WebSocket Activity = new MessageActivity() { Type = model.Activity.Value.Type.Value, - PartyId = model.Activity.Value.PartyId.Value + PartyId = model.Activity.Value.PartyId.GetValueOrDefault() }; } @@ -200,6 +200,10 @@ namespace Discord.WebSocket { _reactions.Clear(); } + internal void RemoveReactionsForEmote(IEmote emote) + { + _reactions.RemoveAll(x => x.Emote.Equals(emote)); + } /// public Task AddReactionAsync(IEmote emote, RequestOptions options = null) @@ -214,6 +218,9 @@ namespace Discord.WebSocket public Task RemoveAllReactionsAsync(RequestOptions options = null) => MessageHelper.RemoveAllReactionsAsync(this, Discord, options); /// + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsForEmoteAsync(this, emote, Discord, options); + /// public IAsyncEnumerable> GetReactionUsersAsync(IEmote emote, int limit, RequestOptions options = null) => MessageHelper.GetReactionUsersAsync(this, emote, limit, Discord, options); } diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs index b26dfe5fb..e1f0f74dc 100644 --- a/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs @@ -123,7 +123,7 @@ namespace Discord.WebSocket model.Content = text; } } - + /// /// Only the author of a message may modify the message. /// Message content is too long, length must be less or equal to . @@ -147,7 +147,19 @@ namespace Discord.WebSocket public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) => MentionUtils.Resolve(this, 0, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); - + + /// + /// This operation may only be called on a channel. + public async Task CrosspostAsync(RequestOptions options = null) + { + if (!(Channel is SocketNewsChannel)) + { + throw new InvalidOperationException("Publishing (crossposting) is only valid in news channels."); + } + + await MessageHelper.CrosspostAsync(this, Discord, options); + } + private string DebuggerDisplay => $"{Author}: {Content} ({Id}{(Attachments.Count > 0 ? $", {Attachments.Count} Attachments" : "")})"; internal new SocketUserMessage Clone() => MemberwiseClone() as SocketUserMessage; } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs index e5dbfa01d..a506a5d7f 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs @@ -154,6 +154,8 @@ namespace Discord.WebSocket Nickname = model.Nick.Value; if (model.Roles.IsSpecified) UpdateRoles(model.Roles.Value); + if (model.PremiumSince.IsSpecified) + _premiumSinceTicks = model.PremiumSince.Value?.UtcTicks; } private void UpdateRoles(ulong[] roleIds) { diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs index 52f111303..407e14419 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs @@ -18,16 +18,20 @@ namespace Discord.WebSocket public IActivity Activity { get; } /// public IImmutableSet ActiveClients { get; } - internal SocketPresence(UserStatus status, IActivity activity, IImmutableSet activeClients) + /// + public IImmutableList Activities { get; } + internal SocketPresence(UserStatus status, IActivity activity, IImmutableSet activeClients, IImmutableList activities) { Status = status; - Activity= activity; - ActiveClients = activeClients; + Activity = activity; + ActiveClients = activeClients ?? ImmutableHashSet.Empty; + Activities = activities ?? ImmutableList.Empty; } internal static SocketPresence Create(Model model) { var clients = ConvertClientTypesDict(model.ClientStatus.GetValueOrDefault()); - return new SocketPresence(model.Status, model.Game?.ToEntity(), clients); + var activities = ConvertActivitiesList(model.Activities); + return new SocketPresence(model.Status, model.Game?.ToEntity(), clients, activities); } /// /// Creates a new containing all of the client types @@ -53,6 +57,25 @@ namespace Discord.WebSocket } return set.ToImmutableHashSet(); } + /// + /// Creates a new containing all the activities + /// that a user has from the data supplied in the Presence update frame. + /// + /// + /// A list of . + /// + /// + /// A list of all that this user currently has available. + /// + private static IImmutableList ConvertActivitiesList(IList activities) + { + if (activities == null || activities.Count == 0) + return ImmutableList.Empty; + var list = new List(); + foreach (var activity in activities) + list.Add(activity.ToEntity()); + return list.ToImmutableList(); + } /// /// Gets the status of the user. diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs index 840a1c30b..dd2e747b4 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs @@ -25,7 +25,7 @@ namespace Discord.WebSocket /// public override bool IsWebhook => false; /// - internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null); } set { } } + internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null, null); } set { } } /// /// This field is not supported for an unknown user. internal override SocketGlobalUser GlobalUser => diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs index 09c4165f4..7d3c2d23b 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs @@ -41,11 +41,16 @@ namespace Discord.WebSocket public UserStatus Status => Presence.Status; /// public IImmutableSet ActiveClients => Presence.ActiveClients ?? ImmutableHashSet.Empty; + /// + public IImmutableList Activities => Presence.Activities ?? ImmutableList.Empty; /// /// Gets mutual guilds shared with this user. /// + /// + /// This property will only include guilds in the same . + /// public IReadOnlyCollection MutualGuilds - => Discord.Guilds.Where(g => g.Users.Any(u => u.Id == Id)).ToImmutableArray(); + => Discord.Guilds.Where(g => g.GetUser(Id) != null).ToImmutableArray(); internal SocketUser(DiscordSocketClient discord, ulong id) : base(discord, id) diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs index 8819fe1b4..d400e1ae7 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs @@ -30,7 +30,7 @@ namespace Discord.WebSocket /// public override bool IsWebhook => true; /// - internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null); } set { } } + internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null, null); } set { } } internal override SocketGlobalUser GlobalUser => throw new NotSupportedException(); diff --git a/src/Discord.Net.Webhook/DiscordWebhookClient.cs b/src/Discord.Net.Webhook/DiscordWebhookClient.cs index dceb45739..e1e8b71b0 100644 --- a/src/Discord.Net.Webhook/DiscordWebhookClient.cs +++ b/src/Discord.Net.Webhook/DiscordWebhookClient.cs @@ -33,7 +33,7 @@ namespace Discord.Webhook : this(webhookUrl, new DiscordRestConfig()) { } // regex pattern to match webhook urls - private static Regex WebhookUrlRegex = new Regex(@"^.*discordapp\.com\/api\/webhooks\/([\d]+)\/([a-z0-9_-]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static Regex WebhookUrlRegex = new Regex(@"^.*(discord|discordapp)\.com\/api\/webhooks\/([\d]+)\/([a-z0-9_-]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// Creates a new Webhook Discord client. public DiscordWebhookClient(ulong webhookId, string webhookToken, DiscordRestConfig config) @@ -77,9 +77,9 @@ namespace Discord.Webhook ApiClient.RequestQueue.RateLimitTriggered += async (id, info) => { if (info == null) - await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {id ?? "null"}").ConfigureAwait(false); + await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {id?.ToString() ?? "null"}").ConfigureAwait(false); else - await _restLogger.WarningAsync($"Rate limit triggered: {id ?? "null"}").ConfigureAwait(false); + await _restLogger.WarningAsync($"Rate limit triggered: {id?.ToString() ?? "null"}").ConfigureAwait(false); }; ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false); } @@ -132,13 +132,13 @@ namespace Discord.Webhook if (match != null) { // ensure that the first group is a ulong, set the _webhookId - // 0th group is always the entire match, so start at index 1 - if (!(match.Groups[1].Success && ulong.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out webhookId))) + // 0th group is always the entire match, and 1 is the domain; so start at index 2 + if (!(match.Groups[2].Success && ulong.TryParse(match.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out webhookId))) throw ex("The webhook Id could not be parsed."); - if (!match.Groups[2].Success) + if (!match.Groups[3].Success) throw ex("The webhook token could not be parsed."); - webhookToken = match.Groups[2].Value; + webhookToken = match.Groups[3].Value; } else throw ex(); diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs index c8d68fb4d..870c05812 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs @@ -73,12 +73,12 @@ namespace Discord throw new NotImplementedException(); } - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); } - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs index 5a26b713f..31df719da 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs @@ -81,12 +81,12 @@ namespace Discord throw new NotImplementedException(); } - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); } - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs index a57c72899..a95a91f5c 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs @@ -167,12 +167,12 @@ namespace Discord throw new NotImplementedException(); } - public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); } - public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false) + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) { throw new NotImplementedException(); }