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..3abbfb632 100644 --- a/docs/guides/voice/samples/joining_audio.cs +++ b/docs/guides/voice/samples/joining_audio.cs @@ -1,4 +1,5 @@ -[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 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/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs index 11f4ce276..432b75f27 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) // 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.Core/Entities/Channels/INestedChannel.cs b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs index 8ce759ad9..f45a0114a 100644 --- a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs @@ -26,6 +26,11 @@ 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); /// /// Creates a new invite to this channel. 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..c321cd2e3 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. /// /// 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/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.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/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/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 36406bfea..7ed57ed6f 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); @@ -1426,8 +1429,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 960932807..58de066fb 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); //Invites /// diff --git a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs index 1cb8f1b85..aaaaad373 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs @@ -57,6 +57,9 @@ 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); //Invites /// diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index b8b66f802..7b77dafc7 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. diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 9c4ecd848..8cd81f218 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) { 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.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index 1fc1dad82..728a4ad53 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 c3272b597..48a7d7c3b 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..ce8cd08de 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) { 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/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() {