diff --git a/docs/guides/concepts/ratelimits.md b/docs/guides/concepts/ratelimits.md index 3565f1370..afeb5f795 100644 --- a/docs/guides/concepts/ratelimits.md +++ b/docs/guides/concepts/ratelimits.md @@ -1,6 +1,6 @@ # Ratelimits -Ratelimits are a core concept of the discord api, each verified library must follow the ratelimit guidelines. Labs introduces a ratelimit exposure system to help you follow these ratelimits in your own code. +Ratelimits are a core concept of any API - Discords API is no exception. each verified library must follow the ratelimit guidelines. ### Using the ratelimit callback diff --git a/docs/guides/interactions/application-commands/01-getting-started.md b/docs/guides/interactions/application-commands/01-getting-started.md index 089bd8b3b..fc8c8fe30 100644 --- a/docs/guides/interactions/application-commands/01-getting-started.md +++ b/docs/guides/interactions/application-commands/01-getting-started.md @@ -6,7 +6,7 @@ title: Introduction to slash commands # Getting started with application commands. -Welcome! This guide will show you how to use application commands. If you have extra questions that aren't covered here you can come to our [Discord](https://discord.com/invite/dvSfUTet3K) server and ask around there. +Welcome! This guide will show you how to use application commands. ## What is an application command? diff --git a/docs/guides/voice/sending-voice.md b/docs/guides/voice/sending-voice.md index 476f2f42e..555adbca2 100644 --- a/docs/guides/voice/sending-voice.md +++ b/docs/guides/voice/sending-voice.md @@ -18,7 +18,7 @@ when developing on .NET Core, this is where you execute `dotnet run` from; typically the same directory as your csproj). For Windows Users, precompiled binaries are available for your -convienence [here](https://discord.foxbot.me/binaries/). +convienence [here](https://github.com/discord-net/Discord.Net/tree/dev/voice-natives). For Linux Users, you will need to compile [Sodium] and [Opus] from source, or install them from your package manager. 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 db08d0d79..f9552ef4b 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -438,6 +438,13 @@ namespace Discord.Commands _defaultTypeReaders[type] = reader; return reader; } + var underlyingType = Nullable.GetUnderlyingType(type); + if (underlyingType != null && underlyingType.IsEnum) + { + reader = NullableTypeReader.Create(underlyingType, EnumTypeReader.GetReader(underlyingType)); + _defaultTypeReaders[type] = reader; + return reader; + } //Is this an entity? for (int i = 0; i < _entityTypeReaders.Count; i++) @@ -510,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; + } + + /// + /// 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 = searchResult.Commands; + 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 @@ -533,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) { @@ -560,51 +628,27 @@ namespace Discord.Commands } } - parseResultsDict[pair.Key] = parseResult; + parseResults[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; + var weightedParseResults = parseResults + .OrderByDescending(x => CalculateScore(x.Key, x.Value)); - argValuesScore = argValuesSum / match.Command.Parameters.Count; - paramValuesScore = paramValuesSum / match.Command.Parameters.Count; - } - - var totalArgsScore = (argValuesScore + paramValuesScore) / 2; - return match.Command.Priority + totalArgsScore * 0.99f; - } - - //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 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/Readers/TimeSpanTypeReader.cs b/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs index b4a27cb5b..5448553b3 100644 --- a/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs +++ b/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs @@ -6,30 +6,50 @@ namespace Discord.Commands { internal class TimeSpanTypeReader : TypeReader { - private static readonly string[] Formats = { - "%d'd'%h'h'%m'm'%s's'", //4d3h2m1s - "%d'd'%h'h'%m'm'", //4d3h2m - "%d'd'%h'h'%s's'", //4d3h 1s - "%d'd'%h'h'", //4d3h - "%d'd'%m'm'%s's'", //4d 2m1s - "%d'd'%m'm'", //4d 2m - "%d'd'%s's'", //4d 1s - "%d'd'", //4d - "%h'h'%m'm'%s's'", // 3h2m1s - "%h'h'%m'm'", // 3h2m - "%h'h'%s's'", // 3h 1s - "%h'h'", // 3h - "%m'm'%s's'", // 2m1s - "%m'm'", // 2m - "%s's'", // 1s + /// + /// TimeSpan try parse formats. + /// + private static readonly string[] Formats = + { + "%d'd'%h'h'%m'm'%s's'", // 4d3h2m1s + "%d'd'%h'h'%m'm'", // 4d3h2m + "%d'd'%h'h'%s's'", // 4d3h 1s + "%d'd'%h'h'", // 4d3h + "%d'd'%m'm'%s's'", // 4d 2m1s + "%d'd'%m'm'", // 4d 2m + "%d'd'%s's'", // 4d 1s + "%d'd'", // 4d + "%h'h'%m'm'%s's'", // 3h2m1s + "%h'h'%m'm'", // 3h2m + "%h'h'%s's'", // 3h 1s + "%h'h'", // 3h + "%m'm'%s's'", // 2m1s + "%m'm'", // 2m + "%s's'", // 1s }; /// public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) { - return (TimeSpan.TryParseExact(input.ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan)) - ? Task.FromResult(TypeReaderResult.FromSuccess(timeSpan)) - : Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse TimeSpan")); + if (string.IsNullOrEmpty(input)) + throw new ArgumentException(message: $"{nameof(input)} must not be null or empty.", paramName: nameof(input)); + + var isNegative = input[0] == '-'; // Char for CultureInfo.InvariantCulture.NumberFormat.NegativeSign + if (isNegative) + { + input = input.Substring(1); + } + + if (TimeSpan.TryParseExact(input.ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan)) + { + return isNegative + ? Task.FromResult(TypeReaderResult.FromSuccess(-timeSpan)) + : Task.FromResult(TypeReaderResult.FromSuccess(timeSpan)); + } + else + { + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse TimeSpan")); + } } } } 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 8a00e6d0b..fb5903633 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.csproj +++ b/src/Discord.Net.Core/Discord.Net.Core.csproj @@ -26,4 +26,4 @@ - + \ No newline at end of file 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/Channels/IStageChannel.cs b/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs index 935acaab9..5e0be5b7e 100644 --- a/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs @@ -17,7 +17,6 @@ namespace Discord string Topic { get; } /// - /// The of the current stage. /// Gets the of the current stage. /// /// diff --git a/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs b/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs index 570cdaa2f..50e46efa6 100644 --- a/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs @@ -19,12 +19,12 @@ namespace Discord bool HasJoined { get; } /// - /// if the current thread is archived, otherwise . + /// Gets whether or not the current thread is archived. /// bool IsArchived { get; } /// - /// Gets whether or not the current thread is archived. + /// Gets the duration of time before the thread is automatically archived after no activity. /// ThreadArchiveDuration AutoArchiveDuration { get; } diff --git a/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs b/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs index 9d55e713d..0582a3e52 100644 --- a/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs +++ b/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs @@ -1,7 +1,7 @@ namespace Discord { /// - /// Specifies the privacy levels of a Stage instance. + /// Represents the privacy level of a stage. /// public enum StagePrivacyLevel { diff --git a/src/Discord.Net.Core/Entities/Emotes/Emote.cs b/src/Discord.Net.Core/Entities/Emotes/Emote.cs index cd88f97cc..3a8cd7457 100644 --- a/src/Discord.Net.Core/Entities/Emotes/Emote.cs +++ b/src/Discord.Net.Core/Entities/Emotes/Emote.cs @@ -74,6 +74,10 @@ namespace Discord public static bool TryParse(string text, out Emote result) { result = null; + + if (text == null) + return false; + if (text.Length >= 4 && text[0] == '<' && (text[1] == ':' || (text[1] == 'a' && text[2] == ':')) && text[text.Length - 1] == '>') { bool animated = text[1] == 'a'; diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index ebf2ccd4a..c2db435cf 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -313,6 +313,13 @@ namespace Discord /// The approximate number of non-offline members in this guild. /// int? ApproximatePresenceCount { get; } + /// + /// Gets the max bitrate for voice channels in this guild. + /// + /// + /// A representing the maximum bitrate value allowed by Discord in this guild. + /// + int MaxBitrate { get; } /// /// Gets the preferred locale of this guild in IETF BCP 47 diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs index 666d1b2e7..6a908b075 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandOptionChoice.cs @@ -11,7 +11,7 @@ namespace Discord private object _value; /// - /// Gets the name of this choice. + /// Gets or sets the name of this choice. /// public string Name { diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandTypes.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandTypes.cs index b6804deee..8cd31a420 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandTypes.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommandTypes.cs @@ -1,22 +1,22 @@ namespace Discord { /// - /// ApplicationCommandType is enum of current valid Application Command Types: Slash, User, Message + /// Represents the types of application commands. /// public enum ApplicationCommandType : byte { /// - /// ApplicationCommandType.Slash is Slash command type + /// A Slash command type /// Slash = 1, /// - /// ApplicationCommandType.User is Context Menu User command type + /// A Context Menu User command type /// User = 2, /// - /// ApplicationCommandType.Message is Context Menu Message command type + /// A Context Menu Message command type /// Message = 3 } diff --git a/src/Discord.Net.Core/Entities/Interactions/AutocompleteResult.cs b/src/Discord.Net.Core/Entities/Interactions/AutocompleteResult.cs index 6b28a84f9..0603a5a50 100644 --- a/src/Discord.Net.Core/Entities/Interactions/AutocompleteResult.cs +++ b/src/Discord.Net.Core/Entities/Interactions/AutocompleteResult.cs @@ -45,7 +45,7 @@ namespace Discord public object Value { get => _value; - set + set { if (value is not string && !value.IsNumericType()) throw new ArgumentException($"{nameof(value)} must be a numeric type or a string!"); diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionData.cs index 3e04c2cc3..428f20fb6 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionData.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionData.cs @@ -18,7 +18,7 @@ namespace Discord string Name { get; } /// - /// Gets the params + values from the user. + /// Gets the options that the user has provided. /// IReadOnlyCollection Options { get; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionDataOption.cs b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionDataOption.cs index b174ef203..072d2b32b 100644 --- a/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionDataOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/IApplicationCommandInteractionDataOption.cs @@ -26,7 +26,7 @@ namespace Discord ApplicationCommandOptionType Type { get; } /// - /// Gets the options for this command. + /// Gets the nested options of this option. /// IReadOnlyCollection Options { get; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs index ad6540698..ebdf29781 100644 --- a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs @@ -18,18 +18,6 @@ namespace Discord /// Pong = 1, - /// - /// ACK a command without sending a message, eating the user's input. - /// - [Obsolete("This response type has been deprecated by discord. Either use ChannelMessageWithSource or DeferredChannelMessageWithSource", true)] - Acknowledge = 2, - - /// - /// Respond with a message, showing the user's input. - /// - [Obsolete("This response type has been deprecated by discord. Either use ChannelMessageWithSource or DeferredChannelMessageWithSource", true)] - ChannelMessage = 3, - /// /// Respond to an interaction with a message. /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs index fd96d30c6..4461a4205 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs @@ -76,7 +76,7 @@ namespace Discord AddComponent(cmp, row); break; case SelectMenuComponent menu: - WithSelectMenu(menu.CustomId, menu.Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.Default)).ToList(), menu.Placeholder, menu.MinValues, menu.MaxValues, menu.IsDisabled, row); + WithSelectMenu(menu.CustomId, menu.Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)).ToList(), menu.Placeholder, menu.MinValues, menu.MaxValues, menu.IsDisabled, row); break; } } @@ -715,7 +715,7 @@ namespace Discord MinValues = selectMenu.MinValues; IsDisabled = selectMenu.IsDisabled; Options = selectMenu.Options? - .Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.Default)) + .Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)) .ToList(); } @@ -969,7 +969,7 @@ namespace Discord Value = value; Description = description; Emote = emote; - this.IsDefault = isDefault; + IsDefault = isDefault; } /// @@ -981,7 +981,7 @@ namespace Discord Value = option.Value; Description = option.Description; Emote = option.Emote; - IsDefault = option.Default; + IsDefault = option.IsDefault; } /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs index dfea84710..229c1e148 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs @@ -48,7 +48,7 @@ namespace Discord public SelectMenuBuilder ToBuilder() => new SelectMenuBuilder( CustomId, - Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.Default)).ToList(), + Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)).ToList(), Placeholder, MaxValues, MinValues, diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs index 755068b2d..6856e1ee3 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs @@ -6,29 +6,29 @@ namespace Discord public class SelectMenuOption { /// - /// The user-facing name of the option, max 25 characters. + /// Gets the user-facing name of the option. /// public string Label { get; } /// - /// The dev-define value of the option, max 100 characters. + /// Gets the dev-define value of the option. /// public string Value { get; } /// - /// An additional description of the option, max 50 characters. + /// Gets a description of the option. /// public string Description { get; } /// - /// A that will be displayed with this menu option. + /// Gets the displayed with this menu option. /// public IEmote Emote { get; } /// - /// Will render this option as selected by default. + /// Gets whether or not this option will render as selected by default. /// - public bool? Default { get; } + public bool? IsDefault { get; } internal SelectMenuOption(string label, string value, string description, IEmote emote, bool? defaultValue) { @@ -36,7 +36,7 @@ namespace Discord Value = value; Description = description; Emote = emote; - Default = defaultValue; + IsDefault = defaultValue; } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs index 73898ddd0..b4fc89cc2 100644 --- a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -519,7 +519,7 @@ namespace Discord Preconditions.AtLeast(name.Length, 1, nameof(name)); Preconditions.AtMost(name.Length, 100, nameof(name)); - if (value is string str) + if(value is string str) { Preconditions.AtLeast(str.Length, 1, nameof(value)); Preconditions.AtMost(str.Length, 100, nameof(value)); @@ -614,7 +614,7 @@ namespace Discord MinValue = value; return this; } - + /// /// Sets the current builders max value field. /// diff --git a/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs index d76a579d9..45e24b7fa 100644 --- a/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs +++ b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs @@ -110,12 +110,6 @@ namespace Discord /// ManageEmojis = 0x00_40_00_00_00, - /// - /// Allows members to use slash commands in text channels. - /// - [Obsolete("UseSlashCommands has been replaced by UseApplicationCommands", true)] - UseSlashCommands = 0x00_80_00_00_00, - /// /// Allows members to use slash commands in text channels. /// @@ -131,17 +125,6 @@ namespace Discord /// ManageThreads = 0x04_00_00_00_00, - /// - /// Allows for creating and participating in threads - /// - [Obsolete("UsePublicThreads has been replaced by CreatePublicThreads and SendMessagesInThreads", true)] - UsePublicThreads = 0x08_00_00_00_00, - - /// - /// Allows for creating and participating in private threads - /// - [Obsolete("UsePrivateThreads has been replaced by CreatePrivateThreads and SendMessagesInThreads", true)] - UsePrivateThreads = 0x10_00_00_00_00, /// /// Allows for creating public threads. /// diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs index 448fd20b9..5a5827c1d 100644 --- a/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs +++ b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs @@ -176,11 +176,6 @@ namespace Discord /// ManageEmojisAndStickers = 0x40_00_00_00, /// - /// Allows members to use slash commands in text channels. - /// - [Obsolete("UseSlashCommands has been replaced by UseApplicationCommands", true)] - UseSlashCommands = 0x80_00_00_00, - /// /// Allows members to use application commands like slash commands and context menus in text channels. /// UseApplicationCommands = 0x80_00_00_00, @@ -209,16 +204,6 @@ namespace Discord /// CreatePrivateThreads = 0x10_00_00_00_00, /// - /// Allows for creating public threads. - /// - [Obsolete("UsePublicThreads has been replaced by CreatePublicThreads and SendMessagesInThreads", true)] - UsePublicThreads = 0x08_00_00_00_00, - /// - /// Allows for creating private threads. - /// - [Obsolete("UsePrivateThreads has been replaced by CreatePrivateThreads and SendMessagesInThreads", true)] - UsePrivateThreads = 0x10_00_00_00_00, - /// /// Allows the usage of custom stickers from other servers. /// UseExternalStickers = 0x20_00_00_00_00, diff --git a/src/Discord.Net.Core/Entities/Roles/IRole.cs b/src/Discord.Net.Core/Entities/Roles/IRole.cs index 9461dadd3..59ca41e31 100644 --- a/src/Discord.Net.Core/Entities/Roles/IRole.cs +++ b/src/Discord.Net.Core/Entities/Roles/IRole.cs @@ -58,7 +58,7 @@ namespace Discord /// A string containing the hash of this role's icon. /// string Icon { get; } - /// + /// /// Gets the unicode emoji of this role. /// /// 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.Core/Utils/UrlValidation.cs b/src/Discord.Net.Core/Utils/UrlValidation.cs index e5c7eff86..8e877bd4e 100644 --- a/src/Discord.Net.Core/Utils/UrlValidation.cs +++ b/src/Discord.Net.Core/Utils/UrlValidation.cs @@ -5,7 +5,7 @@ namespace Discord.Utils internal static class UrlValidation { /// - /// Not full URL validation right now. Just ensures protocol is present and that it's either http or https + /// Not full URL validation right now. Just ensures protocol is present and that it's either http or https /// should be used for url buttons. /// /// The URL to validate before sending to Discord. @@ -22,7 +22,7 @@ namespace Discord.Utils } /// - /// Not full URL validation right now. Just Ensures the protocol is either http, https, or discord + /// Not full URL validation right now. Just Ensures the protocol is either http, https, or discord /// should be used everything other than url buttons. /// /// The URL to validate before sending to discord. diff --git a/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs b/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs index 8f2424dbc..d0a25a829 100644 --- a/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs +++ b/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs @@ -47,7 +47,7 @@ namespace Discord.API } } - Default = option.Default ?? Optional.Unspecified; + Default = option.IsDefault ?? Optional.Unspecified; } } } diff --git a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs index d925e0108..3d09ad145 100644 --- a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs +++ b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs @@ -11,9 +11,8 @@ namespace Discord.API.Rest { private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; - public Stream File { get; } + public FileAttachment[] Files { get; } - public Optional Filename { get; set; } public Optional Content { get; set; } public Optional Nonce { get; set; } public Optional IsTTS { get; set; } @@ -21,22 +20,16 @@ namespace Discord.API.Rest public Optional AvatarUrl { get; set; } public Optional Embeds { get; set; } public Optional AllowedMentions { get; set; } + public Optional MessageComponents { get; set; } - public bool IsSpoiler { get; set; } = false; - - public UploadWebhookFileParams(Stream file) + public UploadWebhookFileParams(params FileAttachment[] files) { - File = file; + Files = files; } public IReadOnlyDictionary ToDictionary() { var d = new Dictionary(); - var filename = Filename.GetValueOrDefault("unknown.dat"); - if (IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) - filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); - - d["file"] = new MultipartFile(File, filename); var payload = new Dictionary(); if (Content.IsSpecified) @@ -49,11 +42,34 @@ namespace Discord.API.Rest payload["username"] = Username.Value; if (AvatarUrl.IsSpecified) payload["avatar_url"] = AvatarUrl.Value; + if (MessageComponents.IsSpecified) + payload["components"] = MessageComponents.Value; if (Embeds.IsSpecified) payload["embeds"] = Embeds.Value; if (AllowedMentions.IsSpecified) payload["allowed_mentions"] = AllowedMentions.Value; + List attachments = new(); + + for (int n = 0; n != Files.Length; n++) + { + var attachment = Files[n]; + + var filename = attachment.FileName ?? "unknown.dat"; + if (attachment.IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) + filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); + d[$"files[{n}]"] = new MultipartFile(attachment.Stream, filename); + + attachments.Add(new + { + id = (ulong)n, + filename = filename, + description = attachment.Description ?? Optional.Unspecified + }); + } + + payload["attachments"] = attachments; + var json = new StringBuilder(); using (var text = new StringWriter(json)) using (var writer = new JsonTextWriter(text)) diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 32c6dc3d7..abe059c64 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -55,7 +55,7 @@ namespace Discord.API _restClientProvider = restClientProvider; UserAgent = userAgent; DefaultRetryMode = defaultRetryMode; - _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver(), NullValueHandling = NullValueHandling.Include }; + _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; UseSystemClock = useSystemClock; RequestQueue = new RequestQueue(); 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 d687b6c3c..239c00467 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs @@ -21,8 +21,10 @@ namespace Discord.Rest public int? UserLimit { get; private set; } /// public ulong? CategoryId { get; private set; } + /// public string Mention => MentionUtils.MentionChannel(Id); + internal RestVoiceChannel(BaseDiscordClient discord, IGuild guild, ulong id) : base(discord, guild, id) { @@ -76,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/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 9b0b66633..daecb1d8c 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -85,6 +85,20 @@ namespace Discord.Rest public int? ApproximateMemberCount { get; private set; } /// public int? ApproximatePresenceCount { get; private set; } + /// + public int MaxBitrate + { + get + { + return PremiumTier switch + { + PremiumTier.Tier1 => 128000, + PremiumTier.Tier2 => 256000, + PremiumTier.Tier3 => 384000, + _ => 96000, + }; + } + } /// public NsfwLevel NsfwLevel { get; private set; } /// diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs index 3aa377a27..d5c261e0b 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs @@ -47,7 +47,6 @@ namespace Discord.Rest /// public IReadOnlyCollection ChannelTypes { get; private set; } - internal RestApplicationCommandOption() { } internal static RestApplicationCommandOption Create(Model model) diff --git a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs index da5bdd0d5..309500c96 100644 --- a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs +++ b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs @@ -51,6 +51,7 @@ namespace Discord.Rest AllowedMentions allowedMentions = args.AllowedMentions.Value; 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."); + Preconditions.AtMost(args.Embeds.Value?.Length ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds 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) diff --git a/src/Discord.Net.Rest/Entities/Roles/RestRole.cs b/src/Discord.Net.Rest/Entities/Roles/RestRole.cs index 26772fcdc..a2ad4fd77 100644 --- a/src/Discord.Net.Rest/Entities/Roles/RestRole.cs +++ b/src/Discord.Net.Rest/Entities/Roles/RestRole.cs @@ -115,7 +115,6 @@ namespace Discord.Rest throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); } } - #endregion } } 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.Rest/Extensions/EntityExtensions.cs b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs index ca36bc6d6..05bd04e64 100644 --- a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs +++ b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs @@ -71,6 +71,7 @@ namespace Discord.Rest public static API.AllowedMentions ToModel(this AllowedMentions entity) { + if (entity == null) return null; return new API.AllowedMentions() { Parse = entity.AllowedTypes?.EnumerateMentionTypes().ToArray(), diff --git a/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs index 490cc2fef..22363199d 100644 --- a/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs +++ b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs @@ -36,7 +36,6 @@ namespace Discord.Net.Converters return new GuildFeatures(features, experimental.ToArray()); } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { throw new NotImplementedException(); 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..562f10de3 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,35 @@ namespace Discord.WebSocket internal void PurgeUsers() { foreach (var guild in _guilds.Values) - guild.PurgeGuildUserCache(); + guild.PurgeUserCache(); + } + + internal SocketApplicationCommand GetCommand(ulong id) + { + if (_commands.TryGetValue(id, out SocketApplicationCommand command)) + return command; + return null; + } + internal void AddCommand(SocketApplicationCommand command) + { + _commands[command.Id] = command; + } + internal SocketApplicationCommand GetOrAddCommand(ulong id, Func commandFactory) + { + return _commands.GetOrAdd(id, commandFactory); + } + internal SocketApplicationCommand RemoveCommand(ulong id) + { + if (_commands.TryRemove(id, out SocketApplicationCommand command)) + return command; + return null; + } + internal void PurgeCommands(Func precondition) + { + var ids = _commands.Where(x => precondition(x.Value)).Select(x => x.Key); + + foreach (var id in ids) + _commands.TryRemove(id, out var _); } internal SocketApplicationCommand GetCommand(ulong id) diff --git a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj index 2702457f0..18b7785aa 100644 --- a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj +++ b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj @@ -1,4 +1,4 @@ - + diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 307f9a009..1e71ce853 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -495,9 +495,12 @@ namespace Discord.WebSocket client.GuildScheduledEventUserAdd += (arg1, arg2) => _guildScheduledEventUserAdd.InvokeAsync(arg1, arg2); client.GuildScheduledEventUserRemove += (arg1, arg2) => _guildScheduledEventUserRemove.InvokeAsync(arg1, arg2); } -#endregion + #endregion #region IDiscordClient + /// + ISelfUser IDiscordClient.CurrentUser => CurrentUser; + /// async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) => await GetApplicationInfoAsync().ConfigureAwait(false); 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/SocketGroupChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs index 7c9b4be47..1bbfa6e97 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs @@ -34,12 +34,12 @@ namespace Discord.WebSocket public IReadOnlyCollection CachedMessages => _messages?.Messages ?? ImmutableArray.Create(); /// - /// Returns a collection representing all of the users in the group. + /// Returns a collection representing all of the users in the group. /// public new IReadOnlyCollection Users => _users.ToReadOnlyCollection(); /// - /// Returns a collection representing all users in the group, not including the client. + /// Returns a collection representing all users in the group, not including the client. /// public IReadOnlyCollection Recipients => _users.Select(x => x.Value).Where(x => x.Id != Discord.CurrentUser.Id).ToReadOnlyCollection(() => _users.Count - 1); 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 9798272b3..54788c629 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs @@ -21,6 +21,7 @@ namespace Discord.WebSocket public int Bitrate { get; private set; } /// public int? UserLimit { get; private set; } + /// public ulong? CategoryId { get; private set; } /// @@ -31,6 +32,10 @@ namespace Discord.WebSocket /// public ICategoryChannel Category => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; + + /// + public string Mention => MentionUtils.MentionChannel(Id); + /// public string Mention => MentionUtils.MentionChannel(Id); /// @@ -97,6 +102,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 beaab1cfe..697d5fe82 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -185,24 +185,18 @@ namespace Discord.WebSocket return id.HasValue ? GetVoiceChannel(id.Value) : null; } } - /// - /// Gets the max bitrate for voice channels in this guild. - /// - /// - /// A representing the maximum bitrate value allowed by Discord in this guild. - /// + /// public int MaxBitrate { get { - var maxBitrate = PremiumTier switch + return PremiumTier switch { PremiumTier.Tier1 => 128000, PremiumTier.Tier2 => 256000, PremiumTier.Tier3 => 384000, _ => 96000, }; - return maxBitrate; } } /// @@ -1150,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/Invites/SocketInvite.cs b/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs index abc418d86..2b64e170e 100644 --- a/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs +++ b/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs @@ -7,7 +7,7 @@ using Model = Discord.API.Gateway.InviteCreateEvent; namespace Discord.WebSocket { /// - /// Represents a WebSocket-based invite to a guild. + /// Represents a WebSocket-based invite to a guild. /// [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public class SocketInvite : SocketEntity, IInviteMetadata diff --git a/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs b/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs index b2691248d..1e90b8f5c 100644 --- a/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs +++ b/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs @@ -57,7 +57,7 @@ namespace Discord.WebSocket public string Mention => IsEveryone ? "@everyone" : MentionUtils.MentionRole(Id); /// - /// Returns an IEnumerable containing all that have this role. + /// Returns an IEnumerable containing all that have this role. /// public IEnumerable Members => Guild.Users.Where(x => x.Roles.Any(r => r.Id == Id)); 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/src/Discord.Net.Webhook/DiscordWebhookClient.cs b/src/Discord.Net.Webhook/DiscordWebhookClient.cs index a4fdf9179..f7ad7301c 100644 --- a/src/Discord.Net.Webhook/DiscordWebhookClient.cs +++ b/src/Discord.Net.Webhook/DiscordWebhookClient.cs @@ -123,14 +123,35 @@ namespace Discord.Webhook /// Returns the ID of the created message. public Task SendFileAsync(string filePath, string text, bool isTTS = false, IEnumerable embeds = null, string username = null, string avatarUrl = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) - => WebhookClientHelper.SendFileAsync(this, filePath, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, isSpoiler); + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageComponent components = null) + => WebhookClientHelper.SendFileAsync(this, filePath, text, isTTS, embeds, username, avatarUrl, + allowedMentions, options, isSpoiler, components); /// Sends a message to the channel for this webhook with an attachment. /// Returns the ID of the created message. public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, IEnumerable embeds = null, string username = null, string avatarUrl = null, - RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null) - => WebhookClientHelper.SendFileAsync(this, stream, filename, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, isSpoiler); + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageComponent components = null) + => WebhookClientHelper.SendFileAsync(this, stream, filename, text, isTTS, embeds, username, + avatarUrl, allowedMentions, options, isSpoiler, components); + + /// Sends a message to the channel for this webhook with an attachment. + /// Returns the ID of the created message. + public Task SendFileAsync(FileAttachment attachment, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null) + => WebhookClientHelper.SendFileAsync(this, attachment, text, isTTS, embeds, username, + avatarUrl, allowedMentions, components, options); + + /// Sends a message to the channel for this webhook with an attachment. + /// Returns the ID of the created message. + public Task SendFilesAsync(IEnumerable attachments, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null) + => WebhookClientHelper.SendFilesAsync(this, attachments, text, isTTS, embeds, username, avatarUrl, + allowedMentions, components, options); + /// Modifies the properties of this webhook. public Task ModifyWebhookAsync(Action func, RequestOptions options = null) diff --git a/src/Discord.Net.Webhook/WebhookClientHelper.cs b/src/Discord.Net.Webhook/WebhookClientHelper.cs index 6e3651323..8b4bb5d2a 100644 --- a/src/Discord.Net.Webhook/WebhookClientHelper.cs +++ b/src/Discord.Net.Webhook/WebhookClientHelper.cs @@ -97,24 +97,51 @@ namespace Discord.Webhook await client.ApiClient.DeleteWebhookMessageAsync(client.Webhook.Id, messageId, options).ConfigureAwait(false); } public static async Task SendFileAsync(DiscordWebhookClient client, string filePath, string text, bool isTTS, - IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler) + IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler, MessageComponent components) { string filename = Path.GetFileName(filePath); using (var file = File.OpenRead(filePath)) - return await SendFileAsync(client, file, filename, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, isSpoiler).ConfigureAwait(false); + return await SendFileAsync(client, file, filename, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, isSpoiler, components).ConfigureAwait(false); } - public static async Task SendFileAsync(DiscordWebhookClient client, Stream stream, string filename, string text, bool isTTS, - IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler) + public static Task SendFileAsync(DiscordWebhookClient client, Stream stream, string filename, string text, bool isTTS, + IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler, + MessageComponent components) + => SendFileAsync(client, new FileAttachment(stream, filename, isSpoiler: isSpoiler), text, isTTS, embeds, username, avatarUrl, allowedMentions, components, options); + + public static Task SendFileAsync(DiscordWebhookClient client, FileAttachment attachment, string text, bool isTTS, IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, MessageComponent components, RequestOptions options) + => SendFilesAsync(client, new FileAttachment[] { attachment }, text, isTTS, embeds, username, avatarUrl, allowedMentions, components, options); + + public static async Task SendFilesAsync(DiscordWebhookClient client, + IEnumerable attachments, string text, bool isTTS, IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, MessageComponent components, RequestOptions options) { - var args = new UploadWebhookFileParams(stream) { Filename = filename, Content = text, IsTTS = isTTS, IsSpoiler = isSpoiler }; - if (username != null) - args.Username = username; - if (avatarUrl != null) - args.AvatarUrl = avatarUrl; - if (embeds != null) - args.Embeds = embeds.Select(x => x.ToModel()).ToArray(); - if(allowedMentions != null) - args.AllowedMentions = allowedMentions.ToModel(); + embeds ??= Array.Empty(); + + 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."); + Preconditions.AtMost(embeds.Count(), 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // 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 UploadWebhookFileParams(attachments.ToArray()) {AvatarUrl = avatarUrl, Username = username, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; var msg = await client.ApiClient.UploadWebhookFileAsync(client.Webhook.Id, args, options).ConfigureAwait(false); return msg.Id; } diff --git a/src/Discord.Net/Discord.Net.nuspec b/src/Discord.Net/Discord.Net.nuspec index ce49fd392..1803c4a0a 100644 --- a/src/Discord.Net/Discord.Net.nuspec +++ b/src/Discord.Net/Discord.Net.nuspec @@ -47,4 +47,4 @@ - + \ No newline at end of file diff --git a/test/Discord.Net.Tests.Unit/ColorTests.cs b/test/Discord.Net.Tests.Unit/ColorTests.cs index 48a6041e5..46d8feabb 100644 --- a/test/Discord.Net.Tests.Unit/ColorTests.cs +++ b/test/Discord.Net.Tests.Unit/ColorTests.cs @@ -10,7 +10,6 @@ namespace Discord /// public class ColorTests { - [Fact] public void Color_New() { Assert.Equal(0u, new Color().RawValue); 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 159bfb034..4514dfc97 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedVoiceChannel.cs @@ -12,6 +12,8 @@ namespace Discord public int? UserLimit => throw new NotImplementedException(); + public string Mention => throw new NotImplementedException(); + public ulong? CategoryId => throw new NotImplementedException(); public int Position => throw new NotImplementedException(); @@ -25,7 +27,6 @@ namespace Discord public string Name => throw new NotImplementedException(); public DateTimeOffset CreatedAt => throw new NotImplementedException(); - public string Mention => throw new NotImplementedException(); public ulong Id => throw new NotImplementedException(); public Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) @@ -49,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(); diff --git a/test/Discord.Net.Tests.Unit/TimeSpanTypeReaderTests.cs b/test/Discord.Net.Tests.Unit/TimeSpanTypeReaderTests.cs new file mode 100644 index 000000000..4cd9cae09 --- /dev/null +++ b/test/Discord.Net.Tests.Unit/TimeSpanTypeReaderTests.cs @@ -0,0 +1,70 @@ +using Discord.Commands; +using System; +using Xunit; + +namespace Discord +{ + public class TimeSpanTypeReaderTests + { + [Theory] + [InlineData("4d3h2m1s", false)] // tests format "%d'd'%h'h'%m'm'%s's'" + [InlineData("4d3h2m", false)] // tests format "%d'd'%h'h'%m'm'" + [InlineData("4d3h1s", false)] // tests format "%d'd'%h'h'%s's'" + [InlineData("4d3h", false)] // tests format "%d'd'%h'h'" + [InlineData("4d2m1s", false)] // tests format "%d'd'%m'm'%s's'" + [InlineData("4d2m", false)] // tests format "%d'd'%m'm'" + [InlineData("4d1s", false)] // tests format "%d'd'%s's'" + [InlineData("4d", false)] // tests format "%d'd'" + [InlineData("3h2m1s", false)] // tests format "%h'h'%m'm'%s's'" + [InlineData("3h2m", false)] // tests format "%h'h'%m'm'" + [InlineData("3h1s", false)] // tests format "%h'h'%s's'" + [InlineData("3h", false)] // tests format "%h'h'" + [InlineData("2m1s", false)] // tests format "%m'm'%s's'" + [InlineData("2m", false)] // tests format "%m'm'" + [InlineData("1s", false)] // tests format "%s's'" + // Negatives + [InlineData("-4d3h2m1s", true)] // tests format "-%d'd'%h'h'%m'm'%s's'" + [InlineData("-4d3h2m", true)] // tests format "-%d'd'%h'h'%m'm'" + [InlineData("-4d3h1s", true)] // tests format "-%d'd'%h'h'%s's'" + [InlineData("-4d3h", true)] // tests format "-%d'd'%h'h'" + [InlineData("-4d2m1s", true)] // tests format "-%d'd'%m'm'%s's'" + [InlineData("-4d2m", true)] // tests format "-%d'd'%m'm'" + [InlineData("-4d1s", true)] // tests format "-%d'd'%s's'" + [InlineData("-4d", true)] // tests format "-%d'd'" + [InlineData("-3h2m1s", true)] // tests format "-%h'h'%m'm'%s's'" + [InlineData("-3h2m", true)] // tests format "-%h'h'%m'm'" + [InlineData("-3h1s", true)] // tests format "-%h'h'%s's'" + [InlineData("-3h", true)] // tests format "-%h'h'" + [InlineData("-2m1s", true)] // tests format "-%m'm'%s's'" + [InlineData("-2m", true)] // tests format "-%m'm'" + [InlineData("-1s", true)] // tests format "-%s's'" + public void TestTimeSpanParse(string input, bool isNegative) + { + var reader = new TimeSpanTypeReader(); + var result = reader.ReadAsync(null, input, null).Result; + Assert.True(result.IsSuccess); + + var actual = (TimeSpan)result.BestMatch; + Assert.True(actual != TimeSpan.Zero); + + if (isNegative) + { + Assert.True(actual < TimeSpan.Zero); + + Assert.True(actual.Seconds == 0 || actual.Seconds == -1); + Assert.True(actual.Minutes == 0 || actual.Minutes == -2); + Assert.True(actual.Hours == 0 || actual.Hours == -3); + Assert.True(actual.Days == 0 || actual.Days == -4); + } + else + { + Assert.True(actual > TimeSpan.Zero); + + Assert.True(actual.Seconds == 0 || actual.Seconds == 1); + Assert.True(actual.Minutes == 0 || actual.Minutes == 2); + Assert.True(actual.Hours == 0 || actual.Hours == 3); + Assert.True(actual.Days == 0 || actual.Days == 4); + } + } + } +} diff --git a/voice-natives/README.md b/voice-natives/README.md new file mode 100644 index 000000000..a89fad45f --- /dev/null +++ b/voice-natives/README.md @@ -0,0 +1,12 @@ +# Voice binaries + +These binaries were taken from the [DSharpPlus](https://dsharpplus.github.io/natives/index.html) website and are temporary until we resolve the old url for them. + +**NOTE**: You need to rename libopus.dll to opus.dll before use, otherwise audio client will complain about missing libraries. + +#### Licenses + +| Library | License | +| :-------: | :-------------------------------------------------------- | +| Opus | https://opus-codec.org/license/ | +| libsodium | https://github.com/jedisct1/libsodium/blob/master/LICENSE | diff --git a/voice-natives/vnext_natives_win32_x64.zip b/voice-natives/vnext_natives_win32_x64.zip new file mode 100644 index 000000000..a447803e5 Binary files /dev/null and b/voice-natives/vnext_natives_win32_x64.zip differ diff --git a/voice-natives/vnext_natives_win32_x86.zip b/voice-natives/vnext_natives_win32_x86.zip new file mode 100644 index 000000000..35522e1ec Binary files /dev/null and b/voice-natives/vnext_natives_win32_x86.zip differ