@@ -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); | |||
} | |||
} |