diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index cdf0118c4..81b5e8dd9 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -710,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/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/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/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/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 732cb5f17..30984c0e9 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -660,6 +660,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)); @@ -1136,6 +1148,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) @@ -1146,13 +1174,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) { diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 790b1e5c3..675847b58 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -264,19 +264,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() + }; + + var model = await client.ApiClient.CreateGuildRoleAsync(guild.Id, createGuildRoleParams, options).ConfigureAwait(false); - return role; + return RestRole.Create(client, guild, model); } //Users @@ -387,6 +386,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 57f8b2509..d6a718b3a 100644 --- a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs +++ b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs @@ -78,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) { diff --git a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs index b4a33c76c..809a55e9c 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs @@ -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.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/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 8359ca048..930ea1585 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -313,6 +313,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/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index b56498061..10470365f 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -1391,6 +1391,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); diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index fb0a56c24..169190a33 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -830,6 +830,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. @@ -1199,6 +1216,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/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); }