| @@ -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) | |||
| @@ -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} | |||
| @@ -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 | |||
| [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 | |||
| @@ -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); | |||
| } | |||
| } | |||
| } | |||
| @@ -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); | |||
| } | |||
| } | |||
| @@ -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(); | |||
| @@ -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)] | |||
| @@ -20,6 +20,8 @@ namespace _02_commands_framework.Services | |||
| _discord = services.GetRequiredService<DiscordSocketClient>(); | |||
| _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<CommandInfo> 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; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,14 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <PropertyGroup> | |||
| <OutputType>Exe</OutputType> | |||
| <TargetFramework>netcoreapp2.1</TargetFramework> | |||
| <RootNamespace>_03_sharded_client</RootNamespace> | |||
| </PropertyGroup> | |||
| <ItemGroup> | |||
| <ProjectReference Include="..\..\src\Discord.Net.Commands\Discord.Net.Commands.csproj" /> | |||
| <ProjectReference Include="..\..\src\Discord.Net.WebSocket\Discord.Net.WebSocket.csproj" /> | |||
| </ItemGroup> | |||
| </Project> | |||
| @@ -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<ShardedCommandContext> | |||
| { | |||
| [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); | |||
| } | |||
| } | |||
| } | |||
| @@ -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<CommandHandlingService>().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<CommandService>() | |||
| .AddSingleton<CommandHandlingService>() | |||
| .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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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<CommandService>(); | |||
| _discord = services.GetRequiredService<DiscordShardedClient>(); | |||
| _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<CommandInfo> 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; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| using System; | |||
| namespace Discord.Commands | |||
| { | |||
| /// <summary> | |||
| /// Instructs the command system to treat command paramters of this type | |||
| /// as a collection of named arguments matching to its properties. | |||
| /// </summary> | |||
| [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] | |||
| public sealed class NamedArgumentTypeAttribute : Attribute { } | |||
| } | |||
| @@ -1,5 +1,4 @@ | |||
| using System; | |||
| using System.Reflection; | |||
| namespace Discord.Commands | |||
| @@ -27,8 +26,8 @@ namespace Discord.Commands | |||
| /// => ReplyAsync(time); | |||
| /// </code> | |||
| /// </example> | |||
| [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(); | |||
| @@ -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; | |||
| @@ -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<NamedArgumentTypeAttribute>() != 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) | |||
| @@ -49,8 +49,8 @@ namespace Discord.Commands | |||
| /// Should the command encounter any of the aforementioned error, this event will not be raised. | |||
| /// </para> | |||
| /// </remarks> | |||
| public event Func<CommandInfo, ICommandContext, IResult, Task> CommandExecuted { add { _commandExecutedEvent.Add(value); } remove { _commandExecutedEvent.Remove(value); } } | |||
| internal readonly AsyncEvent<Func<CommandInfo, ICommandContext, IResult, Task>> _commandExecutedEvent = new AsyncEvent<Func<CommandInfo, ICommandContext, IResult, Task>>(); | |||
| public event Func<Optional<CommandInfo>, ICommandContext, IResult, Task> CommandExecuted { add { _commandExecutedEvent.Add(value); } remove { _commandExecutedEvent.Remove(value); } } | |||
| internal readonly AsyncEvent<Func<Optional<CommandInfo>, ICommandContext, IResult, Task>> _commandExecutedEvent = new AsyncEvent<Func<Optional<CommandInfo>, ICommandContext, IResult, Task>>(); | |||
| private readonly SemaphoreSlim _moduleLock; | |||
| private readonly ConcurrentDictionary<Type, ModuleInfo> _typedModuleDefs; | |||
| @@ -512,7 +512,11 @@ namespace Discord.Commands | |||
| var searchResult = Search(input); | |||
| if (!searchResult.IsSuccess) | |||
| { | |||
| await _commandExecutedEvent.InvokeAsync(Optional.Create<CommandInfo>(), context, searchResult).ConfigureAwait(false); | |||
| return searchResult; | |||
| } | |||
| var commands = searchResult.Commands; | |||
| var preconditionResults = new Dictionary<CommandMatch, PreconditionResult>(); | |||
| @@ -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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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<IReadOnlyCollection<CommandInfo>> GetExecutableCommandsAsync(this ICollection<CommandInfo> commands, ICommandContext context, IServiceProvider provider) | |||
| { | |||
| var executableCommands = new List<CommandInfo>(); | |||
| 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<IReadOnlyCollection<CommandInfo>> GetExecutableCommandsAsync(this CommandService commandService, ICommandContext context, IServiceProvider provider) | |||
| => GetExecutableCommandsAsync(commandService.Commands.ToArray(), context, provider); | |||
| public static async Task<IReadOnlyCollection<CommandInfo>> GetExecutableCommandsAsync(this ModuleInfo module, ICommandContext context, IServiceProvider provider) | |||
| { | |||
| var executableCommands = new List<CommandInfo>(); | |||
| 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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| { | |||
| @@ -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<T> : TypeReader | |||
| where T : class, new() | |||
| { | |||
| private static readonly IReadOnlyDictionary<string, PropertyInfo> _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<TypeReaderResult> 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<object> 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<OverrideTypeReaderAttribute>(); | |||
| 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<IEnumerable>)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<object> 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<IEnumerable> ReadMultiple<TObj>(TypeReader reader, ICommandContext context, IEnumerable<string> args, IServiceProvider services) | |||
| { | |||
| var objs = new List<TObj>(); | |||
| 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<T>) | |||
| .GetTypeInfo() | |||
| .DeclaredMethods | |||
| .Single(m => m.IsPrivate && m.IsStatic && m.Name == nameof(ReadMultiple)); | |||
| private enum ReadState | |||
| { | |||
| LookingForParameter, | |||
| InParameter, | |||
| LookingForArgument, | |||
| InArgument, | |||
| InQuotedArgument, | |||
| End | |||
| } | |||
| } | |||
| } | |||
| @@ -25,5 +25,10 @@ namespace Discord | |||
| /// representing the parent of this channel; <c>null</c> if none is set. | |||
| /// </returns> | |||
| Task<ICategoryChannel> GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); | |||
| /// <summary> | |||
| /// Syncs the permissions of this nested channel with its parent's. | |||
| /// </summary> | |||
| Task SyncPermissionsAsync(RequestOptions options = null); | |||
| } | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| namespace Discord | |||
| { | |||
| public enum ExplicitContentFilterLevel | |||
| { | |||
| /// <summary> No messages will be scanned. </summary> | |||
| Disabled = 0, | |||
| /// <summary> Scans messages from all guild members that do not have a role. </summary> | |||
| /// <remarks> Recommented option for servers that use roles for trusted membership. </remarks> | |||
| MembersWithoutRoles = 1, | |||
| /// <summary> Scan messages sent by all guild members. </summary> | |||
| AllMembers = 2 | |||
| } | |||
| } | |||
| @@ -66,5 +66,9 @@ namespace Discord | |||
| /// Gets or sets the ID of the owner of this guild. | |||
| /// </summary> | |||
| public Optional<ulong> OwnerId { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets the explicit content filter level of this guild. | |||
| /// </summary> | |||
| public Optional<ExplicitContentFilterLevel> ExplicitContentFilter { get; set; } | |||
| } | |||
| } | |||
| @@ -53,6 +53,13 @@ namespace Discord | |||
| /// </returns> | |||
| VerificationLevel VerificationLevel { get; } | |||
| /// <summary> | |||
| /// Gets the level of content filtering applied to user's content in a Guild. | |||
| /// </summary> | |||
| /// <returns> | |||
| /// The level of explicit content filtering. | |||
| /// </returns> | |||
| ExplicitContentFilterLevel ExplicitContentFilter { get; } | |||
| /// <summary> | |||
| /// Gets the ID of this guild's icon. | |||
| /// </summary> | |||
| /// <returns> | |||
| @@ -141,6 +148,13 @@ namespace Discord | |||
| /// </returns> | |||
| ulong OwnerId { get; } | |||
| /// <summary> | |||
| /// Gets the application ID of the guild creator if it is bot-created. | |||
| /// </summary> | |||
| /// <returns> | |||
| /// A <see cref="ulong"/> representing the snowflake identifier of the application ID that created this guild, or <c>null</c> if it was not bot-created. | |||
| /// </returns> | |||
| ulong? ApplicationId { get; } | |||
| /// <summary> | |||
| /// Gets the ID of the region hosting this guild's voice channels. | |||
| /// </summary> | |||
| /// <returns> | |||
| @@ -521,6 +535,18 @@ namespace Discord | |||
| /// </returns> | |||
| Task<IRole> CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false, RequestOptions options = null); | |||
| /// <summary> | |||
| /// Adds a user to this guild. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// 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. | |||
| /// </remarks> | |||
| /// <param name="id">The snowflake identifier of the user.</param> | |||
| /// <param name="accessToken">The OAuth2 access token for the user, requested with the guilds.join scope.</param> | |||
| /// <param name="func">The delegate containing the properties to be applied to the user upon being added to the guild.</param> | |||
| /// <param name="options">The options to be used when sending the request.</param> | |||
| /// <returns>A guild user associated with the specified <paramref name="id" />; <c>null</c> if the user is already in the guild.</returns> | |||
| Task<IGuildUser> AddGuildUserAsync(ulong userId, string accessToken, Action<AddGuildUserProperties> func = null, RequestOptions options = null); | |||
| /// <summary> | |||
| /// Gets a collection of all users in this guild. | |||
| /// </summary> | |||
| @@ -100,5 +100,25 @@ namespace Discord | |||
| /// A read-only collection of user IDs. | |||
| /// </returns> | |||
| IReadOnlyCollection<ulong> MentionedUserIds { get; } | |||
| /// <summary> | |||
| /// Returns the Activity associated with a message. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// Sent with Rich Presence-related chat embeds. | |||
| /// </remarks> | |||
| /// <returns> | |||
| /// A message's activity, if any is associated. | |||
| /// </returns> | |||
| MessageActivity Activity { get; } | |||
| /// <summary> | |||
| /// Returns the Application associated with a messsage. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// Sent with Rich-Presence-related chat embeds. | |||
| /// </remarks> | |||
| /// <returns> | |||
| /// A message's application, if any is associated. | |||
| /// </returns> | |||
| MessageApplication Application { get; } | |||
| } | |||
| } | |||
| @@ -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 | |||
| { | |||
| /// <summary> | |||
| /// Gets the type of activity of this message. | |||
| /// </summary> | |||
| public MessageActivityType Type { get; internal set; } | |||
| /// <summary> | |||
| /// Gets the party ID of this activity, if any. | |||
| /// </summary> | |||
| public string PartyId { get; internal set; } | |||
| private string DebuggerDisplay | |||
| => $"{Type}{(string.IsNullOrWhiteSpace(PartyId) ? "" : $" {PartyId}")}"; | |||
| public override string ToString() => DebuggerDisplay; | |||
| } | |||
| } | |||
| @@ -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 | |||
| } | |||
| } | |||
| @@ -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 | |||
| { | |||
| /// <summary> | |||
| /// Gets the snowflake ID of the application. | |||
| /// </summary> | |||
| public ulong Id { get; internal set; } | |||
| /// <summary> | |||
| /// Gets the ID of the embed's image asset. | |||
| /// </summary> | |||
| public string CoverImage { get; internal set; } | |||
| /// <summary> | |||
| /// Gets the application's description. | |||
| /// </summary> | |||
| public string Description { get; internal set; } | |||
| /// <summary> | |||
| /// Gets the ID of the application's icon. | |||
| /// </summary> | |||
| public string Icon { get; internal set; } | |||
| /// <summary> | |||
| /// Gets the Url of the application's icon. | |||
| /// </summary> | |||
| public string IconUrl | |||
| => $"https://cdn.discordapp.com/app-icons/{Id}/{Icon}"; | |||
| /// <summary> | |||
| /// Gets the name of the application. | |||
| /// </summary> | |||
| public string Name { get; internal set; } | |||
| private string DebuggerDisplay | |||
| => $"{Name} ({Id}): {Description}"; | |||
| public override string ToString() | |||
| => DebuggerDisplay; | |||
| } | |||
| } | |||
| @@ -0,0 +1,62 @@ | |||
| using System.Collections.Generic; | |||
| namespace Discord | |||
| { | |||
| /// <summary> | |||
| /// Properties that are used to add a new <see cref="IGuildUser"/> to the guild with the following parameters. | |||
| /// </summary> | |||
| /// <seealso cref="IGuild.AddGuildUserAsync" /> | |||
| public class AddGuildUserProperties | |||
| { | |||
| /// <summary> | |||
| /// Gets or sets the user's nickname. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// To clear the user's nickname, this value can be set to <c>null</c> or | |||
| /// <see cref="string.Empty"/>. | |||
| /// </remarks> | |||
| public Optional<string> Nickname { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets whether the user should be muted in a voice channel. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// If this value is set to <c>true</c>, no user will be able to hear this user speak in the guild. | |||
| /// </remarks> | |||
| public Optional<bool> Mute { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets whether the user should be deafened in a voice channel. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// If this value is set to <c>true</c>, this user will not be able to hear anyone speak in the guild. | |||
| /// </remarks> | |||
| public Optional<bool> Deaf { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets the roles the user should have. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// <para> | |||
| /// To add a role to a user: | |||
| /// <see cref="IGuildUser.AddRolesAsync(IEnumerable{IRole},RequestOptions)" /> | |||
| /// </para> | |||
| /// <para> | |||
| /// To remove a role from a user: | |||
| /// <see cref="IGuildUser.RemoveRolesAsync(IEnumerable{IRole},RequestOptions)" /> | |||
| /// </para> | |||
| /// </remarks> | |||
| public Optional<IEnumerable<IRole>> Roles { get; set; } | |||
| /// <summary> | |||
| /// Gets or sets the roles the user should have. | |||
| /// </summary> | |||
| /// <remarks> | |||
| /// <para> | |||
| /// To add a role to a user: | |||
| /// <see cref="IGuildUser.AddRolesAsync(IEnumerable{IRole},RequestOptions)" /> | |||
| /// </para> | |||
| /// <para> | |||
| /// To remove a role from a user: | |||
| /// <see cref="IGuildUser.RemoveRolesAsync(IEnumerable{IRole},RequestOptions)" /> | |||
| /// </para> | |||
| /// </remarks> | |||
| public Optional<IEnumerable<ulong>> RoleIds { get; set; } | |||
| } | |||
| } | |||
| @@ -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<EmbedFieldBuilder> 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()); | |||
| } | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| } | |||
| @@ -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; } | |||
| } | |||
| } | |||
| @@ -44,5 +44,11 @@ namespace Discord.API | |||
| public Optional<bool> Pinned { get; set; } | |||
| [JsonProperty("reactions")] | |||
| public Optional<Reaction[]> Reactions { get; set; } | |||
| // sent with Rich Presence-related chat embeds | |||
| [JsonProperty("activity")] | |||
| public Optional<MessageActivity> Activity { get; set; } | |||
| // sent with Rich Presence-related chat embeds | |||
| [JsonProperty("application")] | |||
| public Optional<MessageApplication> Application { get; set; } | |||
| } | |||
| } | |||
| @@ -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<MessageActivityType> Type { get; set; } | |||
| [JsonProperty("party_id")] | |||
| public Optional<string> PartyId { get; set; } | |||
| } | |||
| } | |||
| @@ -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 | |||
| { | |||
| /// <summary> | |||
| /// Gets the snowflake ID of the application. | |||
| /// </summary> | |||
| [JsonProperty("id")] | |||
| public ulong Id { get; set; } | |||
| /// <summary> | |||
| /// Gets the ID of the embed's image asset. | |||
| /// </summary> | |||
| [JsonProperty("cover_image")] | |||
| public string CoverImage { get; set; } | |||
| /// <summary> | |||
| /// Gets the application's description. | |||
| /// </summary> | |||
| [JsonProperty("description")] | |||
| public string Description { get; set; } | |||
| /// <summary> | |||
| /// Gets the ID of the application's icon. | |||
| /// </summary> | |||
| [JsonProperty("icon")] | |||
| public string Icon { get; set; } | |||
| /// <summary> | |||
| /// Gets the name of the application. | |||
| /// </summary> | |||
| [JsonProperty("name")] | |||
| public string Name { get; set; } | |||
| } | |||
| } | |||
| @@ -1,4 +1,4 @@ | |||
| #pragma warning disable CS1591 | |||
| #pragma warning disable CS1591 | |||
| using Newtonsoft.Json; | |||
| namespace Discord.API | |||
| @@ -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<string> Nickname { get; set; } | |||
| [JsonProperty("roles")] | |||
| public Optional<ulong[]> RoleIds { get; set; } | |||
| [JsonProperty("mute")] | |||
| public Optional<bool> IsMuted { get; set; } | |||
| [JsonProperty("deaf")] | |||
| public Optional<bool> IsDeafened { get; set; } | |||
| } | |||
| } | |||
| @@ -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<int> Position { get; set; } | |||
| [JsonProperty("parent_id")] | |||
| public Optional<ulong?> CategoryId { get; set; } | |||
| [JsonProperty("permission_overwrites")] | |||
| public Optional<Overwrite[]> Overwrites { get; set; } | |||
| } | |||
| } | |||
| @@ -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<ulong?> AfkChannelId { get; set; } | |||
| [JsonProperty("owner_id")] | |||
| public Optional<ulong> OwnerId { get; set; } | |||
| [JsonProperty("explicit_content_filter")] | |||
| public Optional<ExplicitContentFilterLevel> ExplicitContentFilter { get; set; } | |||
| } | |||
| } | |||
| @@ -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<string> Filename { get; set; } | |||
| @@ -27,18 +32,27 @@ namespace Discord.API.Rest | |||
| var d = new Dictionary<string, object>(); | |||
| d["file"] = new MultipartFile(File, Filename.GetValueOrDefault("unknown.dat")); | |||
| var payload = new Dictionary<string, object>(); | |||
| 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; | |||
| } | |||
| } | |||
| @@ -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<GuildMember> 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<GuildMember>("PUT", () => $"guilds/{guildId}/members/{userId}", args, ids, options: options); | |||
| } | |||
| public async Task<GuildMember> 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) | |||
| @@ -76,9 +76,13 @@ namespace Discord.Rest | |||
| public static async Task<RestInviteMetadata> 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) | |||
| @@ -201,6 +201,8 @@ namespace Discord.Rest | |||
| /// </returns> | |||
| public Task<ICategoryChannel> 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)"; | |||
| @@ -57,6 +57,8 @@ namespace Discord.Rest | |||
| /// </returns> | |||
| public Task<ICategoryChannel> 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)"; | |||
| @@ -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<ulong>, 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<RestMessage> GetMessageAsync(ulong id, RequestOptions options = null) | |||
| => ChannelHelper.GetMessageAsync(this, Discord, id, options); | |||
| public IAsyncEnumerable<IReadOnlyCollection<RestMessage>> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) | |||
| => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); | |||
| public IAsyncEnumerable<IReadOnlyCollection<RestMessage>> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) | |||
| => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); | |||
| public IAsyncEnumerable<IReadOnlyCollection<RestMessage>> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) | |||
| => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); | |||
| public Task<IReadOnlyCollection<RestMessage>> GetPinnedMessagesAsync(RequestOptions options = null) | |||
| => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); | |||
| public Task<RestUserMessage> SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null) | |||
| => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, options); | |||
| public Task<RestUserMessage> 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<RestUserMessage> 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); | |||
| /// <inheritdoc /> | |||
| public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) | |||
| => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); | |||
| /// <inheritdoc /> | |||
| public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) | |||
| => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); | |||
| /// <inheritdoc /> | |||
| public Task TriggerTypingAsync(RequestOptions options = null) | |||
| => ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
| /// <inheritdoc /> | |||
| public IDisposable EnterTypingState(RequestOptions options = null) | |||
| => ChannelHelper.EnterTypingState(this, Discord, options); | |||
| private string DebuggerDisplay => $"({Id}, Text)"; | |||
| //IMessageChannel | |||
| async Task<IMessage> IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) | |||
| { | |||
| if (mode == CacheMode.AllowDownload) | |||
| return await GetMessageAsync(id, options).ConfigureAwait(false); | |||
| else | |||
| return null; | |||
| } | |||
| IAsyncEnumerable<IReadOnlyCollection<IMessage>> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) | |||
| { | |||
| if (mode == CacheMode.AllowDownload) | |||
| return GetMessagesAsync(limit, options); | |||
| else | |||
| return AsyncEnumerable.Empty<IReadOnlyCollection<IMessage>>(); | |||
| } | |||
| IAsyncEnumerable<IReadOnlyCollection<IMessage>> 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<IReadOnlyCollection<IMessage>>(); | |||
| } | |||
| IAsyncEnumerable<IReadOnlyCollection<IMessage>> 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<IReadOnlyCollection<IMessage>>(); | |||
| } | |||
| async Task<IReadOnlyCollection<IMessage>> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) | |||
| => await GetPinnedMessagesAsync(options).ConfigureAwait(false); | |||
| async Task<IUserMessage> IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, RequestOptions options) | |||
| => await SendFileAsync(filePath, text, isTTS, embed, options); | |||
| async Task<IUserMessage> 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<IUserMessage> 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<IReadOnlyCollection<IUser>> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) => | |||
| throw new NotSupportedException(); | |||
| Task<IUser> IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) => | |||
| throw new NotSupportedException(); | |||
| } | |||
| } | |||
| @@ -32,7 +32,8 @@ namespace Discord.Rest | |||
| Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() : Optional.Create<ImageModel?>(), | |||
| Name = args.Name, | |||
| Splash = args.Splash.IsSpecified ? args.Splash.Value?.ToModel() : Optional.Create<ImageModel?>(), | |||
| 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); | |||
| } | |||
| /// <exception cref="ArgumentNullException"><paramref name="func"/> is <c>null</c>.</exception> | |||
| @@ -253,6 +257,34 @@ namespace Discord.Rest | |||
| } | |||
| //Users | |||
| public static async Task<RestGuildUser> AddGuildUserAsync(IGuild guild, BaseDiscordClient client, ulong userId, string accessToken, | |||
| Action<AddGuildUserProperties> 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<ulong[]>() | |||
| }; | |||
| 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<RestGuildUser> GetUserAsync(IGuild guild, BaseDiscordClient client, | |||
| ulong id, RequestOptions options) | |||
| { | |||
| @@ -32,6 +32,8 @@ namespace Discord.Rest | |||
| public MfaLevel MfaLevel { get; private set; } | |||
| /// <inheritdoc /> | |||
| public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } | |||
| /// <inheritdoc /> | |||
| public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } | |||
| /// <inheritdoc /> | |||
| public ulong? AFKChannelId { get; private set; } | |||
| @@ -48,6 +50,8 @@ namespace Discord.Rest | |||
| /// <inheritdoc /> | |||
| public string SplashId { get; private set; } | |||
| internal bool Available { get; private set; } | |||
| /// <inheritdoc /> | |||
| public ulong? ApplicationId { get; private set; } | |||
| /// <inheritdoc /> | |||
| 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<IReadOnlyCollection<RestGuildUser>> GetUsersAsync(RequestOptions options = null) | |||
| => GuildHelper.GetUsersAsync(this, Discord, null, null, options); | |||
| /// <inheritdoc /> | |||
| public Task<RestGuildUser> AddGuildUserAsync(ulong id, string accessToken, Action<AddGuildUserProperties> func = null, RequestOptions options = null) | |||
| => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); | |||
| /// <summary> | |||
| /// Gets a user from this guild. | |||
| /// </summary> | |||
| @@ -794,6 +804,10 @@ namespace Discord.Rest | |||
| async Task<IRole> IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, RequestOptions options) | |||
| => await CreateRoleAsync(name, permissions, color, isHoisted, options).ConfigureAwait(false); | |||
| /// <inheritdoc /> | |||
| async Task<IGuildUser> IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action<AddGuildUserProperties> func, RequestOptions options) | |||
| => await AddGuildUserAsync(userId, accessToken, func, options); | |||
| /// <inheritdoc /> | |||
| async Task<IGuildUser> IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) | |||
| { | |||
| @@ -1,7 +1,7 @@ | |||
| using System.Diagnostics; | |||
| using Model = Discord.API.GuildEmbed; | |||
| namespace Discord | |||
| namespace Discord.Rest | |||
| { | |||
| [DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||
| public struct RestGuildEmbed | |||
| @@ -2,7 +2,7 @@ using Discord.Rest; | |||
| using System.Diagnostics; | |||
| using Model = Discord.API.VoiceRegion; | |||
| namespace Discord | |||
| namespace Discord.Rest | |||
| { | |||
| /// <summary> | |||
| /// Represents a REST-based voice region. | |||
| @@ -55,6 +55,10 @@ namespace Discord.Rest | |||
| /// <inheritdoc /> | |||
| public DateTimeOffset Timestamp => DateTimeUtils.FromTicks(_timestampTicks); | |||
| /// <inheritdoc /> | |||
| public MessageActivity Activity { get; private set; } | |||
| /// <inheritdoc /> | |||
| 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 | |||
| }; | |||
| } | |||
| } | |||
| /// <inheritdoc /> | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -31,6 +31,8 @@ namespace Discord.WebSocket | |||
| /// </returns> | |||
| 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; | |||
| /// <inheritdoc /> | |||
| @@ -30,6 +30,8 @@ namespace Discord.WebSocket | |||
| /// </returns> | |||
| public ICategoryChannel Category | |||
| => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; | |||
| public Task SyncPermissionsAsync(RequestOptions options = null) | |||
| => ChannelHelper.SyncPermissionsAsync(this, Discord, options); | |||
| /// <inheritdoc /> | |||
| public override IReadOnlyCollection<SocketGuildUser> Users | |||
| @@ -50,6 +50,8 @@ namespace Discord.WebSocket | |||
| public MfaLevel MfaLevel { get; private set; } | |||
| /// <inheritdoc /> | |||
| public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } | |||
| /// <inheritdoc /> | |||
| public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } | |||
| /// <summary> | |||
| /// Gets the number of members. | |||
| /// </summary> | |||
| @@ -73,6 +75,8 @@ namespace Discord.WebSocket | |||
| internal bool IsAvailable { get; private set; } | |||
| /// <summary> Indicates whether the client is connected to this guild. </summary> | |||
| public bool IsConnected { get; internal set; } | |||
| /// <inheritdoc /> | |||
| 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 | |||
| /// <inheritdoc /> | |||
| public Task<RestGuildUser> AddGuildUserAsync(ulong id, string accessToken, Action<AddGuildUserProperties> func = null, RequestOptions options = null) | |||
| => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); | |||
| /// <summary> | |||
| /// Gets a user from this guild. | |||
| /// </summary> | |||
| @@ -1090,6 +1100,10 @@ namespace Discord.WebSocket | |||
| /// <inheritdoc /> | |||
| Task<IReadOnlyCollection<IGuildUser>> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) | |||
| => Task.FromResult<IReadOnlyCollection<IGuildUser>>(Users); | |||
| /// <inheritdoc /> | |||
| async Task<IGuildUser> IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action<AddGuildUserProperties> func, RequestOptions options) | |||
| => await AddGuildUserAsync(userId, accessToken, func, options); | |||
| /// <inheritdoc /> | |||
| Task<IGuildUser> IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) | |||
| => Task.FromResult<IGuildUser>(GetUser(id)); | |||
| @@ -43,6 +43,13 @@ namespace Discord.WebSocket | |||
| public virtual bool IsPinned => false; | |||
| /// <inheritdoc /> | |||
| public virtual DateTimeOffset? EditedTimestamp => null; | |||
| /// <inheritdoc /> | |||
| public MessageActivity Activity { get; private set; } | |||
| /// <inheritdoc /> | |||
| public MessageApplication Application { get; private set; } | |||
| /// <summary> | |||
| /// Returns all attachments included in this message. | |||
| /// </summary> | |||
| @@ -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 | |||
| }; | |||
| } | |||
| } | |||
| /// <inheritdoc /> | |||
| @@ -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; | |||
| /// <inheritdoc /> | |||
| public UserStatus Status => Presence.Status; | |||
| public IReadOnlyCollection<SocketGuild> MutualGuilds | |||
| => Discord.Guilds.Where(g => g.Users.Any(u => u.Id == Id)).ToImmutableArray(); | |||
| internal SocketUser(DiscordSocketClient discord, ulong id) | |||
| : base(discord, id) | |||
| @@ -3,6 +3,7 @@ | |||
| <OutputType>Exe</OutputType> | |||
| <RootNamespace>Discord</RootNamespace> | |||
| <TargetFramework>netcoreapp1.1</TargetFramework> | |||
| <DebugType>portable</DebugType> | |||
| <PackageTargetFallback>$(PackageTargetFallback);portable-net45+win8+wp8+wpa81</PackageTargetFallback> | |||
| </PropertyGroup> | |||
| <ItemGroup> | |||
| @@ -23,8 +24,8 @@ | |||
| <PackageReference Include="Akavache" Version="5.0.0" /> | |||
| <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" /> | |||
| <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> | |||
| <PackageReference Include="xunit" Version="2.3.1" /> | |||
| <PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" /> | |||
| <PackageReference Include="xunit.runner.reporters" Version="2.3.1" /> | |||
| <PackageReference Include="xunit" Version="2.4.0" /> | |||
| <PackageReference Include="xunit.runner.visualstudio" Version="2.4.0" /> | |||
| <PackageReference Include="xunit.runner.reporters" Version="2.4.0" /> | |||
| </ItemGroup> | |||
| </Project> | |||
| @@ -5,8 +5,25 @@ using Xunit; | |||
| namespace Discord | |||
| { | |||
| public class GuidPermissionsTests | |||
| public partial class Tests | |||
| { | |||
| /// <summary> | |||
| /// Tests the behavior of modifying the ExplicitContentFilter property of a Guild. | |||
| /// </summary> | |||
| [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); | |||
| } | |||
| } | |||
| /// <summary> | |||
| /// Tests the behavior of the GuildPermissions class. | |||
| /// </summary> | |||
| [Fact] | |||
| public Task TestGuildPermission() | |||
| { | |||
| @@ -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<TestModule>(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<TestModule>(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<TestModule>(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<TestModule>(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<int> ManyInts { get; set; } | |||
| } | |||
| public sealed class CustomTypeReader : TypeReader | |||
| { | |||
| public override Task<TypeReaderResult> 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); | |||
| } | |||
| } | |||