diff --git a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs index 9ee1a748c..afe3a5af6 100644 --- a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs +++ b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs @@ -54,7 +54,7 @@ namespace Discord.Commands.Builders if (type.GetTypeInfo().IsValueType) DefaultValue = Activator.CreateInstance(type); else if (type.IsArray) - type = ParameterType.GetElementType(); + DefaultValue = Array.CreateInstance(type.GetElementType(), 0); ParameterType = type; } diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index bd65b0eb7..f9552ef4b 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -517,19 +517,83 @@ namespace Discord.Commands services ??= EmptyServiceProvider.Instance; var searchResult = Search(input); - if (!searchResult.IsSuccess) + + var validationResult = await ValidateAndGetBestMatch(searchResult, context, services, multiMatchHandling); + + if (validationResult is SearchResult result) + { + await _commandExecutedEvent.InvokeAsync(Optional.Create(), context, result).ConfigureAwait(false); + return result; + } + + if (validationResult is MatchResult matchResult) + { + return await HandleCommandPipeline(matchResult, context, services); + } + + return validationResult; + } + + private async Task HandleCommandPipeline(MatchResult matchResult, ICommandContext context, IServiceProvider services) + { + if (!matchResult.IsSuccess) + return matchResult; + + if (matchResult.Pipeline is ParseResult parseResult) + { + var executeResult = await matchResult.Match.Value.ExecuteAsync(context, parseResult, services); + + if (!executeResult.IsSuccess && !(executeResult is RuntimeResult || executeResult is ExecuteResult)) // succesful results raise the event in CommandInfo#ExecuteInternalAsync (have to raise it there b/c deffered execution) + await _commandExecutedEvent.InvokeAsync(matchResult.Match.Value.Command, context, executeResult); + return executeResult; + } + + if (matchResult.Pipeline is PreconditionResult preconditionResult) + { + await _commandExecutedEvent.InvokeAsync(matchResult.Match.Value.Command, context, preconditionResult).ConfigureAwait(false); + } + + return matchResult; + } + + // Calculates the 'score' of a command given a parse result + float CalculateScore(CommandMatch match, ParseResult parseResult) + { + float argValuesScore = 0, paramValuesScore = 0; + + if (match.Command.Parameters.Count > 0) { - await _commandExecutedEvent.InvokeAsync(Optional.Create(), context, searchResult).ConfigureAwait(false); - return searchResult; + var argValuesSum = parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; + var paramValuesSum = parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; + + argValuesScore = argValuesSum / match.Command.Parameters.Count; + paramValuesScore = paramValuesSum / match.Command.Parameters.Count; } + var totalArgsScore = (argValuesScore + paramValuesScore) / 2; + return match.Command.Priority + totalArgsScore * 0.99f; + } - var commands = searchResult.Commands; + /// + /// Validates and gets the best from a specified + /// + /// The SearchResult. + /// The context of the command. + /// The service provider to be used on the command's dependency injection. + /// The handling mode when multiple command matches are found. + /// A task that represents the asynchronous validation operation. The task result contains the result of the + /// command validation as a or a if no matches were found. + public async Task ValidateAndGetBestMatch(SearchResult matches, ICommandContext context, IServiceProvider provider, MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + if (!matches.IsSuccess) + return matches; + + var commands = matches.Commands; var preconditionResults = new Dictionary(); - foreach (var match in commands) + foreach (var command in commands) { - preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services).ConfigureAwait(false); + preconditionResults[command] = await command.CheckPreconditionsAsync(context, provider); } var successfulPreconditions = preconditionResults @@ -540,19 +604,16 @@ namespace Discord.Commands { //All preconditions failed, return the one from the highest priority command var bestCandidate = preconditionResults - .OrderByDescending(x => x.Key.Command.Priority) - .FirstOrDefault(x => !x.Value.IsSuccess); - - await _commandExecutedEvent.InvokeAsync(bestCandidate.Key.Command, context, bestCandidate.Value).ConfigureAwait(false); - return bestCandidate.Value; + .OrderByDescending(x => x.Key.Command.Priority) + .FirstOrDefault(x => !x.Value.IsSuccess); + return MatchResult.FromSuccess(bestCandidate.Key,bestCandidate.Value); } - //If we get this far, at least one precondition was successful. + var parseResults = new Dictionary(); - var parseResultsDict = new Dictionary(); foreach (var pair in successfulPreconditions) { - var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services).ConfigureAwait(false); + var parseResult = await pair.Key.ParseAsync(context, matches, pair.Value, provider).ConfigureAwait(false); if (parseResult.Error == CommandError.MultipleMatches) { @@ -567,51 +628,27 @@ namespace Discord.Commands } } - parseResultsDict[pair.Key] = parseResult; - } - - // Calculates the 'score' of a command given a parse result - float CalculateScore(CommandMatch match, ParseResult parseResult) - { - float argValuesScore = 0, paramValuesScore = 0; - - if (match.Command.Parameters.Count > 0) - { - var argValuesSum = parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; - var paramValuesSum = parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; - - argValuesScore = argValuesSum / match.Command.Parameters.Count; - paramValuesScore = paramValuesSum / match.Command.Parameters.Count; - } - - var totalArgsScore = (argValuesScore + paramValuesScore) / 2; - return match.Command.Priority + totalArgsScore * 0.99f; + parseResults[pair.Key] = parseResult; } - //Order the parse results by their score so that we choose the most likely result to execute - var parseResults = parseResultsDict - .OrderByDescending(x => CalculateScore(x.Key, x.Value)); + var weightedParseResults = parseResults + .OrderByDescending(x => CalculateScore(x.Key, x.Value)); - var successfulParses = parseResults + var successfulParses = weightedParseResults .Where(x => x.Value.IsSuccess) .ToArray(); - if (successfulParses.Length == 0) + if(successfulParses.Length == 0) { - //All parses failed, return the one from the highest priority command, using score as a tie breaker var bestMatch = parseResults .FirstOrDefault(x => !x.Value.IsSuccess); - await _commandExecutedEvent.InvokeAsync(bestMatch.Key.Command, context, bestMatch.Value).ConfigureAwait(false); - return bestMatch.Value; + return MatchResult.FromSuccess(bestMatch.Key,bestMatch.Value); } - //If we get this far, at least one parse was successful. Execute the most likely overload. var chosenOverload = successfulParses[0]; - var result = await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services).ConfigureAwait(false); - if (!result.IsSuccess && !(result is RuntimeResult || result is ExecuteResult)) // successful results raise the event in CommandInfo#ExecuteInternalAsync (have to raise it there b/c deferred execution) - await _commandExecutedEvent.InvokeAsync(chosenOverload.Key.Command, context, result); - return result; + + return MatchResult.FromSuccess(chosenOverload.Key,chosenOverload.Value); } #endregion diff --git a/src/Discord.Net.Commands/Results/MatchResult.cs b/src/Discord.Net.Commands/Results/MatchResult.cs new file mode 100644 index 000000000..fb266efa6 --- /dev/null +++ b/src/Discord.Net.Commands/Results/MatchResult.cs @@ -0,0 +1,47 @@ +using System; + +namespace Discord.Commands +{ + public class MatchResult : IResult + { + /// + /// Gets the command that may have matched during the command execution. + /// + public CommandMatch? Match { get; } + + /// + /// Gets on which pipeline stage the command may have matched or failed. + /// + public IResult? Pipeline { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + /// + public bool IsSuccess => !Error.HasValue; + + private MatchResult(CommandMatch? match, IResult? pipeline, CommandError? error, string errorReason) + { + Match = match; + Error = error; + Pipeline = pipeline; + ErrorReason = errorReason; + } + + public static MatchResult FromSuccess(CommandMatch match, IResult pipeline) + => new MatchResult(match,pipeline,null, null); + public static MatchResult FromError(CommandError error, string reason) + => new MatchResult(null,null,error, reason); + public static MatchResult FromError(Exception ex) + => FromError(CommandError.Exception, ex.Message); + public static MatchResult FromError(IResult result) + => new MatchResult(null, null,result.Error, result.ErrorReason); + public static MatchResult FromError(IResult pipeline, CommandError error, string reason) + => new MatchResult(null, pipeline, error, reason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + + } +} diff --git a/src/Discord.Net.Core/Discord.Net.Core.csproj b/src/Discord.Net.Core/Discord.Net.Core.csproj index 29868e1c7..7dc55b1cf 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -1,6 +1,6 @@  - + Discord.Net.Core Discord diff --git a/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs b/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs new file mode 100644 index 000000000..80f128fa8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public enum DefaultApplications : ulong + { + /// + /// Watch youtube together. + /// + Youtube = 880218394199220334, + + /// + /// Youtube development application. + /// + YoutubeDev = 880218832743055411, + + /// + /// Poker! + /// + Poker = 755827207812677713, + + /// + /// Betrayal: A Party Adventure. Betrayal is a social deduction game inspired by Werewolf, Town of Salem, and Among Us. + /// + Betrayal = 773336526917861400, + + /// + /// Sit back, relax, and do some fishing! + /// + Fishing = 814288819477020702, + + /// + /// The queens gambit. + /// + Chess = 832012774040141894, + + /// + /// Development version of chess. + /// + ChessDev = 832012586023256104, + + /// + /// LetterTile is a version of scrabble. + /// + LetterTile = 879863686565621790, + + /// + /// Find words in a jumble of letters in coffee. + /// + WordSnack = 879863976006127627, + + /// + /// It's like skribbl.io. + /// + DoodleCrew = 878067389634314250, + + /// + /// It's like cards against humanity. + /// + Awkword = 879863881349087252, + + /// + /// A word-search like game where you unscramble words and score points in a scrabble fashion. + /// + SpellCast = 852509694341283871, + + /// + /// Classic checkers + /// + Checkers = 832013003968348200, + + /// + /// The development version of poker. + /// + PokerDev = 763133495793942528, + + /// + /// SketchyArtist. + /// + SketchyArtist = 879864070101172255 + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs index 563acd4f8..511d2bf51 100644 --- a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs @@ -60,13 +60,6 @@ namespace Discord /// /// Creates a new invite to this channel. /// - /// - /// The following example creates a new invite to this channel; the invite lasts for 12 hours and can only - /// be used 3 times throughout its lifespan. - /// - /// await guildChannel.CreateInviteAsync(maxAge: 43200, maxUses: 3); - /// - /// /// The id of the embedded application to open for this invite. /// The time (in seconds) until the invite expires. Set to null to never expire. /// The max amount of times this invite may be used. Set to null to have unlimited uses. @@ -79,6 +72,21 @@ namespace Discord /// Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + /// + /// Creates a new invite to this channel. + /// + /// The application to open for this invite. + /// The time (in seconds) until the invite expires. Set to null to never expire. + /// The max amount of times this invite may be used. Set to null to have unlimited uses. + /// If true, the user accepting this invite will be kicked from the guild after closing their client. + /// If true, don't try to reuse a similar invite (useful for creating many unique one time use invites). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous invite creation operation. The task result contains an invite + /// metadata object containing information for the created invite. + /// + Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + /// /// Creates a new invite to this channel. /// diff --git a/src/Discord.Net.Core/Entities/Users/IPresence.cs b/src/Discord.Net.Core/Entities/Users/IPresence.cs index 6972037f0..45babf481 100644 --- a/src/Discord.Net.Core/Entities/Users/IPresence.cs +++ b/src/Discord.Net.Core/Entities/Users/IPresence.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Generic; namespace Discord { @@ -14,10 +14,10 @@ namespace Discord /// /// Gets the set of clients where this user is currently active. /// - IImmutableSet ActiveClients { get; } + IReadOnlyCollection ActiveClients { get; } /// /// Gets the list of activities that this user currently has available. /// - IImmutableList Activities { get; } + IReadOnlyCollection Activities { get; } } } diff --git a/src/Discord.Net.Core/Format.cs b/src/Discord.Net.Core/Format.cs index 63f9d15a6..a5951aa73 100644 --- a/src/Discord.Net.Core/Format.cs +++ b/src/Discord.Net.Core/Format.cs @@ -7,7 +7,8 @@ namespace Discord public static class Format { // Characters which need escaping - private static readonly string[] SensitiveCharacters = { "\\", "*", "_", "~", "`", "|", ">" }; + private static readonly string[] SensitiveCharacters = { + "\\", "*", "_", "~", "`", ".", ":", "/", ">", "|" }; /// Returns a markdown-formatted string with bold formatting. public static string Bold(string text) => $"**{text}**"; @@ -104,5 +105,15 @@ namespace Discord var newText = Regex.Replace(text, @"(\*|_|`|~|>|\\)", ""); return newText; } + + /// + /// Formats a user's username + discriminator while maintaining bidirectional unicode + /// + /// The user whos username and discriminator to format + /// The username + discriminator + public static string UsernameAndDiscriminator(IUser user) + { + return $"\u2066{user.Username}\u2069#{user.Discriminator}"; + } } } diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs index f14bc2ecf..57de0eb45 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -227,8 +227,11 @@ namespace Discord.Rest /// public virtual async Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, options).ConfigureAwait(false); - public virtual Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) - => throw new NotImplementedException(); + public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options); + /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); public virtual Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException(); /// diff --git a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs index 48fc11dcb..239c00467 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs @@ -78,6 +78,9 @@ namespace Discord.Rest public async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options).ConfigureAwait(false); /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + /// public async Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToStreamAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, user, options).ConfigureAwait(false); /// diff --git a/src/Discord.Net.Rest/Entities/Users/RestUser.cs b/src/Discord.Net.Rest/Entities/Users/RestUser.cs index 872bab392..70f990fe7 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestUser.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestUser.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Threading.Tasks; using Model = Discord.API.User; using EventUserModel = Discord.API.GuildScheduledEventUser; +using System.Collections.Generic; namespace Discord.Rest { @@ -41,9 +42,9 @@ namespace Discord.Rest /// public virtual UserStatus Status => UserStatus.Offline; /// - public virtual IImmutableSet ActiveClients => ImmutableHashSet.Empty; + public virtual IReadOnlyCollection ActiveClients => ImmutableHashSet.Empty; /// - public virtual IImmutableList Activities => ImmutableList.Empty; + public virtual IReadOnlyCollection Activities => ImmutableList.Empty; /// public virtual bool IsWebhook => false; @@ -128,8 +129,8 @@ namespace Discord.Rest /// /// A string that resolves to Username#Discriminator of the user. /// - public override string ToString() => $"{Username}#{Discriminator}"; - private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")})"; + public override string ToString() => Format.UsernameAndDiscriminator(this); + private string DebuggerDisplay => $"{Format.UsernameAndDiscriminator(this)} ({Id}{(IsBot ? ", Bot" : "")})"; #endregion #region IUser diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 4ad25d4d5..91fb24021 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -502,6 +502,18 @@ namespace Discord.WebSocket internal readonly AsyncEvent> _recipientRemovedEvent = new AsyncEvent>(); #endregion + #region Presence + + /// Fired when a users presence is updated. + public event Func PresenceUpdated + { + add { _presenceUpdated.Add(value); } + remove { _presenceUpdated.Remove(value); } + } + internal readonly AsyncEvent> _presenceUpdated = new AsyncEvent>(); + + #endregion + #region Invites /// /// Fired when an invite is created. diff --git a/src/Discord.Net.WebSocket/ClientState.cs b/src/Discord.Net.WebSocket/ClientState.cs index 7129feb48..c40ae3f92 100644 --- a/src/Discord.Net.WebSocket/ClientState.cs +++ b/src/Discord.Net.WebSocket/ClientState.cs @@ -115,7 +115,7 @@ namespace Discord.WebSocket if (_guilds.TryRemove(id, out SocketGuild guild)) { guild.PurgeChannelCache(this); - guild.PurgeGuildUserCache(); + guild.PurgeUserCache(); return guild; } return null; @@ -140,7 +140,7 @@ namespace Discord.WebSocket internal void PurgeUsers() { foreach (var guild in _guilds.Values) - guild.PurgeGuildUserCache(); + guild.PurgeUserCache(); } internal SocketApplicationCommand GetCommand(ulong id) diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 03c85ffc7..9ef827778 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -76,6 +76,7 @@ namespace Discord.WebSocket internal int? HandlerTimeout { get; private set; } internal bool AlwaysDownloadDefaultStickers { get; private set; } internal bool AlwaysResolveStickers { get; private set; } + internal bool LogGatewayIntentWarnings { get; private set; } internal new DiscordSocketApiClient ApiClient => base.ApiClient; /// public override IReadOnlyCollection Guilds => State.Guilds; @@ -147,6 +148,7 @@ namespace Discord.WebSocket AlwaysDownloadUsers = config.AlwaysDownloadUsers; AlwaysDownloadDefaultStickers = config.AlwaysDownloadDefaultStickers; AlwaysResolveStickers = config.AlwaysResolveStickers; + LogGatewayIntentWarnings = config.LogGatewayIntentWarnings; HandlerTimeout = config.HandlerTimeout; State = new ClientState(0, 0); Rest = new DiscordSocketRestClient(config, ApiClient); @@ -238,6 +240,9 @@ namespace Discord.WebSocket _defaultStickers = builder.ToImmutable(); } + + if(LogGatewayIntentWarnings) + await LogGatewayIntentsWarning().ConfigureAwait(false); } /// @@ -708,6 +713,52 @@ namespace Discord.WebSocket game); } + private async Task LogGatewayIntentsWarning() + { + if(_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && !_presenceUpdated.HasSubscribers) + { + await _gatewayLogger.WarningAsync("You're using the GuildPresences intent without listening to the PresenceUpdate event, consider removing the intent from your config.").ConfigureAwait(false); + } + + if(!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && _presenceUpdated.HasSubscribers) + { + await _gatewayLogger.WarningAsync("You're using the PresenceUpdate event without specifying the GuildPresences intent, consider adding the intent to your config.").ConfigureAwait(false); + } + + bool hasGuildScheduledEventsSubscribers = + _guildScheduledEventCancelled.HasSubscribers || + _guildScheduledEventUserRemove.HasSubscribers || + _guildScheduledEventCompleted.HasSubscribers || + _guildScheduledEventCreated.HasSubscribers || + _guildScheduledEventStarted.HasSubscribers || + _guildScheduledEventUpdated.HasSubscribers || + _guildScheduledEventUserAdd.HasSubscribers; + + if(_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && !hasGuildScheduledEventsSubscribers) + { + await _gatewayLogger.WarningAsync("You're using the GuildScheduledEvents gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); + } + + if(!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && hasGuildScheduledEventsSubscribers) + { + await _gatewayLogger.WarningAsync("You're using events related to the GuildScheduledEvents gateway intent without specifying the intent, consider adding the intent to your config.").ConfigureAwait(false); + } + + bool hasInviteEventSubscribers = + _inviteCreatedEvent.HasSubscribers || + _inviteDeletedEvent.HasSubscribers; + + if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && !hasInviteEventSubscribers) + { + await _gatewayLogger.WarningAsync("You're using the GuildInvites gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); + } + + if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && hasInviteEventSubscribers) + { + await _gatewayLogger.WarningAsync("You're using events related to the GuildInvites gateway intent without specifying the intent, consider adding the intent to your config.").ConfigureAwait(false); + } + } + #region ProcessMessageAsync private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string type, object payload) { @@ -1858,6 +1909,8 @@ namespace Discord.WebSocket var data = (payload as JToken).ToObject(_serializer); + SocketUser user = null; + if (data.GuildId.IsSpecified) { var guild = State.GetGuild(data.GuildId.Value); @@ -1872,7 +1925,7 @@ namespace Discord.WebSocket return; } - var user = guild.GetUser(data.User.Id); + user = guild.GetUser(data.User.Id); if (user == null) { if (data.Status == UserStatus.Offline) @@ -1890,26 +1943,21 @@ namespace Discord.WebSocket await TimedInvokeAsync(_userUpdatedEvent, nameof(UserUpdated), globalBefore, user).ConfigureAwait(false); } } - - var before = user.Clone(); - user.Update(State, data, true); - var cacheableBefore = new Cacheable(before, user.Id, true, () => Task.FromResult(user)); - await TimedInvokeAsync(_guildMemberUpdatedEvent, nameof(GuildMemberUpdated), cacheableBefore, user).ConfigureAwait(false); } else { - var globalUser = State.GetUser(data.User.Id); - if (globalUser == null) + user = State.GetUser(data.User.Id); + if (user == null) { await UnknownGlobalUserAsync(type, data.User.Id).ConfigureAwait(false); return; } - - var before = globalUser.Clone(); - globalUser.Update(State, data.User); - globalUser.Update(State, data); - await TimedInvokeAsync(_userUpdatedEvent, nameof(UserUpdated), before, globalUser).ConfigureAwait(false); } + + var before = user.Presence.Clone(); + user.Update(State, data.User); + user.Update(data); + await TimedInvokeAsync(_presenceUpdated, nameof(PresenceUpdated), user, before, user.Presence).ConfigureAwait(false); } break; case "TYPING_START": diff --git a/src/Discord.Net.WebSocket/DiscordSocketConfig.cs b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs index 8615eac71..f0e6dc857 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketConfig.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs @@ -183,6 +183,11 @@ namespace Discord.WebSocket /// public GatewayIntents GatewayIntents { get; set; } = GatewayIntents.AllUnprivileged; + /// + /// Gets or sets whether or not to log warnings related to guild intents and events. + /// + public bool LogGatewayIntentWarnings { get; set; } = true; + /// /// Initializes a new instance of the class with the default configuration. /// diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index 8722b569d..aea1bfda5 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -324,6 +324,9 @@ namespace Discord.WebSocket public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options).ConfigureAwait(false); /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + /// public virtual async Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToStreamAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, user, options).ConfigureAwait(false); /// diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs index e57051e80..08b976bfe 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs @@ -100,6 +100,9 @@ namespace Discord.WebSocket public async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options).ConfigureAwait(false); /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + /// public async Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => await ChannelHelper.CreateInviteToStreamAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, user, options).ConfigureAwait(false); /// diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index 03c655a34..697d5fe82 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -1144,22 +1144,29 @@ namespace Discord.WebSocket } return null; } - internal void PurgeGuildUserCache() + + /// + /// Purges this guild's user cache. + /// + public void PurgeUserCache() => PurgeUserCache(_ => true); + /// + /// Purges this guild's user cache. + /// + /// The predicate used to select which users to clear. + public void PurgeUserCache(Func predicate) { - var members = Users; - var self = CurrentUser; - _members.Clear(); - if (self != null) - _members.TryAdd(self.Id, self); + var membersToPurge = Users.Where(x => predicate.Invoke(x) && x?.Id != Discord.CurrentUser.Id); + var membersToKeep = Users.Where(x => !predicate.Invoke(x) || x?.Id == Discord.CurrentUser.Id); + + foreach (var member in membersToPurge) + if(_members.TryRemove(member.Id, out _)) + member.GlobalUser.RemoveRef(Discord); + + foreach (var member in membersToKeep) + _members.TryAdd(member.Id, member); _downloaderPromise = new TaskCompletionSource(); DownloadedMemberCount = _members.Count; - - foreach (var member in members) - { - if (member.Id != self?.Id) - member.GlobalUser.RemoveRef(Discord); - } } /// diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs index 3a1ad23b6..525ae0b34 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Linq; using Model = Discord.API.User; -using PresenceModel = Discord.API.Presence; namespace Discord.WebSocket { @@ -48,11 +47,6 @@ namespace Discord.WebSocket } } - internal void Update(ClientState state, PresenceModel model) - { - Presence = SocketPresence.Create(model); - } - private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Global)"; internal new SocketGlobalUser Clone() => MemberwiseClone() as SocketGlobalUser; } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs index 147456cb0..ae3319227 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs @@ -164,8 +164,7 @@ namespace Discord.WebSocket { if (updatePresence) { - Presence = SocketPresence.Create(model); - GlobalUser.Update(state, model); + Update(model); } if (model.Nick.IsSpecified) Nickname = model.Nick.Value; @@ -174,6 +173,13 @@ namespace Discord.WebSocket if (model.PremiumSince.IsSpecified) _premiumSinceTicks = model.PremiumSince.Value?.UtcTicks; } + + internal override void Update(PresenceModel model) + { + Presence.Update(model); + GlobalUser.Update(model); + } + private void UpdateRoles(ulong[] roleIds) { var roles = ImmutableArray.CreateBuilder(roleIds.Length + 1); diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs index fe672a4d6..5250e15ad 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs @@ -11,26 +11,37 @@ namespace Discord.WebSocket /// Represents the WebSocket user's presence status. This may include their online status and their activity. /// [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - public struct SocketPresence : IPresence + public class SocketPresence : IPresence { /// - public UserStatus Status { get; } + public UserStatus Status { get; private set; } /// - public IImmutableSet ActiveClients { get; } + public IReadOnlyCollection ActiveClients { get; private set; } /// - public IImmutableList Activities { get; } + public IReadOnlyCollection Activities { get; private set; } + + internal SocketPresence() { } internal SocketPresence(UserStatus status, IImmutableSet activeClients, IImmutableList activities) { Status = status; ActiveClients = activeClients ?? ImmutableHashSet.Empty; Activities = activities ?? ImmutableList.Empty; } + internal static SocketPresence Create(Model model) { - var clients = ConvertClientTypesDict(model.ClientStatus.GetValueOrDefault()); - var activities = ConvertActivitiesList(model.Activities); - return new SocketPresence(model.Status, clients, activities); + var entity = new SocketPresence(); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Status = model.Status; + ActiveClients = ConvertClientTypesDict(model.ClientStatus.GetValueOrDefault()) ?? ImmutableArray.Empty; + Activities = ConvertActivitiesList(model.Activities) ?? ImmutableArray.Empty; } + /// /// Creates a new containing all of the client types /// where a user is active from the data supplied in the Presence update frame. @@ -42,7 +53,7 @@ namespace Discord.WebSocket /// /// A collection of all s that this user is active. /// - private static IImmutableSet ConvertClientTypesDict(IDictionary clientTypesDict) + private static IReadOnlyCollection ConvertClientTypesDict(IDictionary clientTypesDict) { if (clientTypesDict == null || clientTypesDict.Count == 0) return ImmutableHashSet.Empty; @@ -84,6 +95,6 @@ namespace Discord.WebSocket public override string ToString() => Status.ToString(); private string DebuggerDisplay => $"{Status}{(Activities?.FirstOrDefault()?.Name ?? "")}"; - internal SocketPresence Clone() => this; + internal SocketPresence Clone() => MemberwiseClone() as SocketPresence; } } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs index 025daf29a..b38bd8a4a 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading.Tasks; using Discord.Rest; using Model = Discord.API.User; +using PresenceModel = Discord.API.Presence; namespace Discord.WebSocket { @@ -40,9 +41,9 @@ namespace Discord.WebSocket /// public UserStatus Status => Presence.Status; /// - public IImmutableSet ActiveClients => Presence.ActiveClients ?? ImmutableHashSet.Empty; + public IReadOnlyCollection ActiveClients => Presence.ActiveClients ?? ImmutableHashSet.Empty; /// - public IImmutableList Activities => Presence.Activities ?? ImmutableList.Empty; + public IReadOnlyCollection Activities => Presence.Activities ?? ImmutableList.Empty; /// /// Gets mutual guilds shared with this user. /// @@ -91,6 +92,11 @@ namespace Discord.WebSocket return hasChanges; } + internal virtual void Update(PresenceModel model) + { + Presence.Update(model); + } + /// public async Task CreateDMChannelAsync(RequestOptions options = null) => await UserHelper.CreateDMChannelAsync(this, Discord, options).ConfigureAwait(false); @@ -109,8 +115,8 @@ namespace Discord.WebSocket /// /// The full name of the user. /// - public override string ToString() => $"{Username}#{Discriminator}"; - private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")})"; + public override string ToString() => Format.UsernameAndDiscriminator(this); + private string DebuggerDisplay => $"{Format.UsernameAndDiscriminator(this)} ({Id}{(IsBot ? ", Bot" : "")})"; internal SocketUser Clone() => MemberwiseClone() as SocketUser; } } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs index 6d08a4478..ad0af04b2 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs @@ -214,5 +214,6 @@ namespace Discord public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null) => throw new NotImplementedException(); public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null) => throw new NotImplementedException(); public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) => throw new NotImplementedException(); + public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException(); } } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs index 61a32e391..4514dfc97 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs @@ -50,6 +50,7 @@ namespace Discord } public Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException(); + public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException(); public Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException();