diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..0388cb239 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# Changelog + +## [Unreleased] +### Added + +- #747: `CommandService` now has a `CommandExecuted` event (e991715) +- #765: Parameters may have a name specified via `NameAttribute` (9c81ab9) +- #773: Both socket clients inherit from `BaseSocketClient` (9b7afec) +- #785: Primitives now automatically load a NullableTypeReader (cb0ff78) +- #819: Support for Welcome Message channels (30e867a) +- #835: Emoji may now be managed from a bot (b4bf046) +- #843: Webhooks may now be managed from a bot (7b2ddd0) +- #863: An embed may be converted to an `EmbedBuilder` using the `.ToEmbedBuilder()` method (5218e6b) +- #877: Support for reading rich presences (34b4e5a) +- #888: Users may now opt-in to using a proxy (678a723) +- #906: API Analyzers to assist users when writing their bot (f69ef2a) +- #907: Full support for channel categories (030422f) +- #913: Animated emoji may be read and written (a19ff18) +- #915: Unused parameters may be discarded, rather than failing the command (5f46aef) +- #929: Standard EqualityComparers for use in LINQ operations with the library's entities (b5e7548) +- 'html' variant added to the `EmbedType` enum (42c879c) + +### Fixed +- #742: `DiscordShardedClient#GetGuildFor` will now direct null guilds to Shard 0 (d5e9d6f) +- #743: Various issues with permissions and inheritance of permissions (f996338) +- #755: `IRole.Mention` will correctly tag the @everyone role (6b5a6e7) +- #768: `CreateGuildAsync` will include the icon stream (865080a) +- #866: Revised permissions constants and behavior (dec7cb2) +- #872: Bulk message deletion should no longer fail for incomplete batch sizes (804d918) +- #923: A null value should properly reset a user's nickname (227f61a) +- #938: The reconnect handler should no longer deadlock during Discord outages (73ac9d7) +- Ignore messages with no ID in bulk delete (676be40) + +### Changed +- #731: `IUserMessage#GetReactionUsersAsync` now takes an `IEmote` instead of a `string` (5d7f2fc) +- #744: IAsyncEnumerable has been redesigned (5bbd9bb) +- #777: `IGuild#DefaultChannel` will now resolve the first accessible channel, per changes to Discord (1ffcd4b) +- #781: Attempting to add or remove a member's EveryoneRole will throw (506a6c9) +- #801: `EmbedBuilder` will no longer implicitly convert to `Embed`, you must build manually (94f7dd2) +- #804: Command-related tasks will have the 'async' suffix (14fbe40) +- #812: The WebSocket4Net provider has been bumped to version 0.15, allowing support for .NET Standard apps (e25054b) +- #829: DeleteMessagesAsync moved from IMessageChannel to ITextChannel (e00f17f) +- #853: WebSocket will now use `zlib-stream` compression (759db34) +- #874: The `ReadMessages` permission is moving to `ViewChannel` (edfbd05) +- #877: Refactored Games into Activities (34b4e5a) +- `IGuildChannel#Nsfw` moved to `ITextChannel`, now maps to the API property (608bc35) +- Preemptive ratelimits are now logged under verbose, rather than warning. (3c1e766) +- The default InviteAge when creating Invites is now 24 hours (9979a02) + + +### Removed +- #790: Redundant overloads for `AddField` removed from EmbedBuilder (479361b) +- #925: RPC is no longer being maintained nor packaged (b30af57) +- User logins (including selfbots) are no longer supported (fc5adca) + +### Misc +- This project is now licensed to the Discord.Net contributors (710e182) +- #786: Unit tests for the Color structure (22b969c) +- #828: We now include a contributing guide (cd82a0f) +- #876: We now include a standard editorconfig (5c8c784) + +## [1.0.2] - 2017-09-09 +### Fixed + +- Guilds utilizing Channel Categories will no longer crash bots on the `READY` event. + +## [1.0.1] - 2017-07-05 +### Fixed + +- #732: Fixed parameter preconditions not being loaded from class-based modules (b6dcc9e) +- #726: Fixed CalculateScore throwing an ArgumentException for missing parameters (7597cf5) +- EmbedBuilder URI validation should no longer throw NullReferenceExceptions in certain edge cases (d89804d) +- Fixed module auto-detection for nested modules (d2afb06) + +### Changed +- ShardedCommandContext now inherits from SocketCommandContext (8cd99be) diff --git a/Discord.Net.sln b/Discord.Net.sln index 9bb940d8c..245515c7c 100644 --- a/Discord.Net.sln +++ b/Discord.Net.sln @@ -26,10 +26,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord.Net.Analyzers", "sr EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{BB59D5B5-E7B0-4BF4-8F82-D14431B2799B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "01_basic_ping_bot", "samples\01_basic_ping_bot\01_basic_ping_bot.csproj", "{F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "01_basic_ping_bot", "samples\01_basic_ping_bot\01_basic_ping_bot.csproj", "{F2FF84FB-F6AD-47E5-9EE5-18206CAE136E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "02_commands_framework", "samples\02_commands_framework\02_commands_framework.csproj", "{4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "03_sharded_client", "samples\03_sharded_client\03_sharded_client.csproj", "{9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -160,6 +162,18 @@ Global {4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76}.Release|x64.Build.0 = Release|Any CPU {4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76}.Release|x86.ActiveCfg = Release|Any CPU {4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76}.Release|x86.Build.0 = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|x64.Build.0 = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Debug|x86.Build.0 = Debug|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|Any CPU.Build.0 = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|x64.ActiveCfg = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|x64.Build.0 = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|x86.ActiveCfg = Release|Any CPU + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -173,6 +187,7 @@ Global {BBA8E7FB-C834-40DC-822F-B112CB7F0140} = {CC3D4B1C-9DE0-448B-8AE7-F3F1F3EC5C3A} {F2FF84FB-F6AD-47E5-9EE5-18206CAE136E} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} {4E1F1F40-B1DD-40C9-A4B1-A2046A4C9C76} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} + {9B4C4AFB-3D15-44C6-9E36-12ED625AAA26} = {BB59D5B5-E7B0-4BF4-8F82-D14431B2799B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D2404771-EEC8-45F2-9D71-F3373F6C1495} diff --git a/docs/faq/basics/client-basics.md b/docs/faq/basics/client-basics.md index 5c529a3f5..9377ac2e9 100644 --- a/docs/faq/basics/client-basics.md +++ b/docs/faq/basics/client-basics.md @@ -63,4 +63,32 @@ use the cached message entity. Read more about it [here](xref:Guides.Concepts.Ev [MessageCacheSize]: xref:Discord.WebSocket.DiscordSocketConfig.MessageCacheSize [DiscordSocketConfig]: xref:Discord.WebSocket.DiscordSocketConfig -[MessageUpdated]: xref:Discord.WebSocket.BaseSocketClient.MessageUpdated \ No newline at end of file +[MessageUpdated]: xref:Discord.WebSocket.BaseSocketClient.MessageUpdated + +## What is a shard/sharded client, and how is it different from the `DiscordSocketClient`? +As your bot grows in popularity, it is recommended that you should section your bot off into separate processes. +The [DiscordShardedClient] is essentially a class that allows you to easily create and manage multiple [DiscordSocketClient] +instances, with each one serving a different amount of guilds. + +There are very few differences from the [DiscordSocketClient] class, and it is very straightforward +to modify your existing code to use a [DiscordShardedClient] when necessary. + +1. You need to specify the total amount of shards, or shard ids, via [DiscordShardedClient]'s constructors. +2. The [Connected], [Disconnected], [Ready], and [LatencyUpdated] events + are replaced with [ShardConnected], [ShardDisconnected], [ShardReady], and [ShardLatencyUpdated]. +3. Every event handler you apply/remove to the [DiscordShardedClient] is applied/removed to each shard. + If you wish to control a specific shard's events, you can access an individual shard through the `Shards` property. + +If you do not wish to use the [DiscordShardedClient] and instead reuse the same [DiscordSocketClient] code and manually shard them, +you can do so by specifying the [ShardId] for the [DiscordSocketConfig] and pass that to the [DiscordSocketClient]'s constructor. + +[DiscordSocketClient]: xref:Discord.WebSocket.DiscordSocketClient +[DiscordShardedClient]: xref:Discord.WebSocket.DiscordShardedClient +[Connected]: xref:Discord.WebSocket.DiscordSocketClient.Connected +[Disconnected]: xref:Discord.WebSocket.DiscordSocketClient.Disconnected +[LatencyUpdated]: xref:Discord.WebSocket.DiscordSocketClient.LatencyUpdated +[ShardConnected]: xref:Discord.WebSocket.DiscordShardedClient.ShardConnected +[ShardDisconnected]: xref:Discord.WebSocket.DiscordShardedClient.ShardDisconnected +[ShardReady]: xref:Discord.WebSocket.DiscordShardedClient.ShardReady +[ShardLatencyUpdated]: xref:Discord.WebSocket.DiscordShardedClient.ShardLatencyUpdated +[ShardId]: xref:Discord.WebSocket.DiscordSocketConfig.ShardId diff --git a/docs/guides/commands/samples/intro/command_handler.cs b/docs/guides/commands/samples/intro/command_handler.cs index e4531fa41..efb31f9b9 100644 --- a/docs/guides/commands/samples/intro/command_handler.cs +++ b/docs/guides/commands/samples/intro/command_handler.cs @@ -35,9 +35,10 @@ public class CommandHandler // Create a number to track where the prefix ends and the command begins int argPos = 0; - // Determine if the message is a command based on the prefix + // Determine if the message is a command based on the prefix and make sure no bots trigger commands if (!(message.HasCharPrefix('!', ref argPos) || - message.HasMentionPrefix(_client.CurrentUser, ref argPos))) + message.HasMentionPrefix(_client.CurrentUser, ref argPos)) || + message.Author.IsBot) return; // Create a WebSocket-based command context based on the message @@ -60,4 +61,4 @@ public class CommandHandler // if (!result.IsSuccess) // await context.Channel.SendMessageAsync(result.ErrorReason); } -} \ No newline at end of file +} diff --git a/docs/guides/commands/samples/post-execution/command_executed_demo.cs b/docs/guides/commands/samples/post-execution/command_executed_demo.cs index 8d8fb911b..b87f4ef06 100644 --- a/docs/guides/commands/samples/post-execution/command_executed_demo.cs +++ b/docs/guides/commands/samples/post-execution/command_executed_demo.cs @@ -27,7 +27,7 @@ public async Task HandleCommandAsync(SocketMessage msg) var message = messageParam as SocketUserMessage; if (message == null) return; int argPos = 0; - if (!(message.HasCharPrefix('!', ref argPos) || message.HasMentionPrefix(_client.CurrentUser, ref argPos))) return; + if (!(message.HasCharPrefix('!', ref argPos) || message.HasMentionPrefix(_client.CurrentUser, ref argPos)) || message.Author.IsBot) return; var context = new SocketCommandContext(_client, message); var result = await _commands.ExecuteAsync(context, argPos, _services); // Optionally, you may pass the result manually into your @@ -35,4 +35,4 @@ public async Task HandleCommandAsync(SocketMessage msg) // precondition failures in the same method. // await OnCommandExecutedAsync(null, context, result); -} \ No newline at end of file +} diff --git a/docs/guides/voice/samples/joining_audio.cs b/docs/guides/voice/samples/joining_audio.cs index 4cec67540..8803d3596 100644 --- a/docs/guides/voice/samples/joining_audio.cs +++ b/docs/guides/voice/samples/joining_audio.cs @@ -1,9 +1,10 @@ -[Command("join")] +// The command's Run Mode MUST be set to RunMode.Async, otherwise, being connected to a voice channel will block the gateway thread. +[Command("join", RunMode = RunMode.Async)] public async Task JoinChannel(IVoiceChannel channel = null) { // Get the audio channel - channel = channel ?? (msg.Author as IGuildUser)?.VoiceChannel; - if (channel == null) { await msg.Channel.SendMessageAsync("User must be in a voice channel, or a voice channel must be passed as an argument."); return; } + channel = channel ?? (Context.User as IGuildUser)?.VoiceChannel; + if (channel == null) { await Context.Channel.SendMessageAsync("User must be in a voice channel, or a voice channel must be passed as an argument."); return; } // For the next step with transmitting audio, you would want to pass this Audio Client in to a service. var audioClient = await channel.ConnectAsync(); diff --git a/docs/guides/voice/sending-voice.md b/docs/guides/voice/sending-voice.md index c30805836..476f2f42e 100644 --- a/docs/guides/voice/sending-voice.md +++ b/docs/guides/voice/sending-voice.md @@ -11,16 +11,16 @@ Information is not guaranteed to be accurate. ## Installing -Audio requires two native libraries, `libsodium` and `opus`. -Both of these libraries must be placed in the runtime directory of your -bot. (When developing on .NET Framework, this would be `bin/debug`, -when developing on .NET Core, this is where you execute `dotnet run` +Audio requires two native libraries, `libsodium` and `opus`. +Both of these libraries must be placed in the runtime directory of your +bot. (When developing on .NET Framework, this would be `bin/debug`, +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 +For Windows Users, precompiled binaries are available for your convienence [here](https://discord.foxbot.me/binaries/). -For Linux Users, you will need to compile [Sodium] and [Opus] from +For Linux Users, you will need to compile [Sodium] and [Opus] from source, or install them from your package manager. [Sodium]: https://download.libsodium.org/libsodium/releases/ @@ -28,7 +28,7 @@ source, or install them from your package manager. ## Joining a Channel -Joining a channel is the first step to sending audio, and will return +Joining a channel is the first step to sending audio, and will return an [IAudioClient] to send data with. To join a channel, simply await [ConnectAsync] on any instance of an @@ -36,67 +36,76 @@ To join a channel, simply await [ConnectAsync] on any instance of an [!code-csharp[Joining a Channel](samples/joining_audio.cs)] -The client will sustain a connection to this channel until it is +>[!WARNING] +>Commands which mutate voice states, such as those where you join/leave +>an audio channel, or send audio, should use [RunMode.Async]. RunMode.Async +>is necessary to prevent a feedback loop which will deadlock clients +>in their default configuration. If you know that you're running your +>commands in a different task than the gateway task, RunMode.Async is +>not required. + +The client will sustain a connection to this channel until it is kicked, disconnected from Discord, or told to disconnect. -It should be noted that voice connections are created on a per-guild -basis; only one audio connection may be open by the bot in a single -guild. To switch channels within a guild, invoke [ConnectAsync] on +It should be noted that voice connections are created on a per-guild +basis; only one audio connection may be open by the bot in a single +guild. To switch channels within a guild, invoke [ConnectAsync] on another voice channel in the guild. [IAudioClient]: xref:Discord.Audio.IAudioClient [ConnectAsync]: xref:Discord.IAudioChannel.ConnectAsync* +[RunMode.Async]: xref:Discord.Commands.RunMode ## Transmitting Audio ### With FFmpeg -[FFmpeg] is an open source, highly versatile AV-muxing tool. This is +[FFmpeg] is an open source, highly versatile AV-muxing tool. This is the recommended method of transmitting audio. -Before you begin, you will need to have a version of FFmpeg downloaded -and placed somewhere in your PATH (or alongside the bot, in the same -location as libsodium and opus). Windows binaries are available on +Before you begin, you will need to have a version of FFmpeg downloaded +and placed somewhere in your PATH (or alongside the bot, in the same +location as libsodium and opus). Windows binaries are available on [FFmpeg's download page]. [FFmpeg]: https://ffmpeg.org/ [FFmpeg's download page]: https://ffmpeg.org/download.html -First, you will need to create a Process that starts FFmpeg. An -example of how to do this is included below, though it is important +First, you will need to create a Process that starts FFmpeg. An +example of how to do this is included below, though it is important that you return PCM at 48000hz. >[!NOTE] ->As of the time of this writing, Discord.Audio struggles significantly ->with processing audio that is already opus-encoded; you will need to +>As of the time of this writing, Discord.Audio struggles significantly +>with processing audio that is already opus-encoded; you will need to >use the PCM write streams. [!code-csharp[Creating FFmpeg](samples/audio_create_ffmpeg.cs)] -Next, to transmit audio from FFmpeg to Discord, you will need to -pull an [AudioOutStream] from your [IAudioClient]. Since we're using +Next, to transmit audio from FFmpeg to Discord, you will need to +pull an [AudioOutStream] from your [IAudioClient]. Since we're using PCM audio, use [IAudioClient.CreatePCMStream]. -The sample rate argument doesn't particularly matter, so long as it is -a valid rate (120, 240, 480, 960, 1920, or 2880). For the sake of +The sample rate argument doesn't particularly matter, so long as it is +a valid rate (120, 240, 480, 960, 1920, or 2880). For the sake of simplicity, I recommend using 1920. -Channels should be left at `2`, unless you specified a different value +Channels should be left at `2`, unless you specified a different value for `-ac 2` when creating FFmpeg. [AudioOutStream]: xref:Discord.Audio.AudioOutStream [IAudioClient.CreatePCMStream]: xref:Discord.Audio.IAudioClient#Discord_Audio_IAudioClient_CreateDirectPCMStream_Discord_Audio_AudioApplication_System_Nullable_System_Int32__System_Int32_ -Finally, audio will need to be piped from FFmpeg's stdout into your -AudioOutStream. This step can be as complex as you'd like it to be, but -for the majority of cases, you can just use [Stream.CopyToAsync], as +Finally, audio will need to be piped from FFmpeg's stdout into your +AudioOutStream. This step can be as complex as you'd like it to be, but +for the majority of cases, you can just use [Stream.CopyToAsync], as shown below. [Stream.CopyToAsync]: https://msdn.microsoft.com/en-us/library/hh159084(v=vs.110).aspx -If you are implementing a queue for sending songs, it's likely that -you will want to wait for audio to stop playing before continuing on -to the next song. You can await `AudioOutStream.FlushAsync` to wait for +If you are implementing a queue for sending songs, it's likely that +you will want to wait for audio to stop playing before continuing on +to the next song. You can await `AudioOutStream.FlushAsync` to wait for the audio client's internal buffer to clear out. [!code-csharp[Sending Audio](samples/audio_ffmpeg.cs)] diff --git a/samples/02_commands_framework/Services/CommandHandlingService.cs b/samples/02_commands_framework/Services/CommandHandlingService.cs index fc253eed3..ca7af2774 100644 --- a/samples/02_commands_framework/Services/CommandHandlingService.cs +++ b/samples/02_commands_framework/Services/CommandHandlingService.cs @@ -20,6 +20,8 @@ namespace _02_commands_framework.Services _discord = services.GetRequiredService(); _services = services; + _commands.CommandExecuted += CommandExecutedAsync; + _commands.Log += LogAsync; _discord.MessageReceived += MessageReceivedAsync; } @@ -39,11 +41,28 @@ namespace _02_commands_framework.Services if (!message.HasMentionPrefix(_discord.CurrentUser, ref argPos)) return; var context = new SocketCommandContext(_discord, message); - var result = await _commands.ExecuteAsync(context, argPos, _services); + await _commands.ExecuteAsync(context, argPos, _services); // we will handle the result in CommandExecutedAsync + } + + public async Task CommandExecutedAsync(Optional command, ICommandContext context, IResult result) + { + // command is unspecified when there was a search failure (command not found); we don't care about these errors + if (!command.IsSpecified) + return; + + // the command was succesful, we don't care about this result, unless we want to log that a command succeeded. + if (result.IsSuccess) + return; + + // the command failed, let's notify the user that something happened. + await context.Channel.SendMessageAsync($"error: {result.ToString()}"); + } + + private Task LogAsync(LogMessage log) + { + Console.WriteLine(log.ToString()); - if (result.Error.HasValue && - result.Error.Value != CommandError.UnknownCommand) // it's bad practice to send 'unknown command' errors - await context.Channel.SendMessageAsync(result.ToString()); + return Task.CompletedTask; } } } diff --git a/samples/03_sharded_client/03_sharded_client.csproj b/samples/03_sharded_client/03_sharded_client.csproj new file mode 100644 index 000000000..5d76961cd --- /dev/null +++ b/samples/03_sharded_client/03_sharded_client.csproj @@ -0,0 +1,14 @@ + + + + Exe + netcoreapp2.1 + _03_sharded_client + + + + + + + + diff --git a/samples/03_sharded_client/Modules/PublicModule.cs b/samples/03_sharded_client/Modules/PublicModule.cs new file mode 100644 index 000000000..60e57563a --- /dev/null +++ b/samples/03_sharded_client/Modules/PublicModule.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Discord.Commands; + +namespace _03_sharded_client.Modules +{ + // Remember to make your module reference the ShardedCommandContext + public class PublicModule : ModuleBase + { + [Command("info")] + public async Task InfoAsync() + { + var msg = $@"Hi {Context.User}! There are currently {Context.Client.Shards} shards! + This guild is being served by shard number {Context.Client.GetShardFor(Context.Guild).ShardId}"; + await ReplyAsync(msg); + } + } +} diff --git a/samples/03_sharded_client/Program.cs b/samples/03_sharded_client/Program.cs new file mode 100644 index 000000000..048145f9f --- /dev/null +++ b/samples/03_sharded_client/Program.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using _03_sharded_client.Services; +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; + +namespace _03_sharded_client +{ + // This is a minimal example of using Discord.Net's Sharded Client + // The provided DiscordShardedClient class simplifies having multiple + // DiscordSocketClient instances (or shards) to serve a large number of guilds. + class Program + { + private DiscordShardedClient _client; + + static void Main(string[] args) + => new Program().MainAsync().GetAwaiter().GetResult(); + public async Task MainAsync() + { + // You specify the amount of shards you'd like to have with the + // DiscordSocketConfig. Generally, it's recommended to + // have 1 shard per 1500-2000 guilds your bot is in. + var config = new DiscordSocketConfig + { + TotalShards = 2 + }; + + _client = new DiscordShardedClient(config); + var services = ConfigureServices(); + + // The Sharded Client does not have a Ready event. + // The ShardReady event is used instead, allowing for individual + // control per shard. + _client.ShardReady += ReadyAsync; + _client.Log += LogAsync; + + await services.GetRequiredService().InitializeAsync(); + + await _client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("token")); + await _client.StartAsync(); + + await Task.Delay(-1); + } + + private IServiceProvider ConfigureServices() + { + return new ServiceCollection() + .AddSingleton(_client) + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + } + + + private Task ReadyAsync(DiscordSocketClient shard) + { + Console.WriteLine($"Shard Number {shard.ShardId} is connected and ready!"); + return Task.CompletedTask; + } + + private Task LogAsync(LogMessage log) + { + Console.WriteLine(log.ToString()); + return Task.CompletedTask; + } + } +} diff --git a/samples/03_sharded_client/Services/CommandHandlingService.cs b/samples/03_sharded_client/Services/CommandHandlingService.cs new file mode 100644 index 000000000..1230cbcff --- /dev/null +++ b/samples/03_sharded_client/Services/CommandHandlingService.cs @@ -0,0 +1,72 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Discord; +using Discord.Commands; +using Discord.WebSocket; + +namespace _03_sharded_client.Services +{ + public class CommandHandlingService + { + private readonly CommandService _commands; + private readonly DiscordShardedClient _discord; + private readonly IServiceProvider _services; + + public CommandHandlingService(IServiceProvider services) + { + _commands = services.GetRequiredService(); + _discord = services.GetRequiredService(); + _services = services; + + _commands.CommandExecuted += CommandExecutedAsync; + _commands.Log += LogAsync; + _discord.MessageReceived += MessageReceivedAsync; + } + + public async Task InitializeAsync() + { + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + } + + public async Task MessageReceivedAsync(SocketMessage rawMessage) + { + // Ignore system messages, or messages from other bots + if (!(rawMessage is SocketUserMessage message)) + return; + if (message.Source != MessageSource.User) + return; + + // This value holds the offset where the prefix ends + var argPos = 0; + if (!message.HasMentionPrefix(_discord.CurrentUser, ref argPos)) + return; + + // A new kind of command context, ShardedCommandContext can be utilized with the commands framework + var context = new ShardedCommandContext(_discord, message); + await _commands.ExecuteAsync(context, argPos, _services); + } + + public async Task CommandExecutedAsync(Optional command, ICommandContext context, IResult result) + { + // command is unspecified when there was a search failure (command not found); we don't care about these errors + if (!command.IsSpecified) + return; + + // the command was succesful, we don't care about this result, unless we want to log that a command succeeded. + if (result.IsSuccess) + return; + + // the command failed, let's notify the user that something happened. + await context.Channel.SendMessageAsync($"error: {result.ToString()}"); + } + + private Task LogAsync(LogMessage log) + { + Console.WriteLine(log.ToString()); + + return Task.CompletedTask; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs b/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs new file mode 100644 index 000000000..a43286110 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Instructs the command system to treat command paramters of this type + /// as a collection of named arguments matching to its properties. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class NamedArgumentTypeAttribute : Attribute { } +} diff --git a/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs b/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs index 85f5df10e..a44dcb6e4 100644 --- a/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs +++ b/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs @@ -1,5 +1,4 @@ using System; - using System.Reflection; namespace Discord.Commands @@ -27,8 +26,8 @@ namespace Discord.Commands /// => ReplyAsync(time); /// /// - [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] - public class OverrideTypeReaderAttribute : Attribute + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class OverrideTypeReaderAttribute : Attribute { private static readonly TypeInfo TypeReaderTypeInfo = typeof(TypeReader).GetTypeInfo(); diff --git a/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs index 3b71c87b0..aec8dcbe3 100644 --- a/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs +++ b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs @@ -280,7 +280,7 @@ namespace Discord.Commands } } - private static TypeReader GetTypeReader(CommandService service, Type paramType, Type typeReaderType, IServiceProvider services) + internal static TypeReader GetTypeReader(CommandService service, Type paramType, Type typeReaderType, IServiceProvider services) { var readers = service.GetTypeReaders(paramType); TypeReader reader = null; diff --git a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs index 8a59c247c..4ad5bfac0 100644 --- a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs +++ b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs @@ -56,11 +56,36 @@ namespace Discord.Commands.Builders private TypeReader GetReader(Type type) { - var readers = Command.Module.Service.GetTypeReaders(type); + var commands = Command.Module.Service; + if (type.GetTypeInfo().GetCustomAttribute() != null) + { + IsRemainder = true; + var reader = commands.GetTypeReaders(type)?.FirstOrDefault().Value; + if (reader == null) + { + Type readerType; + try + { + readerType = typeof(NamedArgumentTypeReader<>).MakeGenericType(new[] { type }); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Parameter type '{type.Name}' for command '{Command.Name}' must be a class with a public parameterless constructor to use as a NamedArgumentType.", ex); + } + + reader = (TypeReader)Activator.CreateInstance(readerType, new[] { commands }); + commands.AddTypeReader(type, reader); + } + + return reader; + } + + + var readers = commands.GetTypeReaders(type); if (readers != null) return readers.FirstOrDefault().Value; else - return Command.Module.Service.GetDefaultTypeReader(type); + return commands.GetDefaultTypeReader(type); } public ParameterBuilder WithSummary(string summary) diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index 11f4ce276..d5b3f9ff4 100644 --- a/src/Discord.Net.Commands/CommandService.cs +++ b/src/Discord.Net.Commands/CommandService.cs @@ -49,8 +49,8 @@ namespace Discord.Commands /// Should the command encounter any of the aforementioned error, this event will not be raised. /// /// - public event Func CommandExecuted { add { _commandExecutedEvent.Add(value); } remove { _commandExecutedEvent.Remove(value); } } - internal readonly AsyncEvent> _commandExecutedEvent = new AsyncEvent>(); + public event Func, ICommandContext, IResult, Task> CommandExecuted { add { _commandExecutedEvent.Add(value); } remove { _commandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent, ICommandContext, IResult, Task>> _commandExecutedEvent = new AsyncEvent, ICommandContext, IResult, Task>>(); private readonly SemaphoreSlim _moduleLock; private readonly ConcurrentDictionary _typedModuleDefs; @@ -512,7 +512,11 @@ namespace Discord.Commands var searchResult = Search(input); if (!searchResult.IsSuccess) + { + await _commandExecutedEvent.InvokeAsync(Optional.Create(), context, searchResult).ConfigureAwait(false); return searchResult; + } + var commands = searchResult.Commands; var preconditionResults = new Dictionary(); @@ -532,6 +536,8 @@ namespace Discord.Commands 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; } @@ -589,12 +595,17 @@ namespace Discord.Commands //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; } //If we get this far, at least one parse was successful. Execute the most likely overload. var chosenOverload = successfulParses[0]; - return await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services).ConfigureAwait(false); + var result = await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services).ConfigureAwait(false); + if (!result.IsSuccess && !(result is RuntimeResult)) // succesful results raise the event in CommandInfo#ExecuteInternalAsync (have to raise it there b/c deffered execution) + await _commandExecutedEvent.InvokeAsync(chosenOverload.Key.Command, context, result); + return result; } } } diff --git a/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs b/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs new file mode 100644 index 000000000..675a9073d --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + public static class CommandServiceExtensions + { + public static async Task> GetExecutableCommandsAsync(this ICollection commands, ICommandContext context, IServiceProvider provider) + { + var executableCommands = new List(); + + var tasks = commands.Select(async c => + { + var result = await c.CheckPreconditionsAsync(context, provider).ConfigureAwait(false); + return new { Command = c, PreconditionResult = result }; + }); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (var result in results) + { + if (result.PreconditionResult.IsSuccess) + executableCommands.Add(result.Command); + } + + return executableCommands; + } + public static Task> GetExecutableCommandsAsync(this CommandService commandService, ICommandContext context, IServiceProvider provider) + => GetExecutableCommandsAsync(commandService.Commands.ToArray(), context, provider); + public static async Task> GetExecutableCommandsAsync(this ModuleInfo module, ICommandContext context, IServiceProvider provider) + { + var executableCommands = new List(); + + executableCommands.AddRange(await module.Commands.ToArray().GetExecutableCommandsAsync(context, provider).ConfigureAwait(false)); + + var tasks = module.Submodules.Select(async s => await s.GetExecutableCommandsAsync(context, provider).ConfigureAwait(false)); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + executableCommands.AddRange(results.SelectMany(c => c)); + + return executableCommands; + } + } +} diff --git a/src/Discord.Net.Commands/Info/CommandInfo.cs b/src/Discord.Net.Commands/Info/CommandInfo.cs index d27a1ed7b..a8aa3157c 100644 --- a/src/Discord.Net.Commands/Info/CommandInfo.cs +++ b/src/Discord.Net.Commands/Info/CommandInfo.cs @@ -272,6 +272,10 @@ namespace Discord.Commands var wrappedEx = new CommandException(this, context, ex); await Module.Service._cmdLogger.ErrorAsync(wrappedEx).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + if (Module.Service._throwOnError) { if (ex == originalEx) @@ -280,7 +284,7 @@ namespace Discord.Commands ExceptionDispatchInfo.Capture(ex).Throw(); } - return ExecuteResult.FromError(CommandError.Exception, ex.Message); + return result; } finally { diff --git a/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs b/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs new file mode 100644 index 000000000..01559293f --- /dev/null +++ b/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal sealed class NamedArgumentTypeReader : TypeReader + where T : class, new() + { + private static readonly IReadOnlyDictionary _tProps = typeof(T).GetTypeInfo().DeclaredProperties + .Where(p => p.SetMethod != null && p.SetMethod.IsPublic && !p.SetMethod.IsStatic) + .ToImmutableDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + + private readonly CommandService _commands; + + public NamedArgumentTypeReader(CommandService commands) + { + _commands = commands; + } + + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var result = new T(); + var state = ReadState.LookingForParameter; + int beginRead = 0, currentRead = 0; + + while (state != ReadState.End) + { + try + { + var prop = Read(out var arg); + var propVal = await ReadArgumentAsync(prop, arg).ConfigureAwait(false); + if (propVal != null) + prop.SetMethod.Invoke(result, new[] { propVal }); + else + return TypeReaderResult.FromError(CommandError.ParseFailed, $"Could not parse the argument for the parameter '{prop.Name}' as type '{prop.PropertyType}'."); + } + catch (Exception ex) + { + //TODO: use the Exception overload after a rebase on latest + return TypeReaderResult.FromError(CommandError.Exception, ex.Message); + } + } + + return TypeReaderResult.FromSuccess(result); + + PropertyInfo Read(out string arg) + { + string currentParam = null; + char match = '\0'; + + for (; currentRead < input.Length; currentRead++) + { + var currentChar = input[currentRead]; + switch (state) + { + case ReadState.LookingForParameter: + if (Char.IsWhiteSpace(currentChar)) + continue; + else + { + beginRead = currentRead; + state = ReadState.InParameter; + } + break; + case ReadState.InParameter: + if (currentChar != ':') + continue; + else + { + currentParam = input.Substring(beginRead, currentRead - beginRead); + state = ReadState.LookingForArgument; + } + break; + case ReadState.LookingForArgument: + if (Char.IsWhiteSpace(currentChar)) + continue; + else + { + beginRead = currentRead; + state = (QuotationAliasUtils.GetDefaultAliasMap.TryGetValue(currentChar, out match)) + ? ReadState.InQuotedArgument + : ReadState.InArgument; + } + break; + case ReadState.InArgument: + if (!Char.IsWhiteSpace(currentChar)) + continue; + else + return GetPropAndValue(out arg); + case ReadState.InQuotedArgument: + if (currentChar != match) + continue; + else + return GetPropAndValue(out arg); + } + } + + if (currentParam == null) + throw new InvalidOperationException("No parameter name was read."); + + return GetPropAndValue(out arg); + + PropertyInfo GetPropAndValue(out string argv) + { + bool quoted = state == ReadState.InQuotedArgument; + state = (currentRead == (quoted ? input.Length - 1 : input.Length)) + ? ReadState.End + : ReadState.LookingForParameter; + + if (quoted) + { + argv = input.Substring(beginRead + 1, currentRead - beginRead - 1).Trim(); + currentRead++; + } + else + argv = input.Substring(beginRead, currentRead - beginRead); + + return _tProps[currentParam]; + } + } + + async Task ReadArgumentAsync(PropertyInfo prop, string arg) + { + var elemType = prop.PropertyType; + bool isCollection = false; + if (elemType.GetTypeInfo().IsGenericType && elemType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + elemType = prop.PropertyType.GenericTypeArguments[0]; + isCollection = true; + } + + var overridden = prop.GetCustomAttribute(); + var reader = (overridden != null) + ? ModuleClassBuilder.GetTypeReader(_commands, elemType, overridden.TypeReader, services) + : (_commands.GetDefaultTypeReader(elemType) + ?? _commands.GetTypeReaders(elemType).FirstOrDefault().Value); + + if (reader != null) + { + if (isCollection) + { + var method = _readMultipleMethod.MakeGenericMethod(elemType); + var task = (Task)method.Invoke(null, new object[] { reader, context, arg.Split(','), services }); + return await task.ConfigureAwait(false); + } + else + return await ReadSingle(reader, context, arg, services).ConfigureAwait(false); + } + return null; + } + } + + private static async Task ReadSingle(TypeReader reader, ICommandContext context, string arg, IServiceProvider services) + { + var readResult = await reader.ReadAsync(context, arg, services).ConfigureAwait(false); + return (readResult.IsSuccess) + ? readResult.BestMatch + : null; + } + private static async Task ReadMultiple(TypeReader reader, ICommandContext context, IEnumerable args, IServiceProvider services) + { + var objs = new List(); + foreach (var arg in args) + { + var read = await ReadSingle(reader, context, arg.Trim(), services).ConfigureAwait(false); + if (read != null) + objs.Add((TObj)read); + } + return objs.ToImmutableArray(); + } + private static readonly MethodInfo _readMultipleMethod = typeof(NamedArgumentTypeReader) + .GetTypeInfo() + .DeclaredMethods + .Single(m => m.IsPrivate && m.IsStatic && m.Name == nameof(ReadMultiple)); + + private enum ReadState + { + LookingForParameter, + InParameter, + LookingForArgument, + InArgument, + InQuotedArgument, + End + } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs index 22182a4ca..6719f91d4 100644 --- a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs @@ -25,5 +25,10 @@ namespace Discord /// representing the parent of this channel; null if none is set. /// Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Syncs the permissions of this nested channel with its parent's. + /// + Task SyncPermissionsAsync(RequestOptions options = null); } } diff --git a/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs b/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs new file mode 100644 index 000000000..54c0bdafe --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + public enum ExplicitContentFilterLevel + { + /// No messages will be scanned. + Disabled = 0, + /// Scans messages from all guild members that do not have a role. + /// Recommented option for servers that use roles for trusted membership. + MembersWithoutRoles = 1, + /// Scan messages sent by all guild members. + AllMembers = 2 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs b/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs index 0ffe8db35..e6d21a463 100644 --- a/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs +++ b/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs @@ -66,5 +66,9 @@ namespace Discord /// Gets or sets the ID of the owner of this guild. /// public Optional OwnerId { get; set; } + /// + /// Gets or sets the explicit content filter level of this guild. + /// + public Optional ExplicitContentFilter { get; set; } } } diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index 9b91b9440..a7206bd59 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -53,6 +53,13 @@ namespace Discord /// VerificationLevel VerificationLevel { get; } /// + /// Gets the level of content filtering applied to user's content in a Guild. + /// + /// + /// The level of explicit content filtering. + /// + ExplicitContentFilterLevel ExplicitContentFilter { get; } + /// /// Gets the ID of this guild's icon. /// /// @@ -141,6 +148,13 @@ namespace Discord /// ulong OwnerId { get; } /// + /// Gets the application ID of the guild creator if it is bot-created. + /// + /// + /// A representing the snowflake identifier of the application ID that created this guild, or null if it was not bot-created. + /// + ulong? ApplicationId { get; } + /// /// Gets the ID of the region hosting this guild's voice channels. /// /// @@ -521,6 +535,18 @@ namespace Discord /// Task CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false, RequestOptions options = null); + /// + /// Adds a user to this guild. + /// + /// + /// This method requires you have an OAuth2 access token for the user, requested with the guilds.join scope, and that the bot have the MANAGE_INVITES permission in the guild. + /// + /// The snowflake identifier of the user. + /// The OAuth2 access token for the user, requested with the guilds.join scope. + /// The delegate containing the properties to be applied to the user upon being added to the guild. + /// The options to be used when sending the request. + /// A guild user associated with the specified ; null if the user is already in the guild. + Task AddGuildUserAsync(ulong userId, string accessToken, Action func = null, RequestOptions options = null); /// /// Gets a collection of all users in this guild. /// diff --git a/src/Discord.Net.Core/Entities/Messages/IMessage.cs b/src/Discord.Net.Core/Entities/Messages/IMessage.cs index 87754eecd..33e019419 100644 --- a/src/Discord.Net.Core/Entities/Messages/IMessage.cs +++ b/src/Discord.Net.Core/Entities/Messages/IMessage.cs @@ -100,5 +100,25 @@ namespace Discord /// A read-only collection of user IDs. /// IReadOnlyCollection MentionedUserIds { get; } + /// + /// Returns the Activity associated with a message. + /// + /// + /// Sent with Rich Presence-related chat embeds. + /// + /// + /// A message's activity, if any is associated. + /// + MessageActivity Activity { get; } + /// + /// Returns the Application associated with a messsage. + /// + /// + /// Sent with Rich-Presence-related chat embeds. + /// + /// + /// A message's application, if any is associated. + /// + MessageApplication Application { get; } } } diff --git a/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs b/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs new file mode 100644 index 000000000..b539b8f9b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MessageActivity + { + /// + /// Gets the type of activity of this message. + /// + public MessageActivityType Type { get; internal set; } + /// + /// Gets the party ID of this activity, if any. + /// + public string PartyId { get; internal set; } + + private string DebuggerDisplay + => $"{Type}{(string.IsNullOrWhiteSpace(PartyId) ? "" : $" {PartyId}")}"; + + public override string ToString() => DebuggerDisplay; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs b/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs new file mode 100644 index 000000000..68b99a9c1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public enum MessageActivityType + { + Join = 1, + Spectate = 2, + Listen = 3, + JoinRequest = 5 + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs b/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs new file mode 100644 index 000000000..05616cb59 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MessageApplication + { + /// + /// Gets the snowflake ID of the application. + /// + public ulong Id { get; internal set; } + /// + /// Gets the ID of the embed's image asset. + /// + public string CoverImage { get; internal set; } + /// + /// Gets the application's description. + /// + public string Description { get; internal set; } + /// + /// Gets the ID of the application's icon. + /// + public string Icon { get; internal set; } + /// + /// Gets the Url of the application's icon. + /// + public string IconUrl + => $"https://cdn.discordapp.com/app-icons/{Id}/{Icon}"; + /// + /// Gets the name of the application. + /// + public string Name { get; internal set; } + private string DebuggerDisplay + => $"{Name} ({Id}): {Description}"; + public override string ToString() + => DebuggerDisplay; + } +} diff --git a/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs b/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs new file mode 100644 index 000000000..e380d9027 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Properties that are used to add a new to the guild with the following parameters. + /// + /// + public class AddGuildUserProperties + { + /// + /// Gets or sets the user's nickname. + /// + /// + /// To clear the user's nickname, this value can be set to null or + /// . + /// + public Optional Nickname { get; set; } + /// + /// Gets or sets whether the user should be muted in a voice channel. + /// + /// + /// If this value is set to true, no user will be able to hear this user speak in the guild. + /// + public Optional Mute { get; set; } + /// + /// Gets or sets whether the user should be deafened in a voice channel. + /// + /// + /// If this value is set to true, this user will not be able to hear anyone speak in the guild. + /// + public Optional Deaf { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> Roles { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> RoleIds { get; set; } + } +} diff --git a/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs b/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs index 2d72dc985..60fdcfbee 100644 --- a/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs +++ b/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; namespace Discord { @@ -65,5 +67,15 @@ namespace Discord return builder; } + + public static EmbedBuilder WithFields(this EmbedBuilder builder, IEnumerable fields) + { + foreach (var field in fields) + builder.AddField(field); + + return builder; + } + public static EmbedBuilder WithFields(this EmbedBuilder builder, params EmbedFieldBuilder[] fields) + => WithFields(builder, fields.AsEnumerable()); } } diff --git a/src/Discord.Net.Core/Net/RpcException.cs b/src/Discord.Net.Core/Net/RpcException.cs deleted file mode 100644 index 195fad73f..000000000 --- a/src/Discord.Net.Core/Net/RpcException.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace Discord -{ - public class RpcException : Exception - { - public int ErrorCode { get; } - public string Reason { get; } - - public RpcException(int errorCode, string reason = null) - : base($"The server sent error {errorCode}{(reason != null ? $": \"{reason}\"" : "")}") - { - ErrorCode = errorCode; - Reason = reason; - } - } -} diff --git a/src/Discord.Net.Rest/API/Common/Guild.cs b/src/Discord.Net.Rest/API/Common/Guild.cs index 0ca1bc236..a84b55a93 100644 --- a/src/Discord.Net.Rest/API/Common/Guild.cs +++ b/src/Discord.Net.Rest/API/Common/Guild.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using Newtonsoft.Json; namespace Discord.API @@ -25,10 +25,12 @@ namespace Discord.API public bool EmbedEnabled { get; set; } [JsonProperty("embed_channel_id")] public ulong? EmbedChannelId { get; set; } - [JsonProperty("system_channel_id")] - public ulong? SystemChannelId { get; set; } [JsonProperty("verification_level")] public VerificationLevel VerificationLevel { get; set; } + [JsonProperty("default_message_notifications")] + public DefaultMessageNotifications DefaultMessageNotifications { get; set; } + [JsonProperty("explicit_content_filter")] + public ExplicitContentFilterLevel ExplicitContentFilter { get; set; } [JsonProperty("voice_states")] public VoiceState[] VoiceStates { get; set; } [JsonProperty("roles")] @@ -39,7 +41,9 @@ namespace Discord.API public string[] Features { get; set; } [JsonProperty("mfa_level")] public MfaLevel MfaLevel { get; set; } - [JsonProperty("default_message_notifications")] - public DefaultMessageNotifications DefaultMessageNotifications { get; set; } + [JsonProperty("application_id")] + public ulong? ApplicationId { get; set; } + [JsonProperty("system_channel_id")] + public ulong? SystemChannelId { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/Message.cs b/src/Discord.Net.Rest/API/Common/Message.cs index 229249ccf..10bdbe568 100644 --- a/src/Discord.Net.Rest/API/Common/Message.cs +++ b/src/Discord.Net.Rest/API/Common/Message.cs @@ -44,5 +44,11 @@ namespace Discord.API public Optional Pinned { get; set; } [JsonProperty("reactions")] public Optional Reactions { get; set; } + // sent with Rich Presence-related chat embeds + [JsonProperty("activity")] + public Optional Activity { get; set; } + // sent with Rich Presence-related chat embeds + [JsonProperty("application")] + public Optional Application { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/MessageActivity.cs b/src/Discord.Net.Rest/API/Common/MessageActivity.cs new file mode 100644 index 000000000..701f6fc03 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageActivity.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + public class MessageActivity + { + [JsonProperty("type")] + public Optional Type { get; set; } + [JsonProperty("party_id")] + public Optional PartyId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageApplication.cs b/src/Discord.Net.Rest/API/Common/MessageApplication.cs new file mode 100644 index 000000000..7302185ad --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageApplication.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + public class MessageApplication + { + /// + /// Gets the snowflake ID of the application. + /// + [JsonProperty("id")] + public ulong Id { get; set; } + /// + /// Gets the ID of the embed's image asset. + /// + [JsonProperty("cover_image")] + public string CoverImage { get; set; } + /// + /// Gets the application's description. + /// + [JsonProperty("description")] + public string Description { get; set; } + /// + /// Gets the ID of the application's icon. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + /// + /// Gets the name of the application. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Overwrite.cs b/src/Discord.Net.Rest/API/Common/Overwrite.cs index 1ba836127..1f3548a1c 100644 --- a/src/Discord.Net.Rest/API/Common/Overwrite.cs +++ b/src/Discord.Net.Rest/API/Common/Overwrite.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using Newtonsoft.Json; namespace Discord.API diff --git a/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs b/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs new file mode 100644 index 000000000..ef6229edb --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class AddGuildMemberParams + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("nick")] + public Optional Nickname { get; set; } + [JsonProperty("roles")] + public Optional RoleIds { get; set; } + [JsonProperty("mute")] + public Optional IsMuted { get; set; } + [JsonProperty("deaf")] + public Optional IsDeafened { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs index 120eeb3a8..e5e8a4632 100644 --- a/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using Newtonsoft.Json; namespace Discord.API.Rest @@ -12,5 +12,7 @@ namespace Discord.API.Rest public Optional Position { get; set; } [JsonProperty("parent_id")] public Optional CategoryId { get; set; } + [JsonProperty("permission_overwrites")] + public Optional Overwrites { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs index 8de10f534..ba70c58d6 100644 --- a/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs @@ -1,4 +1,4 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using Newtonsoft.Json; namespace Discord.API.Rest @@ -28,5 +28,7 @@ namespace Discord.API.Rest public Optional AfkChannelId { get; set; } [JsonProperty("owner_id")] public Optional OwnerId { get; set; } + [JsonProperty("explicit_content_filter")] + public Optional ExplicitContentFilter { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs index 6d6eb29b2..479a7857a 100644 --- a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs +++ b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs @@ -1,12 +1,17 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 using System.Collections.Generic; using System.IO; +using System.Text; +using Discord.Net.Converters; using Discord.Net.Rest; +using Newtonsoft.Json; namespace Discord.API.Rest { internal class UploadWebhookFileParams { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + public Stream File { get; } public Optional Filename { get; set; } @@ -27,18 +32,27 @@ namespace Discord.API.Rest var d = new Dictionary(); d["file"] = new MultipartFile(File, Filename.GetValueOrDefault("unknown.dat")); + var payload = new Dictionary(); if (Content.IsSpecified) - d["content"] = Content.Value; + payload["content"] = Content.Value; if (IsTTS.IsSpecified) - d["tts"] = IsTTS.Value.ToString(); + payload["tts"] = IsTTS.Value.ToString(); if (Nonce.IsSpecified) - d["nonce"] = Nonce.Value; + payload["nonce"] = Nonce.Value; if (Username.IsSpecified) - d["username"] = Username.Value; + payload["username"] = Username.Value; if (AvatarUrl.IsSpecified) - d["avatar_url"] = AvatarUrl.Value; + payload["avatar_url"] = AvatarUrl.Value; if (Embeds.IsSpecified) - d["embeds"] = Embeds.Value; + payload["embeds"] = Embeds.Value; + + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + + d["payload_json"] = json.ToString(); + return d; } } diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 36406bfea..57d7c718a 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -1,3 +1,4 @@ + #pragma warning disable CS1591 using Discord.API.Rest; using Discord.Net; @@ -977,6 +978,8 @@ namespace Discord.API Preconditions.NotNull(args, nameof(args)); Preconditions.AtLeast(args.MaxAge, 0, nameof(args.MaxAge)); Preconditions.AtLeast(args.MaxUses, 0, nameof(args.MaxUses)); + Preconditions.AtMost(args.MaxAge, 86400, nameof(args.MaxAge), + "The maximum age of an invite must be less than or equal to a day (86400 seconds)."); options = RequestOptions.CreateOrClone(options); var ids = new BucketIds(channelId: channelId); @@ -991,6 +994,25 @@ namespace Discord.API } //Guild Members + public async Task AddGuildMemberAsync(ulong guildId, ulong userId, AddGuildMemberParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrWhitespace(args.AccessToken, nameof(args.AccessToken)); + + if (args.RoleIds.IsSpecified) + { + foreach (var roleId in args.RoleIds.Value) + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + } + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + + return await SendJsonAsync("PUT", () => $"guilds/{guildId}/members/{userId}", args, ids, options: options); + } public async Task GetGuildMemberAsync(ulong guildId, ulong userId, RequestOptions options = null) { Preconditions.NotEqual(guildId, 0, nameof(guildId)); @@ -1426,8 +1448,11 @@ namespace Discord.API lastIndex = rightIndex + 1; } + if (builder[builder.Length - 1] == '/') + builder.Remove(builder.Length - 1, 1); format = builder.ToString(); + return x => string.Format(format, x.ToArray()); } catch (Exception ex) diff --git a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs index 716f3beaf..5c232f292 100644 --- a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs +++ b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs @@ -76,9 +76,13 @@ namespace Discord.Rest public static async Task CreateInviteAsync(IGuildChannel channel, BaseDiscordClient client, int? maxAge, int? maxUses, bool isTemporary, bool isUnique, RequestOptions options) { - var args = new CreateChannelInviteParams { IsTemporary = isTemporary, IsUnique = isUnique }; - args.MaxAge = maxAge.GetValueOrDefault(0); - args.MaxUses = maxUses.GetValueOrDefault(0); + var args = new API.Rest.CreateChannelInviteParams + { + IsTemporary = isTemporary, + IsUnique = isUnique, + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0 + }; var model = await client.ApiClient.CreateChannelInviteAsync(channel.Id, args, options).ConfigureAwait(false); return RestInviteMetadata.Create(client, null, channel, model); } @@ -348,6 +352,23 @@ namespace Discord.Rest var model = await client.ApiClient.GetChannelAsync(channel.CategoryId.Value, options).ConfigureAwait(false); return RestCategoryChannel.Create(client, model) as ICategoryChannel; } + public static async Task SyncPermissionsAsync(INestedChannel channel, BaseDiscordClient client, RequestOptions options) + { + var category = await GetCategoryAsync(channel, client, options).ConfigureAwait(false); + if (category == null) throw new InvalidOperationException("This channel does not have a parent channel."); + + var apiArgs = new ModifyGuildChannelParams + { + Overwrites = category.PermissionOverwrites + .Select(overwrite => new API.Overwrite{ + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue, + Deny = overwrite.Permissions.DenyValue + }).ToArray() + }; + await client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options).ConfigureAwait(false); + } //Helpers private static IUser GetAuthor(BaseDiscordClient client, IGuild guild, UserModel model, ulong? webhookId) diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs index 4ccd35a3a..beeb8de19 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -201,6 +201,8 @@ namespace Discord.Rest /// public Task GetCategoryAsync(RequestOptions options = null) => ChannelHelper.GetCategoryAsync(this, Discord, options); + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); private string DebuggerDisplay => $"{Name} ({Id}, Text)"; diff --git a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs index 7f0295c18..1a2c5ceae 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs @@ -57,6 +57,8 @@ namespace Discord.Rest /// public Task GetCategoryAsync(RequestOptions options = null) => ChannelHelper.GetCategoryAsync(this, Discord, options); + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; diff --git a/src/Discord.Net.Rest/Entities/Channels/RpcVirtualMessageChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RpcVirtualMessageChannel.cs deleted file mode 100644 index ce37af6b4..000000000 --- a/src/Discord.Net.Rest/Entities/Channels/RpcVirtualMessageChannel.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -namespace Discord.Rest -{ - [DebuggerDisplay(@"{DebuggerDisplay,nq}")] - internal class RestVirtualMessageChannel : RestEntity, IMessageChannel - { - public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); - public string Mention => MentionUtils.MentionChannel(Id); - - internal RestVirtualMessageChannel(BaseDiscordClient discord, ulong id) - : base(discord, id) - { - } - internal static RestVirtualMessageChannel Create(BaseDiscordClient discord, ulong id) - { - return new RestVirtualMessageChannel(discord, id); - } - - public Task GetMessageAsync(ulong id, RequestOptions options = null) - => ChannelHelper.GetMessageAsync(this, Discord, id, options); - public IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) - => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); - public IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) - => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); - public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) - => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); - public Task> GetPinnedMessagesAsync(RequestOptions options = null) - => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); - - public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null) - => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, options); - public Task SendFileAsync(string filePath, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null) - => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, options); - public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, Embed embed = null, RequestOptions options = null) - - => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, options); - - /// - public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) - => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); - /// - public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) - => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); - - /// - public Task TriggerTypingAsync(RequestOptions options = null) - => ChannelHelper.TriggerTypingAsync(this, Discord, options); - /// - public IDisposable EnterTypingState(RequestOptions options = null) - => ChannelHelper.EnterTypingState(this, Discord, options); - - private string DebuggerDisplay => $"({Id}, Text)"; - - //IMessageChannel - async Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) - { - if (mode == CacheMode.AllowDownload) - return await GetMessageAsync(id, options).ConfigureAwait(false); - else - return null; - } - IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) - { - if (mode == CacheMode.AllowDownload) - return GetMessagesAsync(limit, options); - else - return AsyncEnumerable.Empty>(); - } - IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) - { - if (mode == CacheMode.AllowDownload) - return GetMessagesAsync(fromMessageId, dir, limit, options); - else - return AsyncEnumerable.Empty>(); - } - IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) - { - if (mode == CacheMode.AllowDownload) - return GetMessagesAsync(fromMessage, dir, limit, options); - else - return AsyncEnumerable.Empty>(); - } - async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) - => await GetPinnedMessagesAsync(options).ConfigureAwait(false); - - async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options) - => await SendFileAsync(filePath, text, isTTS, embed, options); - - async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, Embed embed, RequestOptions options) - => await SendFileAsync(stream, filename, text, isTTS, embed, options).ConfigureAwait(false); - async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options) - => await SendMessageAsync(text, isTTS, embed, options).ConfigureAwait(false); - - //IChannel - string IChannel.Name => - throw new NotSupportedException(); - - IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) => - throw new NotSupportedException(); - - Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) => - throw new NotSupportedException(); - } -} diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index b8b66f802..c31fa89f2 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -32,7 +32,8 @@ namespace Discord.Rest Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() : Optional.Create(), Name = args.Name, Splash = args.Splash.IsSpecified ? args.Splash.Value?.ToModel() : Optional.Create(), - VerificationLevel = args.VerificationLevel + VerificationLevel = args.VerificationLevel, + ExplicitContentFilter = args.ExplicitContentFilter }; if (args.AfkChannel.IsSpecified) @@ -60,6 +61,9 @@ namespace Discord.Rest if (!apiArgs.Icon.IsSpecified && guild.IconId != null) apiArgs.Icon = new ImageModel(guild.IconId); + if (args.ExplicitContentFilter.IsSpecified) + apiArgs.ExplicitContentFilter = args.ExplicitContentFilter.Value; + return await client.ApiClient.ModifyGuildAsync(guild.Id, apiArgs, options).ConfigureAwait(false); } /// is null. @@ -253,6 +257,34 @@ namespace Discord.Rest } //Users + public static async Task AddGuildUserAsync(IGuild guild, BaseDiscordClient client, ulong userId, string accessToken, + Action func, RequestOptions options) + { + var args = new AddGuildUserProperties(); + func?.Invoke(args); + + if (args.Roles.IsSpecified) + { + var ids = args.Roles.Value.Select(r => r.Id); + + if (args.RoleIds.IsSpecified) + args.RoleIds.Value.Concat(ids); + else + args.RoleIds = Optional.Create(ids); + } + var apiArgs = new AddGuildMemberParams + { + AccessToken = accessToken, + Nickname = args.Nickname, + IsDeafened = args.Deaf, + IsMuted = args.Mute, + RoleIds = args.RoleIds.IsSpecified ? args.RoleIds.Value.Distinct().ToArray() : Optional.Create() + }; + + var model = await client.ApiClient.AddGuildMemberAsync(guild.Id, userId, apiArgs, options); + + return model is null ? null : RestGuildUser.Create(client, guild, model); + } public static async Task GetUserAsync(IGuild guild, BaseDiscordClient client, ulong id, RequestOptions options) { diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 9c4ecd848..bd70bf96b 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -32,6 +32,8 @@ namespace Discord.Rest public MfaLevel MfaLevel { get; private set; } /// public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } + /// + public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } /// public ulong? AFKChannelId { get; private set; } @@ -48,6 +50,8 @@ namespace Discord.Rest /// public string SplashId { get; private set; } internal bool Available { get; private set; } + /// + public ulong? ApplicationId { get; private set; } /// public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); @@ -98,6 +102,8 @@ namespace Discord.Rest VerificationLevel = model.VerificationLevel; MfaLevel = model.MfaLevel; DefaultMessageNotifications = model.DefaultMessageNotifications; + ExplicitContentFilter = model.ExplicitContentFilter; + ApplicationId = model.ApplicationId; if (model.Emojis != null) { @@ -531,6 +537,10 @@ namespace Discord.Rest public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) => GuildHelper.GetUsersAsync(this, Discord, null, null, options); + /// + public Task AddGuildUserAsync(ulong id, string accessToken, Action func = null, RequestOptions options = null) + => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); + /// /// Gets a user from this guild. /// @@ -794,6 +804,10 @@ namespace Discord.Rest async Task IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, RequestOptions options) => await CreateRoleAsync(name, permissions, color, isHoisted, options).ConfigureAwait(false); + /// + async Task IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action func, RequestOptions options) + => await AddGuildUserAsync(userId, accessToken, func, options); + /// async Task IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) { diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuildEmbed.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuildEmbed.cs index 00f3fae69..41c76eb06 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuildEmbed.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuildEmbed.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Model = Discord.API.GuildEmbed; -namespace Discord +namespace Discord.Rest { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public struct RestGuildEmbed diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs b/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs index 7a03b68d4..a363f051b 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs @@ -2,7 +2,7 @@ using Discord.Rest; using System.Diagnostics; using Model = Discord.API.VoiceRegion; -namespace Discord +namespace Discord.Rest { /// /// Represents a REST-based voice region. diff --git a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs index a4ecf3647..fae1aff99 100644 --- a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs +++ b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs @@ -55,6 +55,10 @@ namespace Discord.Rest /// public DateTimeOffset Timestamp => DateTimeUtils.FromTicks(_timestampTicks); + /// + public MessageActivity Activity { get; private set; } + /// + public MessageApplication Application { get; private set; } internal RestMessage(BaseDiscordClient discord, ulong id, IMessageChannel channel, IUser author, MessageSource source) : base(discord, id) @@ -77,6 +81,29 @@ namespace Discord.Rest if (model.Content.IsSpecified) Content = model.Content.Value; + + if (model.Application.IsSpecified) + { + // create a new Application from the API model + Application = new MessageApplication() + { + Id = model.Application.Value.Id, + CoverImage = model.Application.Value.CoverImage, + Description = model.Application.Value.Description, + Icon = model.Application.Value.Icon, + Name = model.Application.Value.Name + }; + } + + if (model.Activity.IsSpecified) + { + // create a new Activity from the API model + Activity = new MessageActivity() + { + Type = model.Activity.Value.Type.Value, + PartyId = model.Activity.Value.PartyId.Value + }; + } } /// diff --git a/src/Discord.Net.Rest/Entities/Users/RestConnection.cs b/src/Discord.Net.Rest/Entities/Users/RestConnection.cs index 0c91493d2..1afb813c0 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestConnection.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestConnection.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics; using Model = Discord.API.Connection; -namespace Discord +namespace Discord.Rest { [DebuggerDisplay(@"{DebuggerDisplay,nq}")] public class RestConnection : IConnection diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 81538e6e7..3d260d1a6 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -1158,7 +1158,11 @@ namespace Discord.WebSocket if (author == null) { if (guild != null) - author = guild.AddOrUpdateUser(data.Member.Value); //per g250k, we can create an entire member now + { + author = data.Member.IsSpecified // member isn't always included, but use it when we can + ? guild.AddOrUpdateUser(data.Member.Value) + : guild.AddOrUpdateUser(data.Author.Value); // user has no guild-specific data + } else if (channel is SocketGroupChannel) author = (channel as SocketGroupChannel).GetOrAddUser(data.Author.Value); else diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index 8faf1c6eb..acd868020 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -31,6 +31,8 @@ namespace Discord.WebSocket /// public ICategoryChannel Category => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); private bool _nsfw; /// diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs index 07977e5e0..dd71416db 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs @@ -30,6 +30,8 @@ namespace Discord.WebSocket /// public ICategoryChannel Category => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); /// public override IReadOnlyCollection Users diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index b4f87e12a..412f3acff 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -50,6 +50,8 @@ namespace Discord.WebSocket public MfaLevel MfaLevel { get; private set; } /// public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } + /// + public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } /// /// Gets the number of members. /// @@ -73,6 +75,8 @@ namespace Discord.WebSocket internal bool IsAvailable { get; private set; } /// Indicates whether the client is connected to this guild. public bool IsConnected { get; internal set; } + /// + public ulong? ApplicationId { get; internal set; } internal ulong? AFKChannelId { get; private set; } internal ulong? EmbedChannelId { get; private set; } @@ -346,6 +350,8 @@ namespace Discord.WebSocket VerificationLevel = model.VerificationLevel; MfaLevel = model.MfaLevel; DefaultMessageNotifications = model.DefaultMessageNotifications; + ExplicitContentFilter = model.ExplicitContentFilter; + ApplicationId = model.ApplicationId; if (model.Emojis != null) { @@ -663,6 +669,10 @@ namespace Discord.WebSocket } //Users + /// + public Task AddGuildUserAsync(ulong id, string accessToken, Action func = null, RequestOptions options = null) + => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); + /// /// Gets a user from this guild. /// @@ -1090,6 +1100,10 @@ namespace Discord.WebSocket /// Task> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) => Task.FromResult>(Users); + + /// + async Task IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action func, RequestOptions options) + => await AddGuildUserAsync(userId, accessToken, func, options); /// Task IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) => Task.FromResult(GetUser(id)); diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs index 0767f2ad7..2cfcee270 100644 --- a/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs @@ -43,6 +43,13 @@ namespace Discord.WebSocket public virtual bool IsPinned => false; /// public virtual DateTimeOffset? EditedTimestamp => null; + + /// + public MessageActivity Activity { get; private set; } + + /// + public MessageApplication Application { get; private set; } + /// /// Returns all attachments included in this message. /// @@ -105,6 +112,29 @@ namespace Discord.WebSocket if (model.Content.IsSpecified) Content = model.Content.Value; + + if (model.Application.IsSpecified) + { + // create a new Application from the API model + Application = new MessageApplication() + { + Id = model.Application.Value.Id, + CoverImage = model.Application.Value.CoverImage, + Description = model.Application.Value.Description, + Icon = model.Application.Value.Icon, + Name = model.Application.Value.Name + }; + } + + if (model.Activity.IsSpecified) + { + // create a new Activity from the API model + Activity = new MessageActivity() + { + Type = model.Activity.Value.Type.Value, + PartyId = model.Activity.Value.PartyId.Value + }; + } } /// diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs index 9a101cddb..41c463a89 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs @@ -1,7 +1,10 @@ -using Discord.Rest; using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; +using Discord.Rest; using Model = Discord.API.User; namespace Discord.WebSocket @@ -35,6 +38,8 @@ namespace Discord.WebSocket public IActivity Activity => Presence.Activity; /// public UserStatus Status => Presence.Status; + public IReadOnlyCollection MutualGuilds + => Discord.Guilds.Where(g => g.Users.Any(u => u.Id == Id)).ToImmutableArray(); internal SocketUser(DiscordSocketClient discord, ulong id) : base(discord, id) diff --git a/test/Discord.Net.Tests/Discord.Net.Tests.csproj b/test/Discord.Net.Tests/Discord.Net.Tests.csproj index 60491a96f..0ee6f7e59 100644 --- a/test/Discord.Net.Tests/Discord.Net.Tests.csproj +++ b/test/Discord.Net.Tests/Discord.Net.Tests.csproj @@ -3,6 +3,7 @@ Exe Discord netcoreapp1.1 + portable $(PackageTargetFallback);portable-net45+win8+wp8+wpa81 @@ -23,8 +24,8 @@ - - - + + + diff --git a/test/Discord.Net.Tests/Tests.GuildPermissions.cs b/test/Discord.Net.Tests/Tests.Guilds.cs similarity index 94% rename from test/Discord.Net.Tests/Tests.GuildPermissions.cs rename to test/Discord.Net.Tests/Tests.Guilds.cs index f49f431b5..09e3d044d 100644 --- a/test/Discord.Net.Tests/Tests.GuildPermissions.cs +++ b/test/Discord.Net.Tests/Tests.Guilds.cs @@ -5,8 +5,25 @@ using Xunit; namespace Discord { - public class GuidPermissionsTests + public partial class Tests { + /// + /// Tests the behavior of modifying the ExplicitContentFilter property of a Guild. + /// + [Fact] + public async Task TestExplicitContentFilter() + { + foreach (var level in Enum.GetValues(typeof(ExplicitContentFilterLevel))) + { + await _guild.ModifyAsync(x => x.ExplicitContentFilter = (ExplicitContentFilterLevel)level); + await _guild.UpdateAsync(); + Assert.Equal(level, _guild.ExplicitContentFilter); + } + } + + /// + /// Tests the behavior of the GuildPermissions class. + /// [Fact] public Task TestGuildPermission() { diff --git a/test/Discord.Net.Tests/Tests.TypeReaders.cs b/test/Discord.Net.Tests/Tests.TypeReaders.cs new file mode 100644 index 000000000..91514bfae --- /dev/null +++ b/test/Discord.Net.Tests/Tests.TypeReaders.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Discord.Commands; +using Xunit; + +namespace Discord +{ + public sealed class TypeReaderTests + { + [Fact] + public async Task TestNamedArgumentReader() + { + var commands = new CommandService(); + var module = await commands.AddModuleAsync(null); + + Assert.NotNull(module); + Assert.NotEmpty(module.Commands); + + var cmd = module.Commands[0]; + Assert.NotNull(cmd); + Assert.NotEmpty(cmd.Parameters); + + var param = cmd.Parameters[0]; + Assert.NotNull(param); + Assert.True(param.IsRemainder); + + var result = await param.ParseAsync(null, "bar: hello foo: 42"); + Assert.True(result.IsSuccess); + + var m = result.BestMatch as ArgumentType; + Assert.NotNull(m); + Assert.Equal(expected: 42, actual: m.Foo); + Assert.Equal(expected: "hello", actual: m.Bar); + } + + [Fact] + public async Task TestQuotedArgumentValue() + { + var commands = new CommandService(); + var module = await commands.AddModuleAsync(null); + + Assert.NotNull(module); + Assert.NotEmpty(module.Commands); + + var cmd = module.Commands[0]; + Assert.NotNull(cmd); + Assert.NotEmpty(cmd.Parameters); + + var param = cmd.Parameters[0]; + Assert.NotNull(param); + Assert.True(param.IsRemainder); + + var result = await param.ParseAsync(null, "foo: 42 bar: 《hello》"); + Assert.True(result.IsSuccess); + + var m = result.BestMatch as ArgumentType; + Assert.NotNull(m); + Assert.Equal(expected: 42, actual: m.Foo); + Assert.Equal(expected: "hello", actual: m.Bar); + } + + [Fact] + public async Task TestNonPatternInput() + { + var commands = new CommandService(); + var module = await commands.AddModuleAsync(null); + + Assert.NotNull(module); + Assert.NotEmpty(module.Commands); + + var cmd = module.Commands[0]; + Assert.NotNull(cmd); + Assert.NotEmpty(cmd.Parameters); + + var param = cmd.Parameters[0]; + Assert.NotNull(param); + Assert.True(param.IsRemainder); + + var result = await param.ParseAsync(null, "foobar"); + Assert.False(result.IsSuccess); + Assert.Equal(expected: CommandError.Exception, actual: result.Error); + } + + [Fact] + public async Task TestMultiple() + { + var commands = new CommandService(); + var module = await commands.AddModuleAsync(null); + + Assert.NotNull(module); + Assert.NotEmpty(module.Commands); + + var cmd = module.Commands[0]; + Assert.NotNull(cmd); + Assert.NotEmpty(cmd.Parameters); + + var param = cmd.Parameters[0]; + Assert.NotNull(param); + Assert.True(param.IsRemainder); + + var result = await param.ParseAsync(null, "manyints: \"1, 2, 3, 4, 5, 6, 7\""); + Assert.True(result.IsSuccess); + + var m = result.BestMatch as ArgumentType; + Assert.NotNull(m); + Assert.Equal(expected: new int[] { 1, 2, 3, 4, 5, 6, 7 }, actual: m.ManyInts); + } + } + + [NamedArgumentType] + public sealed class ArgumentType + { + public int Foo { get; set; } + + [OverrideTypeReader(typeof(CustomTypeReader))] + public string Bar { get; set; } + + public IEnumerable ManyInts { get; set; } + } + + public sealed class CustomTypeReader : TypeReader + { + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + => Task.FromResult(TypeReaderResult.FromSuccess(input)); + } + + public sealed class TestModule : ModuleBase + { + [Command("test")] + public Task TestCommand(ArgumentType arg) => Task.Delay(0); + } +}