@@ -1,7 +1,7 @@ | |||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> | |||
<PropertyGroup> | |||
<VersionPrefix>1.0.0</VersionPrefix> | |||
<VersionSuffix>rc3</VersionSuffix> | |||
<VersionPrefix>1.0.1</VersionPrefix> | |||
<VersionSuffix></VersionSuffix> | |||
<Authors>RogueException</Authors> | |||
<PackageTags>discord;discordapp</PackageTags> | |||
<PackageProjectUrl>https://github.com/RogueException/Discord.Net</PackageProjectUrl> | |||
@@ -1,4 +1,4 @@ | |||
# Discord.Net v1.0.0-rc | |||
# Discord.Net | |||
[](https://www.nuget.org/packages/Discord.Net) | |||
[](https://www.myget.org/feed/Packages/discord-net) | |||
[](https://ci.appveyor.com/project/RogueException/discord-net/branch/dev) | |||
@@ -34,7 +34,7 @@ after_build: | |||
if ($Env:APPVEYOR_REPO_TAG -eq "true") { | |||
nuget pack src\Discord.Net\Discord.Net.nuspec -OutputDirectory "artifacts" -properties suffix="" | |||
} else { | |||
nuget pack src\Discord.Net\Discord.Net.nuspec -OutputDirectory "artifacts" -properties suffix="-$Env:BUILD" | |||
nuget pack src\Discord.Net\Discord.Net.nuspec -OutputDirectory "artifacts" -properties suffix="-build-$Env:BUILD" | |||
} | |||
- ps: Get-ChildItem artifacts\*.nupkg | % { Push-AppveyorArtifact $_.FullName -FileName $_.Name } | |||
@@ -45,7 +45,7 @@ Discord.Net's implementation of Modules is influenced heavily from | |||
ASP.Net Core's Controller pattern. This means that the lifetime of a | |||
module instance is only as long as the command being invoked. | |||
**Avoid using long-running code** in your modules whereever possible. | |||
**Avoid using long-running code** in your modules wherever possible. | |||
You should **not** be implementing very much logic into your modules; | |||
outsource to a service for that. | |||
@@ -167,8 +167,8 @@ a dependency map. | |||
Modules are constructed using Dependency Injection. Any parameters | |||
that are placed in the constructor must be injected into an | |||
@Discord.Commands.IDependencyMap. Alternatively, you may accept an | |||
IDependencyMap as an argument and extract services yourself. | |||
@System.IServiceProvider. Alternatively, you may accept an | |||
IServiceProvider as an argument and extract services yourself. | |||
### Module Properties | |||
@@ -205,21 +205,20 @@ you use DI when writing your modules. | |||
### Setup | |||
First, you need to create an @Discord.Commands.IDependencyMap. | |||
The library includes @Discord.Commands.DependencyMap to help with | |||
this, however you may create your own IDependencyMap if you wish. | |||
First, you need to create an @System.IServiceProvider | |||
You may create your own IServiceProvider if you wish. | |||
Next, add the dependencies your modules will use to the map. | |||
Finally, pass the map into the `LoadAssembly` method. | |||
Your modules will automatically be loaded with this dependency map. | |||
[!code-csharp[DependencyMap Setup](samples/dependency_map_setup.cs)] | |||
[!code-csharp[IServiceProvider Setup](samples/dependency_map_setup.cs)] | |||
### Usage in Modules | |||
In the constructor of your module, any parameters will be filled in by | |||
the @Discord.Commands.IDependencyMap you pass into `LoadAssembly`. | |||
the @System.IServiceProvider you pass into `LoadAssembly`. | |||
Any publicly settable properties will also be filled in the same manner. | |||
@@ -228,12 +227,12 @@ Any publicly settable properties will also be filled in the same manner. | |||
being injected. | |||
>[!NOTE] | |||
>If you accept `CommandService` or `IDependencyMap` as a parameter in | |||
>If you accept `CommandService` or `IServiceProvider` as a parameter in | |||
your constructor or as an injectable property, these entries will be filled | |||
by the CommandService the module was loaded from, and the DependencyMap passed | |||
by the CommandService the module was loaded from, and the ServiceProvider passed | |||
into it, respectively. | |||
[!code-csharp[DependencyMap in Modules](samples/dependency_module.cs)] | |||
[!code-csharp[ServiceProvider in Modules](samples/dependency_module.cs)] | |||
# Preconditions | |||
@@ -1,14 +1,16 @@ | |||
using System; | |||
using System.Threading.Tasks; | |||
using System.Reflection; | |||
using Discord; | |||
using Discord.WebSocket; | |||
using Discord.Commands; | |||
using Microsoft.Extensions.DependencyInjection; | |||
public class Program | |||
{ | |||
private CommandService commands; | |||
private DiscordSocketClient client; | |||
private DependencyMap map; | |||
private IServiceProvider services; | |||
static void Main(string[] args) => new Program().Start().GetAwaiter().GetResult(); | |||
@@ -19,38 +21,40 @@ public class Program | |||
string token = "bot token here"; | |||
map = new DependencyMap(); | |||
services = new ServiceCollection() | |||
.BuildServiceProvider(); | |||
await InstallCommands(); | |||
await client.LoginAsync(TokenType.Bot, token); | |||
await client.ConnectAsync(); | |||
await client.StartAsync(); | |||
await Task.Delay(-1); | |||
} | |||
public async Task InstallCommands() | |||
{ | |||
// Hook the MessageReceived Event into our Command Handler | |||
client.MessageReceived += HandleCommand; | |||
// Discover all of the commands in this assembly and load them. | |||
// Discover all of the commands in this assembly and load them. | |||
await commands.AddModulesAsync(Assembly.GetEntryAssembly()); | |||
} | |||
public async Task HandleCommand(SocketMessage messageParam) | |||
{ | |||
{ | |||
// Don't process the command if it was a System Message | |||
var message = messageParam as SocketUserMessage; | |||
if (message == null) return; | |||
// 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 if it starts with '!' or a mention prefix | |||
if (!(message.HasCharPrefix('!', ref argPos) || message.HasMentionPrefix(client.CurrentUser, ref argPos))) return; | |||
// 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 if it starts with '!' or a mention prefix | |||
if (!(message.HasCharPrefix('!', ref argPos) || message.HasMentionPrefix(client.CurrentUser, ref argPos))) return; | |||
// Create a Command Context | |||
var context = new CommandContext(client, message); | |||
// Execute the command. (result does not indicate a return value, | |||
// rather an object stating if the command executed succesfully) | |||
var result = await commands.ExecuteAsync(context, argPos, map); | |||
// rather an object stating if the command executed successfully) | |||
var result = await commands.ExecuteAsync(context, argPos, service); | |||
if (!result.IsSuccess) | |||
await context.Channel.SendMessageAsync(result.ErrorReason); | |||
} | |||
} | |||
} |
@@ -7,12 +7,11 @@ public class Commands | |||
{ | |||
public async Task Install(DiscordSocketClient client) | |||
{ | |||
// Here, we will inject the Dependency Map with | |||
// Here, we will inject the ServiceProvider with | |||
// all of the services our client will use. | |||
_map.Add(client); | |||
_map.Add(commands); | |||
_map.Add(new NotificationService(_map)); | |||
_map.Add(new DatabaseService(_map)); | |||
_serviceCollection.AddSingleton(client) | |||
_serviceCollection.AddSingleton(new NotificationService()) | |||
_serviceCollection.AddSingleton(new DatabaseService()) | |||
// ... | |||
await _commands.AddModulesAsync(Assembly.GetEntryAssembly()); | |||
} | |||
@@ -2,16 +2,18 @@ | |||
using Discord.Commands; | |||
using Discord.WebSocket; | |||
using Microsoft.Extensions.DependencyInjection; | |||
using System; | |||
using System.Threading.Tasks; | |||
// Inherit from PreconditionAttribute | |||
public class RequireOwnerAttribute : PreconditionAttribute | |||
{ | |||
// Override the CheckPermissions method | |||
public async override Task<PreconditionResult> CheckPermissions(ICommandContext context, CommandInfo command, IDependencyMap map) | |||
public async override Task<PreconditionResult> CheckPermissions(ICommandContext context, CommandInfo command, IServiceProvider services) | |||
{ | |||
// Get the ID of the bot's owner | |||
var ownerId = (await map.Get<DiscordSocketClient>().GetApplicationInfoAsync()).Owner.Id; | |||
var ownerId = (await services.GetService<DiscordSocketClient>().GetApplicationInfoAsync()).Owner.Id; | |||
// If this command was executed by that user, return a success | |||
if (context.User.Id == ownerId) | |||
return PreconditionResult.FromSuccess(); | |||
@@ -8,7 +8,11 @@ public class Program | |||
public async Task MainAsync() | |||
{ | |||
_client = new DiscordSocketClient(); | |||
// When working with events that have Cacheable<IMessage, ulong> parameters, | |||
// you must enable the message cache in your config settings if you plan to | |||
// use the cached message entity. | |||
var _config = new DiscordSocketConfig { MessageCacheSize = 100 }; | |||
_client = new DiscordSocketClient(_config); | |||
await _client.LoginAsync(TokenType.Bot, "bot token"); | |||
await _client.StartAsync(); | |||
@@ -25,7 +29,8 @@ public class Program | |||
private async Task MessageUpdated(Cacheable<IMessage, ulong> before, SocketMessage after, ISocketMessageChannel channel) | |||
{ | |||
// If the message was not in the cache, downloading it will result in getting a copy of `after`. | |||
var message = await before.GetOrDownloadAsync(); | |||
Console.WriteLine($"{message} -> {after}"); | |||
} | |||
} | |||
} |
@@ -211,7 +211,7 @@ For your reference, you may view the [completed program]. | |||
# Building a bot with commands | |||
This section will show you how to write a program that is ready for | |||
[commands](commands.md). Note that this will not be explaining _how_ | |||
[commands](commands/commands.md). Note that this will not be explaining _how_ | |||
to write commands or services, it will only be covering the general | |||
structure. | |||
@@ -224,4 +224,4 @@ should be to separate the program (initialization and command handler), | |||
the modules (handle commands), and the services (persistent storage, | |||
pure functions, data manipulation). | |||
**todo:** diagram of bot structure | |||
**todo:** diagram of bot structure |
@@ -30,8 +30,8 @@ class Program | |||
LogLevel = LogSeverity.Info, | |||
// If you or another service needs to do anything with messages | |||
// (eg. checking Reactions), you should probably | |||
// set the MessageCacheSize here. | |||
// (eg. checking Reactions, checking the content of edited/deleted messages), | |||
// you must set the MessageCacheSize. You may adjust the number as needed. | |||
//MessageCacheSize = 50, | |||
// If your platform doesn't have native websockets, | |||
@@ -41,7 +41,7 @@ class Program | |||
}); | |||
} | |||
// Create a named logging handler, so it can be re-used by addons | |||
// Example of a logging handler. This can be re-used by addons | |||
// that ask for a Func<LogMessage, Task>. | |||
private static Task Logger(LogMessage message) | |||
{ | |||
@@ -65,6 +65,13 @@ class Program | |||
} | |||
Console.WriteLine($"{DateTime.Now,-19} [{message.Severity,8}] {message.Source}: {message.Message}"); | |||
Console.ForegroundColor = cc; | |||
// If you get an error saying 'CompletedTask' doesn't exist, | |||
// your project is targeting .NET 4.5.2 or lower. You'll need | |||
// to adjust your project's target framework to 4.6 or higher | |||
// (instructions for this are easily Googled). | |||
// If you *need* to run on .NET 4.5 for compat/other reasons, | |||
// the alternative is to 'return Task.Delay(0);' instead. | |||
return Task.CompletedTask; | |||
} | |||
@@ -92,16 +99,17 @@ class Program | |||
// and other dependencies that your commands might need. | |||
_map.AddSingleton(new SomeServiceClass()); | |||
// Either search the program and add all Module classes that can be found: | |||
await _commands.AddModulesAsync(Assembly.GetEntryAssembly()); | |||
// Or add Modules manually if you prefer to be a little more explicit: | |||
await _commands.AddModuleAsync<SomeModule>(); | |||
// When all your required services are in the collection, build the container. | |||
// Tip: There's an overload taking in a 'validateScopes' bool to make sure | |||
// you haven't made any mistakes in your dependency graph. | |||
_services = _map.BuildServiceProvider(); | |||
// Either search the program and add all Module classes that can be found. | |||
// Module classes *must* be marked 'public' or they will be ignored. | |||
await _commands.AddModulesAsync(Assembly.GetEntryAssembly()); | |||
// Or add Modules manually if you prefer to be a little more explicit: | |||
await _commands.AddModuleAsync<SomeModule>(); | |||
// Subscribe a handler to see if a message invokes a command. | |||
_client.MessageReceived += HandleCommandAsync; | |||
} | |||
@@ -120,7 +128,7 @@ class Program | |||
// commands to be invoked by mentioning the bot instead. | |||
if (msg.HasCharPrefix('!', ref pos) /* || msg.HasMentionPrefix(_client.CurrentUser, ref pos) */) | |||
{ | |||
// Create a Command Context | |||
// Create a Command Context. | |||
var context = new SocketCommandContext(_client, msg); | |||
// Execute the command. (result does not indicate a return value, | |||
@@ -42,7 +42,7 @@ events are delegates, but are still registered the same. | |||
For example, let's look at [DiscordSocketClient.MessageReceived](xref:Discord.WebSocket.DiscordSocketClient#Discord_WebSocket_DiscordSocketClient_MessageReceived) | |||
To hook an event into MessageReceived, we now use the following code: | |||
[!code-csharp[Event Registration](guides/samples/migrating/event.cs)] | |||
[!code-csharp[Event Registration](samples/event.cs)] | |||
> **All Event Handlers in 1.0 MUST return Task!** | |||
@@ -50,7 +50,7 @@ If your event handler is marked as `async`, it will automatically return `Task`. | |||
if you do not need to execute asynchronus code, do _not_ mark your handler as `async`, and instead, | |||
stick a `return Task.CompletedTask` at the bottom. | |||
[!code-csharp[Sync Event Registration](guides/samples/migrating/sync_event.cs)] | |||
[!code-csharp[Sync Event Registration](samples/sync_event.cs)] | |||
**Event handlers no longer require a sender.** The only arguments your event handler needs to accept | |||
are the parameters used by the event. It is recommended to look at the event in IntelliSense or on the | |||
@@ -3,7 +3,7 @@ private async Task SendAsync(IAudioClient client, string path) | |||
// Create FFmpeg using the previous example | |||
var ffmpeg = CreateStream(path); | |||
var output = ffmpeg.StandardOutput.BaseStream; | |||
var discord = client.CreatePCMStream(AudioApplication.Mixed, 1920); | |||
var discord = client.CreatePCMStream(AudioApplication.Mixed); | |||
await output.CopyToAsync(discord); | |||
await discord.FlushAsync(); | |||
} |
@@ -6,6 +6,13 @@ namespace Discord.Commands | |||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] | |||
public abstract class PreconditionAttribute : Attribute | |||
{ | |||
/// <summary> | |||
/// Specify a group that this precondition belongs to. Preconditions of the same group require only one | |||
/// of the preconditions to pass in order to be successful (A || B). Specifying <see cref="Group"/> = <see cref="null"/> | |||
/// or not at all will require *all* preconditions to pass, just like normal (A && B). | |||
/// </summary> | |||
public string Group { get; set; } = null; | |||
public abstract Task<PreconditionResult> CheckPermissions(ICommandContext context, CommandInfo command, IServiceProvider services); | |||
} | |||
} |
@@ -44,14 +44,16 @@ namespace Discord.Commands | |||
public override async Task<PreconditionResult> CheckPermissions(ICommandContext context, CommandInfo command, IServiceProvider services) | |||
{ | |||
var guildUser = await context.Guild.GetCurrentUserAsync(); | |||
IGuildUser guildUser = null; | |||
if (context.Guild != null) | |||
guildUser = await context.Guild.GetCurrentUserAsync().ConfigureAwait(false); | |||
if (GuildPermission.HasValue) | |||
{ | |||
if (guildUser == null) | |||
return PreconditionResult.FromError("Command must be used in a guild channel"); | |||
if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) | |||
return PreconditionResult.FromError($"Command requires guild permission {GuildPermission.Value}"); | |||
return PreconditionResult.FromError($"Bot requires guild permission {GuildPermission.Value}"); | |||
} | |||
if (ChannelPermission.HasValue) | |||
@@ -65,7 +67,7 @@ namespace Discord.Commands | |||
perms = ChannelPermissions.All(guildChannel); | |||
if (!perms.Has(ChannelPermission.Value)) | |||
return PreconditionResult.FromError($"Command requires channel permission {ChannelPermission.Value}"); | |||
return PreconditionResult.FromError($"Bot requires channel permission {ChannelPermission.Value}"); | |||
} | |||
return PreconditionResult.FromSuccess(); | |||
@@ -52,7 +52,7 @@ namespace Discord.Commands | |||
if (guildUser == null) | |||
return Task.FromResult(PreconditionResult.FromError("Command must be used in a guild channel")); | |||
if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) | |||
return Task.FromResult(PreconditionResult.FromError($"Command requires guild permission {GuildPermission.Value}")); | |||
return Task.FromResult(PreconditionResult.FromError($"User requires guild permission {GuildPermission.Value}")); | |||
} | |||
if (ChannelPermission.HasValue) | |||
@@ -66,7 +66,7 @@ namespace Discord.Commands | |||
perms = ChannelPermissions.All(guildChannel); | |||
if (!perms.Has(ChannelPermission.Value)) | |||
return Task.FromResult(PreconditionResult.FromError($"Command requires channel permission {ChannelPermission.Value}")); | |||
return Task.FromResult(PreconditionResult.FromError($"User requires channel permission {ChannelPermission.Value}")); | |||
} | |||
return Task.FromResult(PreconditionResult.FromSuccess()); | |||
@@ -10,10 +10,11 @@ namespace Discord.Commands.Builders | |||
{ | |||
private readonly List<PreconditionAttribute> _preconditions; | |||
private readonly List<ParameterBuilder> _parameters; | |||
private readonly List<Attribute> _attributes; | |||
private readonly List<string> _aliases; | |||
public ModuleBuilder Module { get; } | |||
internal Func<ICommandContext, object[], IServiceProvider, Task> Callback { get; set; } | |||
internal Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> Callback { get; set; } | |||
public string Name { get; set; } | |||
public string Summary { get; set; } | |||
@@ -24,6 +25,7 @@ namespace Discord.Commands.Builders | |||
public IReadOnlyList<PreconditionAttribute> Preconditions => _preconditions; | |||
public IReadOnlyList<ParameterBuilder> Parameters => _parameters; | |||
public IReadOnlyList<Attribute> Attributes => _attributes; | |||
public IReadOnlyList<string> Aliases => _aliases; | |||
//Automatic | |||
@@ -33,10 +35,11 @@ namespace Discord.Commands.Builders | |||
_preconditions = new List<PreconditionAttribute>(); | |||
_parameters = new List<ParameterBuilder>(); | |||
_attributes = new List<Attribute>(); | |||
_aliases = new List<string>(); | |||
} | |||
//User-defined | |||
internal CommandBuilder(ModuleBuilder module, string primaryAlias, Func<ICommandContext, object[], IServiceProvider, Task> callback) | |||
internal CommandBuilder(ModuleBuilder module, string primaryAlias, Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> callback) | |||
: this(module) | |||
{ | |||
Discord.Preconditions.NotNull(primaryAlias, nameof(primaryAlias)); | |||
@@ -77,12 +80,17 @@ namespace Discord.Commands.Builders | |||
{ | |||
for (int i = 0; i < aliases.Length; i++) | |||
{ | |||
var alias = aliases[i] ?? ""; | |||
string alias = aliases[i] ?? ""; | |||
if (!_aliases.Contains(alias)) | |||
_aliases.Add(alias); | |||
} | |||
return this; | |||
} | |||
public CommandBuilder AddAttributes(params Attribute[] attributes) | |||
{ | |||
_attributes.AddRange(attributes); | |||
return this; | |||
} | |||
public CommandBuilder AddPrecondition(PreconditionAttribute precondition) | |||
{ | |||
_preconditions.Add(precondition); | |||
@@ -122,11 +130,11 @@ namespace Discord.Commands.Builders | |||
var firstMultipleParam = _parameters.FirstOrDefault(x => x.IsMultiple); | |||
if ((firstMultipleParam != null) && (firstMultipleParam != lastParam)) | |||
throw new InvalidOperationException("Only the last parameter in a command may have the Multiple flag."); | |||
throw new InvalidOperationException($"Only the last parameter in a command may have the Multiple flag. Parameter: {firstMultipleParam.Name} in {PrimaryAlias}"); | |||
var firstRemainderParam = _parameters.FirstOrDefault(x => x.IsRemainder); | |||
if ((firstRemainderParam != null) && (firstRemainderParam != lastParam)) | |||
throw new InvalidOperationException("Only the last parameter in a command may have the Remainder flag."); | |||
throw new InvalidOperationException($"Only the last parameter in a command may have the Remainder flag. Parameter: {firstRemainderParam.Name} in {PrimaryAlias}"); | |||
} | |||
return new CommandInfo(this, info, service); | |||
@@ -10,6 +10,7 @@ namespace Discord.Commands.Builders | |||
private readonly List<CommandBuilder> _commands; | |||
private readonly List<ModuleBuilder> _submodules; | |||
private readonly List<PreconditionAttribute> _preconditions; | |||
private readonly List<Attribute> _attributes; | |||
private readonly List<string> _aliases; | |||
public CommandService Service { get; } | |||
@@ -21,6 +22,7 @@ namespace Discord.Commands.Builders | |||
public IReadOnlyList<CommandBuilder> Commands => _commands; | |||
public IReadOnlyList<ModuleBuilder> Modules => _submodules; | |||
public IReadOnlyList<PreconditionAttribute> Preconditions => _preconditions; | |||
public IReadOnlyList<Attribute> Attributes => _attributes; | |||
public IReadOnlyList<string> Aliases => _aliases; | |||
//Automatic | |||
@@ -32,6 +34,7 @@ namespace Discord.Commands.Builders | |||
_commands = new List<CommandBuilder>(); | |||
_submodules = new List<ModuleBuilder>(); | |||
_preconditions = new List<PreconditionAttribute>(); | |||
_attributes = new List<Attribute>(); | |||
_aliases = new List<string>(); | |||
} | |||
//User-defined | |||
@@ -63,18 +66,23 @@ namespace Discord.Commands.Builders | |||
{ | |||
for (int i = 0; i < aliases.Length; i++) | |||
{ | |||
var alias = aliases[i] ?? ""; | |||
string alias = aliases[i] ?? ""; | |||
if (!_aliases.Contains(alias)) | |||
_aliases.Add(alias); | |||
} | |||
return this; | |||
} | |||
public ModuleBuilder AddAttributes(params Attribute[] attributes) | |||
{ | |||
_attributes.AddRange(attributes); | |||
return this; | |||
} | |||
public ModuleBuilder AddPrecondition(PreconditionAttribute precondition) | |||
{ | |||
_preconditions.Add(precondition); | |||
return this; | |||
} | |||
public ModuleBuilder AddCommand(string primaryAlias, Func<ICommandContext, object[], IServiceProvider, Task> callback, Action<CommandBuilder> createFunc) | |||
public ModuleBuilder AddCommand(string primaryAlias, Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> callback, Action<CommandBuilder> createFunc) | |||
{ | |||
var builder = new CommandBuilder(this, primaryAlias, callback); | |||
createFunc(builder); | |||
@@ -12,25 +12,42 @@ namespace Discord.Commands | |||
{ | |||
private static readonly TypeInfo _moduleTypeInfo = typeof(IModuleBase).GetTypeInfo(); | |||
public static IEnumerable<TypeInfo> Search(Assembly assembly) | |||
public static async Task<IReadOnlyList<TypeInfo>> SearchAsync(Assembly assembly, CommandService service) | |||
{ | |||
foreach (var type in assembly.ExportedTypes) | |||
bool IsLoadableModule(TypeInfo info) | |||
{ | |||
var typeInfo = type.GetTypeInfo(); | |||
if (IsValidModuleDefinition(typeInfo) && | |||
!typeInfo.IsDefined(typeof(DontAutoLoadAttribute))) | |||
return info.DeclaredMethods.Any(x => x.GetCustomAttribute<CommandAttribute>() != null) && | |||
info.GetCustomAttribute<DontAutoLoadAttribute>() == null; | |||
} | |||
var result = new List<TypeInfo>(); | |||
foreach (var typeInfo in assembly.DefinedTypes) | |||
{ | |||
if (typeInfo.IsPublic || typeInfo.IsNestedPublic) | |||
{ | |||
yield return typeInfo; | |||
if (IsValidModuleDefinition(typeInfo) && | |||
!typeInfo.IsDefined(typeof(DontAutoLoadAttribute))) | |||
{ | |||
result.Add(typeInfo); | |||
} | |||
} | |||
else if (IsLoadableModule(typeInfo)) | |||
{ | |||
await service._cmdLogger.WarningAsync($"Class {typeInfo.FullName} is not public and cannot be loaded. To suppress this message, mark the class with {nameof(DontAutoLoadAttribute)}."); | |||
} | |||
} | |||
return result; | |||
} | |||
public static Dictionary<Type, ModuleInfo> Build(CommandService service, params TypeInfo[] validTypes) => Build(validTypes, service); | |||
public static Dictionary<Type, ModuleInfo> Build(IEnumerable<TypeInfo> validTypes, CommandService service) | |||
public static Task<Dictionary<Type, ModuleInfo>> BuildAsync(CommandService service, params TypeInfo[] validTypes) => BuildAsync(validTypes, service); | |||
public static async Task<Dictionary<Type, ModuleInfo>> BuildAsync(IEnumerable<TypeInfo> validTypes, CommandService service) | |||
{ | |||
/*if (!validTypes.Any()) | |||
throw new InvalidOperationException("Could not find any valid modules from the given selection");*/ | |||
var topLevelGroups = validTypes.Where(x => x.DeclaringType == null); | |||
var subGroups = validTypes.Intersect(topLevelGroups); | |||
@@ -48,10 +65,13 @@ namespace Discord.Commands | |||
BuildModule(module, typeInfo, service); | |||
BuildSubTypes(module, typeInfo.DeclaredNestedTypes, builtTypes, service); | |||
builtTypes.Add(typeInfo); | |||
result[typeInfo.AsType()] = module.Build(service); | |||
} | |||
await service._cmdLogger.DebugAsync($"Successfully built {builtTypes.Count} modules.").ConfigureAwait(false); | |||
return result; | |||
} | |||
@@ -102,6 +122,9 @@ namespace Discord.Commands | |||
case PreconditionAttribute precondition: | |||
builder.AddPrecondition(precondition); | |||
break; | |||
default: | |||
builder.AddAttributes(attribute); | |||
break; | |||
} | |||
} | |||
@@ -128,26 +151,35 @@ namespace Discord.Commands | |||
foreach (var attribute in attributes) | |||
{ | |||
// TODO: C#7 type switch | |||
if (attribute is CommandAttribute) | |||
switch (attribute) | |||
{ | |||
var cmdAttr = attribute as CommandAttribute; | |||
builder.AddAliases(cmdAttr.Text); | |||
builder.RunMode = cmdAttr.RunMode; | |||
builder.Name = builder.Name ?? cmdAttr.Text; | |||
case CommandAttribute command: | |||
builder.AddAliases(command.Text); | |||
builder.RunMode = command.RunMode; | |||
builder.Name = builder.Name ?? command.Text; | |||
break; | |||
case NameAttribute name: | |||
builder.Name = name.Text; | |||
break; | |||
case PriorityAttribute priority: | |||
builder.Priority = priority.Priority; | |||
break; | |||
case SummaryAttribute summary: | |||
builder.Summary = summary.Text; | |||
break; | |||
case RemarksAttribute remarks: | |||
builder.Remarks = remarks.Text; | |||
break; | |||
case AliasAttribute alias: | |||
builder.AddAliases(alias.Aliases); | |||
break; | |||
case PreconditionAttribute precondition: | |||
builder.AddPrecondition(precondition); | |||
break; | |||
default: | |||
builder.AddAttributes(attribute); | |||
break; | |||
} | |||
else if (attribute is NameAttribute) | |||
builder.Name = (attribute as NameAttribute).Text; | |||
else if (attribute is PriorityAttribute) | |||
builder.Priority = (attribute as PriorityAttribute).Priority; | |||
else if (attribute is SummaryAttribute) | |||
builder.Summary = (attribute as SummaryAttribute).Text; | |||
else if (attribute is RemarksAttribute) | |||
builder.Remarks = (attribute as RemarksAttribute).Text; | |||
else if (attribute is AliasAttribute) | |||
builder.AddAliases((attribute as AliasAttribute).Aliases); | |||
else if (attribute is PreconditionAttribute) | |||
builder.AddPrecondition(attribute as PreconditionAttribute); | |||
} | |||
if (builder.Name == null) | |||
@@ -165,22 +197,34 @@ namespace Discord.Commands | |||
var createInstance = ReflectionUtils.CreateBuilder<IModuleBase>(typeInfo, service); | |||
builder.Callback = async (ctx, args, map) => | |||
async Task<IResult> ExecuteCallback(ICommandContext context, object[] args, IServiceProvider services, CommandInfo cmd) | |||
{ | |||
var instance = createInstance(map); | |||
instance.SetContext(ctx); | |||
var instance = createInstance(services); | |||
instance.SetContext(context); | |||
try | |||
{ | |||
instance.BeforeExecute(); | |||
instance.BeforeExecute(cmd); | |||
var task = method.Invoke(instance, args) as Task ?? Task.Delay(0); | |||
await task.ConfigureAwait(false); | |||
if (task is Task<RuntimeResult> resultTask) | |||
{ | |||
return await resultTask.ConfigureAwait(false); | |||
} | |||
else | |||
{ | |||
await task.ConfigureAwait(false); | |||
return ExecuteResult.FromSuccess(); | |||
} | |||
} | |||
finally | |||
{ | |||
instance.AfterExecute(); | |||
instance.AfterExecute(cmd); | |||
(instance as IDisposable)?.Dispose(); | |||
} | |||
}; | |||
} | |||
builder.Callback = ExecuteCallback; | |||
} | |||
private static void BuildParameter(ParameterBuilder builder, System.Reflection.ParameterInfo paramInfo, int position, int count, CommandService service) | |||
@@ -195,24 +239,30 @@ namespace Discord.Commands | |||
foreach (var attribute in attributes) | |||
{ | |||
// TODO: C#7 type switch | |||
if (attribute is SummaryAttribute) | |||
builder.Summary = (attribute as SummaryAttribute).Text; | |||
else if (attribute is OverrideTypeReaderAttribute) | |||
builder.TypeReader = GetTypeReader(service, paramType, (attribute as OverrideTypeReaderAttribute).TypeReader); | |||
else if (attribute is ParameterPreconditionAttribute) | |||
builder.AddPrecondition(attribute as ParameterPreconditionAttribute); | |||
else if (attribute is ParamArrayAttribute) | |||
{ | |||
builder.IsMultiple = true; | |||
paramType = paramType.GetElementType(); | |||
} | |||
else if (attribute is RemainderAttribute) | |||
switch (attribute) | |||
{ | |||
if (position != count-1) | |||
throw new InvalidOperationException("Remainder parameters must be the last parameter in a command."); | |||
builder.IsRemainder = true; | |||
case SummaryAttribute summary: | |||
builder.Summary = summary.Text; | |||
break; | |||
case OverrideTypeReaderAttribute typeReader: | |||
builder.TypeReader = GetTypeReader(service, paramType, typeReader.TypeReader); | |||
break; | |||
case ParamArrayAttribute _: | |||
builder.IsMultiple = true; | |||
paramType = paramType.GetElementType(); | |||
break; | |||
case ParameterPreconditionAttribute precon: | |||
builder.AddPrecondition(precon); | |||
break; | |||
case RemainderAttribute _: | |||
if (position != count - 1) | |||
throw new InvalidOperationException($"Remainder parameters must be the last parameter in a command. Parameter: {paramInfo.Name} in {paramInfo.Member.DeclaringType.Name}.{paramInfo.Member.Name}"); | |||
builder.IsRemainder = true; | |||
break; | |||
default: | |||
builder.AddAttributes(attribute); | |||
break; | |||
} | |||
} | |||
@@ -258,9 +308,9 @@ namespace Discord.Commands | |||
private static bool IsValidCommandDefinition(MethodInfo methodInfo) | |||
{ | |||
return methodInfo.IsDefined(typeof(CommandAttribute)) && | |||
(methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(void)) && | |||
(methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task<RuntimeResult>)) && | |||
!methodInfo.IsStatic && | |||
!methodInfo.IsGenericMethod; | |||
} | |||
} | |||
} | |||
} |
@@ -8,7 +8,8 @@ namespace Discord.Commands.Builders | |||
{ | |||
public class ParameterBuilder | |||
{ | |||
private readonly List<ParameterPreconditionAttribute> _preconditions; | |||
private readonly List<ParameterPreconditionAttribute> _preconditions; | |||
private readonly List<Attribute> _attributes; | |||
public CommandBuilder Command { get; } | |||
public string Name { get; internal set; } | |||
@@ -22,11 +23,13 @@ namespace Discord.Commands.Builders | |||
public string Summary { get; set; } | |||
public IReadOnlyList<ParameterPreconditionAttribute> Preconditions => _preconditions; | |||
public IReadOnlyList<Attribute> Attributes => _attributes; | |||
//Automatic | |||
internal ParameterBuilder(CommandBuilder command) | |||
{ | |||
_preconditions = new List<ParameterPreconditionAttribute>(); | |||
_attributes = new List<Attribute>(); | |||
Command = command; | |||
} | |||
@@ -49,7 +52,7 @@ namespace Discord.Commands.Builders | |||
TypeReader = Command.Module.Service.GetDefaultTypeReader(type); | |||
if (TypeReader == null) | |||
throw new InvalidOperationException($"{type} does not have a TypeReader registered for it"); | |||
throw new InvalidOperationException($"{type} does not have a TypeReader registered for it. Parameter: {Name} in {Command.PrimaryAlias}"); | |||
if (type.GetTypeInfo().IsValueType) | |||
DefaultValue = Activator.CreateInstance(type); | |||
@@ -84,6 +87,11 @@ namespace Discord.Commands.Builders | |||
return this; | |||
} | |||
public ParameterBuilder AddAttributes(params Attribute[] attributes) | |||
{ | |||
_attributes.AddRange(attributes); | |||
return this; | |||
} | |||
public ParameterBuilder AddPrecondition(ParameterPreconditionAttribute precondition) | |||
{ | |||
_preconditions.Add(precondition); | |||
@@ -18,6 +18,9 @@ | |||
UnmetPrecondition, | |||
//Execute | |||
Exception | |||
Exception, | |||
//Runtime | |||
Unsuccessful | |||
} | |||
} |
@@ -18,11 +18,11 @@ namespace Discord.Commands | |||
public Task<PreconditionResult> CheckPreconditionsAsync(ICommandContext context, IServiceProvider services = null) | |||
=> Command.CheckPreconditionsAsync(context, services); | |||
public Task<ParseResult> ParseAsync(ICommandContext context, SearchResult searchResult, PreconditionResult? preconditionResult = null) | |||
=> Command.ParseAsync(context, Alias.Length, searchResult, preconditionResult); | |||
public Task<ExecuteResult> ExecuteAsync(ICommandContext context, IEnumerable<object> argList, IEnumerable<object> paramList, IServiceProvider services) | |||
public Task<ParseResult> ParseAsync(ICommandContext context, SearchResult searchResult, PreconditionResult preconditionResult = null, IServiceProvider services = null) | |||
=> Command.ParseAsync(context, Alias.Length, searchResult, preconditionResult, services); | |||
public Task<IResult> ExecuteAsync(ICommandContext context, IEnumerable<object> argList, IEnumerable<object> paramList, IServiceProvider services) | |||
=> Command.ExecuteAsync(context, argList, paramList, services); | |||
public Task<ExecuteResult> ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) | |||
public Task<IResult> ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) | |||
=> Command.ExecuteAsync(context, parseResult, services); | |||
} | |||
} |
@@ -1,4 +1,5 @@ | |||
using System.Collections.Immutable; | |||
using System; | |||
using System.Collections.Immutable; | |||
using System.Text; | |||
using System.Threading.Tasks; | |||
@@ -13,7 +14,7 @@ namespace Discord.Commands | |||
QuotedParameter | |||
} | |||
public static async Task<ParseResult> ParseArgs(CommandInfo command, ICommandContext context, string input, int startPos) | |||
public static async Task<ParseResult> ParseArgs(CommandInfo command, ICommandContext context, IServiceProvider services, string input, int startPos) | |||
{ | |||
ParameterInfo curParam = null; | |||
StringBuilder argBuilder = new StringBuilder(input.Length); | |||
@@ -110,7 +111,7 @@ namespace Discord.Commands | |||
if (curParam == null) | |||
return ParseResult.FromError(CommandError.BadArgCount, "The input text has too many parameters."); | |||
var typeReaderResult = await curParam.Parse(context, argString).ConfigureAwait(false); | |||
var typeReaderResult = await curParam.Parse(context, argString, services).ConfigureAwait(false); | |||
if (!typeReaderResult.IsSuccess && typeReaderResult.Error != CommandError.MultipleMatches) | |||
return ParseResult.FromError(typeReaderResult); | |||
@@ -133,7 +134,7 @@ namespace Discord.Commands | |||
if (curParam != null && curParam.IsRemainder) | |||
{ | |||
var typeReaderResult = await curParam.Parse(context, argBuilder.ToString()).ConfigureAwait(false); | |||
var typeReaderResult = await curParam.Parse(context, argBuilder.ToString(), services).ConfigureAwait(false); | |||
if (!typeReaderResult.IsSuccess) | |||
return ParseResult.FromError(typeReaderResult); | |||
argList.Add(typeReaderResult); | |||
@@ -33,7 +33,7 @@ namespace Discord.Commands | |||
public IEnumerable<ModuleInfo> Modules => _moduleDefs.Select(x => x); | |||
public IEnumerable<CommandInfo> Commands => _moduleDefs.SelectMany(x => x.Commands); | |||
public ILookup<Type, TypeReader> TypeReaders => _typeReaders.SelectMany(x => x.Value.Select(y => new {y.Key, y.Value})).ToLookup(x => x.Key, x => x.Value); | |||
public ILookup<Type, TypeReader> TypeReaders => _typeReaders.SelectMany(x => x.Value.Select(y => new { y.Key, y.Value })).ToLookup(x => x.Key, x => x.Value); | |||
public CommandService() : this(new CommandServiceConfig()) { } | |||
public CommandService(CommandServiceConfig config) | |||
@@ -59,6 +59,9 @@ namespace Discord.Commands | |||
foreach (var type in PrimitiveParsers.SupportedTypes) | |||
_defaultTypeReaders[type] = PrimitiveTypeReader.Create(type); | |||
_defaultTypeReaders[typeof(string)] = | |||
new PrimitiveTypeReader<string>((string x, out string y) => { y = x; return true; }, 0); | |||
var entityTypeReaders = ImmutableList.CreateBuilder<Tuple<Type, Type>>(); | |||
entityTypeReaders.Add(new Tuple<Type, Type>(typeof(IMessage), typeof(MessageTypeReader<>))); | |||
entityTypeReaders.Add(new Tuple<Type, Type>(typeof(IChannel), typeof(ChannelTypeReader<>))); | |||
@@ -95,7 +98,7 @@ namespace Discord.Commands | |||
if (_typedModuleDefs.ContainsKey(type)) | |||
throw new ArgumentException($"This module has already been added."); | |||
var module = ModuleClassBuilder.Build(this, typeInfo).FirstOrDefault(); | |||
var module = (await ModuleClassBuilder.BuildAsync(this, typeInfo).ConfigureAwait(false)).FirstOrDefault(); | |||
if (module.Value == default(ModuleInfo)) | |||
throw new InvalidOperationException($"Could not build the module {type.FullName}, did you pass an invalid type?"); | |||
@@ -114,8 +117,8 @@ namespace Discord.Commands | |||
await _moduleLock.WaitAsync().ConfigureAwait(false); | |||
try | |||
{ | |||
var types = ModuleClassBuilder.Search(assembly).ToArray(); | |||
var moduleDefs = ModuleClassBuilder.Build(types, this); | |||
var types = await ModuleClassBuilder.SearchAsync(assembly, this).ConfigureAwait(false); | |||
var moduleDefs = await ModuleClassBuilder.BuildAsync(types, this).ConfigureAwait(false); | |||
foreach (var info in moduleDefs) | |||
{ | |||
@@ -161,8 +164,7 @@ namespace Discord.Commands | |||
await _moduleLock.WaitAsync().ConfigureAwait(false); | |||
try | |||
{ | |||
ModuleInfo module; | |||
if (!_typedModuleDefs.TryRemove(type, out module)) | |||
if (!_typedModuleDefs.TryRemove(type, out var module)) | |||
return false; | |||
return RemoveModuleInternal(module); | |||
@@ -196,20 +198,18 @@ namespace Discord.Commands | |||
} | |||
public void AddTypeReader(Type type, TypeReader reader) | |||
{ | |||
var readers = _typeReaders.GetOrAdd(type, x=> new ConcurrentDictionary<Type, TypeReader>()); | |||
var readers = _typeReaders.GetOrAdd(type, x => new ConcurrentDictionary<Type, TypeReader>()); | |||
readers[reader.GetType()] = reader; | |||
} | |||
internal IDictionary<Type, TypeReader> GetTypeReaders(Type type) | |||
{ | |||
ConcurrentDictionary<Type, TypeReader> definedTypeReaders; | |||
if (_typeReaders.TryGetValue(type, out definedTypeReaders)) | |||
if (_typeReaders.TryGetValue(type, out var definedTypeReaders)) | |||
return definedTypeReaders; | |||
return null; | |||
} | |||
internal TypeReader GetDefaultTypeReader(Type type) | |||
{ | |||
TypeReader reader; | |||
if (_defaultTypeReaders.TryGetValue(type, out reader)) | |||
if (_defaultTypeReaders.TryGetValue(type, out var reader)) | |||
return reader; | |||
var typeInfo = type.GetTypeInfo(); | |||
@@ -235,13 +235,13 @@ namespace Discord.Commands | |||
} | |||
//Execution | |||
public SearchResult Search(ICommandContext context, int argPos) | |||
public SearchResult Search(ICommandContext context, int argPos) | |||
=> Search(context, context.Message.Content.Substring(argPos)); | |||
public SearchResult Search(ICommandContext context, string input) | |||
{ | |||
string searchInput = _caseSensitive ? input : input.ToLowerInvariant(); | |||
var matches = _map.GetCommands(searchInput).OrderByDescending(x => x.Command.Priority).ToImmutableArray(); | |||
if (matches.Length > 0) | |||
return SearchResult.FromSuccess(input, matches); | |||
else | |||
@@ -259,46 +259,86 @@ namespace Discord.Commands | |||
return searchResult; | |||
var commands = searchResult.Commands; | |||
for (int i = 0; i < commands.Count; i++) | |||
var preconditionResults = new Dictionary<CommandMatch, PreconditionResult>(); | |||
foreach (var match in commands) | |||
{ | |||
var preconditionResult = await commands[i].CheckPreconditionsAsync(context, services).ConfigureAwait(false); | |||
if (!preconditionResult.IsSuccess) | |||
{ | |||
if (commands.Count == 1) | |||
return preconditionResult; | |||
else | |||
continue; | |||
} | |||
preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services).ConfigureAwait(false); | |||
} | |||
var successfulPreconditions = preconditionResults | |||
.Where(x => x.Value.IsSuccess) | |||
.ToArray(); | |||
if (successfulPreconditions.Length == 0) | |||
{ | |||
//All preconditions failed, return the one from the highest priority command | |||
var bestCandidate = preconditionResults | |||
.OrderByDescending(x => x.Key.Command.Priority) | |||
.FirstOrDefault(x => !x.Value.IsSuccess); | |||
return bestCandidate.Value; | |||
} | |||
//If we get this far, at least one precondition was successful. | |||
var parseResultsDict = new Dictionary<CommandMatch, ParseResult>(); | |||
foreach (var pair in successfulPreconditions) | |||
{ | |||
var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services).ConfigureAwait(false); | |||
var parseResult = await commands[i].ParseAsync(context, searchResult, preconditionResult).ConfigureAwait(false); | |||
if (!parseResult.IsSuccess) | |||
if (parseResult.Error == CommandError.MultipleMatches) | |||
{ | |||
if (parseResult.Error == CommandError.MultipleMatches) | |||
IReadOnlyList<TypeReaderValue> argList, paramList; | |||
switch (multiMatchHandling) | |||
{ | |||
IReadOnlyList<TypeReaderValue> argList, paramList; | |||
switch (multiMatchHandling) | |||
{ | |||
case MultiMatchHandling.Best: | |||
argList = parseResult.ArgValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); | |||
paramList = parseResult.ParamValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); | |||
parseResult = ParseResult.FromSuccess(argList, paramList); | |||
break; | |||
} | |||
case MultiMatchHandling.Best: | |||
argList = parseResult.ArgValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); | |||
paramList = parseResult.ParamValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); | |||
parseResult = ParseResult.FromSuccess(argList, paramList); | |||
break; | |||
} | |||
} | |||
if (!parseResult.IsSuccess) | |||
{ | |||
if (commands.Count == 1) | |||
return parseResult; | |||
else | |||
continue; | |||
} | |||
parseResultsDict[pair.Key] = parseResult; | |||
} | |||
// Calculates the 'score' of a command given a parse result | |||
float CalculateScore(CommandMatch match, ParseResult parseResult) | |||
{ | |||
float argValuesScore = 0, paramValuesScore = 0; | |||
if (match.Command.Parameters.Count > 0) | |||
{ | |||
var argValuesSum = parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; | |||
var paramValuesSum = parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; | |||
argValuesScore = argValuesSum / match.Command.Parameters.Count; | |||
paramValuesScore = paramValuesSum / match.Command.Parameters.Count; | |||
} | |||
return await commands[i].ExecuteAsync(context, parseResult, services).ConfigureAwait(false); | |||
var totalArgsScore = (argValuesScore + paramValuesScore) / 2; | |||
return match.Command.Priority + totalArgsScore * 0.99f; | |||
} | |||
//Order the parse results by their score so that we choose the most likely result to execute | |||
var parseResults = parseResultsDict | |||
.OrderByDescending(x => CalculateScore(x.Key, x.Value)); | |||
var successfulParses = parseResults | |||
.Where(x => x.Value.IsSuccess) | |||
.ToArray(); | |||
if (successfulParses.Length == 0) | |||
{ | |||
//All parses failed, return the one from the highest priority command, using score as a tie breaker | |||
var bestMatch = parseResults | |||
.FirstOrDefault(x => !x.Value.IsSuccess); | |||
return bestMatch.Value; | |||
} | |||
return SearchResult.FromError(CommandError.UnknownCommand, "This input does not match any overload."); | |||
//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); | |||
} | |||
} | |||
} |
@@ -4,8 +4,8 @@ | |||
{ | |||
void SetContext(ICommandContext context); | |||
void BeforeExecute(); | |||
void BeforeExecute(CommandInfo command); | |||
void AfterExecute(); | |||
void AfterExecute(CommandInfo command); | |||
} | |||
} |
@@ -18,7 +18,7 @@ namespace Discord.Commands | |||
private static readonly System.Reflection.MethodInfo _convertParamsMethod = typeof(CommandInfo).GetTypeInfo().GetDeclaredMethod(nameof(ConvertParamsList)); | |||
private static readonly ConcurrentDictionary<Type, Func<IEnumerable<object>, object>> _arrayConverters = new ConcurrentDictionary<Type, Func<IEnumerable<object>, object>>(); | |||
private readonly Func<ICommandContext, object[], IServiceProvider, Task> _action; | |||
private readonly Func<ICommandContext, object[], IServiceProvider, CommandInfo, Task> _action; | |||
public ModuleInfo Module { get; } | |||
public string Name { get; } | |||
@@ -31,18 +31,19 @@ namespace Discord.Commands | |||
public IReadOnlyList<string> Aliases { get; } | |||
public IReadOnlyList<ParameterInfo> Parameters { get; } | |||
public IReadOnlyList<PreconditionAttribute> Preconditions { get; } | |||
public IReadOnlyList<Attribute> Attributes { get; } | |||
internal CommandInfo(CommandBuilder builder, ModuleInfo module, CommandService service) | |||
{ | |||
Module = module; | |||
Name = builder.Name; | |||
Summary = builder.Summary; | |||
Remarks = builder.Remarks; | |||
RunMode = (builder.RunMode == RunMode.Default ? service._defaultRunMode : builder.RunMode); | |||
Priority = builder.Priority; | |||
Aliases = module.Aliases | |||
.Permutate(builder.Aliases, (first, second) => | |||
{ | |||
@@ -57,6 +58,7 @@ namespace Discord.Commands | |||
.ToImmutableArray(); | |||
Preconditions = builder.Preconditions.ToImmutableArray(); | |||
Attributes = builder.Attributes.ToImmutableArray(); | |||
Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); | |||
HasVarArgs = builder.Parameters.Count > 0 ? builder.Parameters[builder.Parameters.Count - 1].IsMultiple : false; | |||
@@ -68,58 +70,80 @@ namespace Discord.Commands | |||
{ | |||
services = services ?? EmptyServiceProvider.Instance; | |||
foreach (PreconditionAttribute precondition in Module.Preconditions) | |||
async Task<PreconditionResult> CheckGroups(IEnumerable<PreconditionAttribute> preconditions, string type) | |||
{ | |||
var result = await precondition.CheckPermissions(context, this, services).ConfigureAwait(false); | |||
if (!result.IsSuccess) | |||
return result; | |||
} | |||
foreach (IGrouping<string, PreconditionAttribute> preconditionGroup in preconditions.GroupBy(p => p.Group, StringComparer.Ordinal)) | |||
{ | |||
if (preconditionGroup.Key == null) | |||
{ | |||
foreach (PreconditionAttribute precondition in preconditionGroup) | |||
{ | |||
var result = await precondition.CheckPermissions(context, this, services).ConfigureAwait(false); | |||
if (!result.IsSuccess) | |||
return result; | |||
} | |||
} | |||
else | |||
{ | |||
var results = new List<PreconditionResult>(); | |||
foreach (PreconditionAttribute precondition in preconditionGroup) | |||
results.Add(await precondition.CheckPermissions(context, this, services).ConfigureAwait(false)); | |||
foreach (PreconditionAttribute precondition in Preconditions) | |||
{ | |||
var result = await precondition.CheckPermissions(context, this, services).ConfigureAwait(false); | |||
if (!result.IsSuccess) | |||
return result; | |||
if (!results.Any(p => p.IsSuccess)) | |||
return PreconditionGroupResult.FromError($"{type} precondition group {preconditionGroup.Key} failed.", results); | |||
} | |||
} | |||
return PreconditionGroupResult.FromSuccess(); | |||
} | |||
var moduleResult = await CheckGroups(Module.Preconditions, "Module"); | |||
if (!moduleResult.IsSuccess) | |||
return moduleResult; | |||
var commandResult = await CheckGroups(Preconditions, "Command"); | |||
if (!commandResult.IsSuccess) | |||
return commandResult; | |||
return PreconditionResult.FromSuccess(); | |||
} | |||
public async Task<ParseResult> ParseAsync(ICommandContext context, int startIndex, SearchResult searchResult, PreconditionResult? preconditionResult = null) | |||
public async Task<ParseResult> ParseAsync(ICommandContext context, int startIndex, SearchResult searchResult, PreconditionResult preconditionResult = null, IServiceProvider services = null) | |||
{ | |||
services = services ?? EmptyServiceProvider.Instance; | |||
if (!searchResult.IsSuccess) | |||
return ParseResult.FromError(searchResult); | |||
if (preconditionResult != null && !preconditionResult.Value.IsSuccess) | |||
return ParseResult.FromError(preconditionResult.Value); | |||
if (preconditionResult != null && !preconditionResult.IsSuccess) | |||
return ParseResult.FromError(preconditionResult); | |||
string input = searchResult.Text.Substring(startIndex); | |||
return await CommandParser.ParseArgs(this, context, input, 0).ConfigureAwait(false); | |||
return await CommandParser.ParseArgs(this, context, services, input, 0).ConfigureAwait(false); | |||
} | |||
public Task<ExecuteResult> ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) | |||
public Task<IResult> ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) | |||
{ | |||
if (!parseResult.IsSuccess) | |||
return Task.FromResult(ExecuteResult.FromError(parseResult)); | |||
return Task.FromResult((IResult)ExecuteResult.FromError(parseResult)); | |||
var argList = new object[parseResult.ArgValues.Count]; | |||
for (int i = 0; i < parseResult.ArgValues.Count; i++) | |||
{ | |||
if (!parseResult.ArgValues[i].IsSuccess) | |||
return Task.FromResult(ExecuteResult.FromError(parseResult.ArgValues[i])); | |||
return Task.FromResult((IResult)ExecuteResult.FromError(parseResult.ArgValues[i])); | |||
argList[i] = parseResult.ArgValues[i].Values.First().Value; | |||
} | |||
var paramList = new object[parseResult.ParamValues.Count]; | |||
for (int i = 0; i < parseResult.ParamValues.Count; i++) | |||
{ | |||
if (!parseResult.ParamValues[i].IsSuccess) | |||
return Task.FromResult(ExecuteResult.FromError(parseResult.ParamValues[i])); | |||
return Task.FromResult((IResult)ExecuteResult.FromError(parseResult.ParamValues[i])); | |||
paramList[i] = parseResult.ParamValues[i].Values.First().Value; | |||
} | |||
return ExecuteAsync(context, argList, paramList, services); | |||
} | |||
public async Task<ExecuteResult> ExecuteAsync(ICommandContext context, IEnumerable<object> argList, IEnumerable<object> paramList, IServiceProvider services) | |||
public async Task<IResult> ExecuteAsync(ICommandContext context, IEnumerable<object> argList, IEnumerable<object> paramList, IServiceProvider services) | |||
{ | |||
services = services ?? EmptyServiceProvider.Instance; | |||
@@ -130,7 +154,7 @@ namespace Discord.Commands | |||
for (int position = 0; position < Parameters.Count; position++) | |||
{ | |||
var parameter = Parameters[position]; | |||
var argument = args[position]; | |||
object argument = args[position]; | |||
var result = await parameter.CheckPreconditionsAsync(context, argument, services).ConfigureAwait(false); | |||
if (!result.IsSuccess) | |||
return ExecuteResult.FromError(result); | |||
@@ -139,10 +163,9 @@ namespace Discord.Commands | |||
switch (RunMode) | |||
{ | |||
case RunMode.Sync: //Always sync | |||
await ExecuteAsyncInternal(context, args, services).ConfigureAwait(false); | |||
break; | |||
return await ExecuteAsyncInternal(context, args, services).ConfigureAwait(false); | |||
case RunMode.Async: //Always async | |||
var t2 = Task.Run(async () => | |||
var t2 = Task.Run(async () => | |||
{ | |||
await ExecuteAsyncInternal(context, args, services).ConfigureAwait(false); | |||
}); | |||
@@ -156,12 +179,26 @@ namespace Discord.Commands | |||
} | |||
} | |||
private async Task ExecuteAsyncInternal(ICommandContext context, object[] args, IServiceProvider services) | |||
private async Task<IResult> ExecuteAsyncInternal(ICommandContext context, object[] args, IServiceProvider services) | |||
{ | |||
await Module.Service._cmdLogger.DebugAsync($"Executing {GetLogText(context)}").ConfigureAwait(false); | |||
try | |||
{ | |||
await _action(context, args, services).ConfigureAwait(false); | |||
var task = _action(context, args, services, this); | |||
if (task is Task<IResult> resultTask) | |||
{ | |||
var result = await resultTask.ConfigureAwait(false); | |||
if (result is RuntimeResult execResult) | |||
return execResult; | |||
} | |||
else if (task is Task<ExecuteResult> execTask) | |||
{ | |||
return await execTask.ConfigureAwait(false); | |||
} | |||
else | |||
await task.ConfigureAwait(false); | |||
return ExecuteResult.FromSuccess(); | |||
} | |||
catch (Exception ex) | |||
{ | |||
@@ -178,8 +215,13 @@ namespace Discord.Commands | |||
else | |||
ExceptionDispatchInfo.Capture(ex).Throw(); | |||
} | |||
return ExecuteResult.FromError(CommandError.Exception, ex.Message); | |||
} | |||
finally | |||
{ | |||
await Module.Service._cmdLogger.VerboseAsync($"Executed {GetLogText(context)}").ConfigureAwait(false); | |||
} | |||
await Module.Service._cmdLogger.VerboseAsync($"Executed {GetLogText(context)}").ConfigureAwait(false); | |||
} | |||
private object[] GenerateArgs(IEnumerable<object> argList, IEnumerable<object> paramsList) | |||
@@ -190,7 +232,7 @@ namespace Discord.Commands | |||
argCount--; | |||
int i = 0; | |||
foreach (var arg in argList) | |||
foreach (object arg in argList) | |||
{ | |||
if (i == argCount) | |||
throw new InvalidOperationException("Command was invoked with too many parameters"); | |||
@@ -216,11 +258,11 @@ namespace Discord.Commands | |||
=> paramsList.Cast<T>().ToArray(); | |||
internal string GetLogText(ICommandContext context) | |||
{ | |||
{ | |||
if (context.Guild != null) | |||
return $"\"{Name}\" for {context.User} in {context.Guild}/{context.Channel}"; | |||
else | |||
return $"\"{Name}\" for {context.User} in {context.Channel}"; | |||
} | |||
} | |||
} | |||
} |
@@ -1,3 +1,4 @@ | |||
using System; | |||
using System.Linq; | |||
using System.Collections.Generic; | |||
using System.Collections.Immutable; | |||
@@ -16,6 +17,7 @@ namespace Discord.Commands | |||
public IReadOnlyList<string> Aliases { get; } | |||
public IReadOnlyList<CommandInfo> Commands { get; } | |||
public IReadOnlyList<PreconditionAttribute> Preconditions { get; } | |||
public IReadOnlyList<Attribute> Attributes { get; } | |||
public IReadOnlyList<ModuleInfo> Submodules { get; } | |||
public ModuleInfo Parent { get; } | |||
public bool IsSubmodule => Parent != null; | |||
@@ -32,6 +34,7 @@ namespace Discord.Commands | |||
Aliases = BuildAliases(builder, service).ToImmutableArray(); | |||
Commands = builder.Commands.Select(x => x.Build(this, service)).ToImmutableArray(); | |||
Preconditions = BuildPreconditions(builder).ToImmutableArray(); | |||
Attributes = BuildAttributes(builder).ToImmutableArray(); | |||
Submodules = BuildSubmodules(builder, service).ToImmutableArray(); | |||
} | |||
@@ -86,5 +89,19 @@ namespace Discord.Commands | |||
return result; | |||
} | |||
private static List<Attribute> BuildAttributes(ModuleBuilder builder) | |||
{ | |||
var result = new List<Attribute>(); | |||
ModuleBuilder parent = builder; | |||
while (parent != null) | |||
{ | |||
result.AddRange(parent.Attributes); | |||
parent = parent.Parent; | |||
} | |||
return result; | |||
} | |||
} | |||
} |
@@ -21,6 +21,7 @@ namespace Discord.Commands | |||
public object DefaultValue { get; } | |||
public IReadOnlyList<ParameterPreconditionAttribute> Preconditions { get; } | |||
public IReadOnlyList<Attribute> Attributes { get; } | |||
internal ParameterInfo(ParameterBuilder builder, CommandInfo command, CommandService service) | |||
{ | |||
@@ -36,6 +37,7 @@ namespace Discord.Commands | |||
DefaultValue = builder.DefaultValue; | |||
Preconditions = builder.Preconditions.ToImmutableArray(); | |||
Attributes = builder.Attributes.ToImmutableArray(); | |||
_reader = builder.TypeReader; | |||
} | |||
@@ -54,9 +56,10 @@ namespace Discord.Commands | |||
return PreconditionResult.FromSuccess(); | |||
} | |||
public async Task<TypeReaderResult> Parse(ICommandContext context, string input) | |||
public async Task<TypeReaderResult> Parse(ICommandContext context, string input, IServiceProvider services = null) | |||
{ | |||
return await _reader.Read(context, input).ConfigureAwait(false); | |||
services = services ?? EmptyServiceProvider.Instance; | |||
return await _reader.Read(context, input, services).ConfigureAwait(false); | |||
} | |||
public override string ToString() => Name; | |||
@@ -15,11 +15,11 @@ namespace Discord.Commands | |||
return await Context.Channel.SendMessageAsync(message, isTTS, embed, options).ConfigureAwait(false); | |||
} | |||
protected virtual void BeforeExecute() | |||
protected virtual void BeforeExecute(CommandInfo command) | |||
{ | |||
} | |||
protected virtual void AfterExecute() | |||
protected virtual void AfterExecute(CommandInfo command) | |||
{ | |||
} | |||
@@ -27,13 +27,11 @@ namespace Discord.Commands | |||
void IModuleBase.SetContext(ICommandContext context) | |||
{ | |||
var newValue = context as T; | |||
if (newValue == null) | |||
throw new InvalidOperationException($"Invalid context type. Expected {typeof(T).Name}, got {context.GetType().Name}"); | |||
Context = newValue; | |||
Context = newValue ?? throw new InvalidOperationException($"Invalid context type. Expected {typeof(T).Name}, got {context.GetType().Name}"); | |||
} | |||
void IModuleBase.BeforeExecute() => BeforeExecute(); | |||
void IModuleBase.BeforeExecute(CommandInfo command) => BeforeExecute(command); | |||
void IModuleBase.AfterExecute() => AfterExecute(); | |||
void IModuleBase.AfterExecute(CommandInfo command) => AfterExecute(command); | |||
} | |||
} |
@@ -31,11 +31,6 @@ namespace Discord.Commands | |||
parserBuilder[typeof(DateTimeOffset)] = (TryParseDelegate<DateTimeOffset>)DateTimeOffset.TryParse; | |||
parserBuilder[typeof(TimeSpan)] = (TryParseDelegate<TimeSpan>)TimeSpan.TryParse; | |||
parserBuilder[typeof(char)] = (TryParseDelegate<char>)char.TryParse; | |||
parserBuilder[typeof(string)] = (TryParseDelegate<string>)delegate (string str, out string value) | |||
{ | |||
value = str; | |||
return true; | |||
}; | |||
return parserBuilder.ToImmutable(); | |||
} | |||
@@ -9,7 +9,7 @@ namespace Discord.Commands | |||
internal class ChannelTypeReader<T> : TypeReader | |||
where T : class, IChannel | |||
{ | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
if (context.Guild != null) | |||
{ | |||
@@ -44,12 +44,11 @@ namespace Discord.Commands | |||
_enumsByValue = byValueBuilder.ToImmutable(); | |||
} | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
T baseValue; | |||
object enumValue; | |||
if (_tryParse(input, out baseValue)) | |||
if (_tryParse(input, out T baseValue)) | |||
{ | |||
if (_enumsByValue.TryGetValue(baseValue, out enumValue)) | |||
return Task.FromResult(TypeReaderResult.FromSuccess(enumValue)); | |||
@@ -1,4 +1,5 @@ | |||
using System.Globalization; | |||
using System; | |||
using System.Globalization; | |||
using System.Threading.Tasks; | |||
namespace Discord.Commands | |||
@@ -6,15 +7,14 @@ namespace Discord.Commands | |||
internal class MessageTypeReader<T> : TypeReader | |||
where T : class, IMessage | |||
{ | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
ulong id; | |||
//By Id (1.0) | |||
if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) | |||
{ | |||
var msg = await context.Channel.GetMessageAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T; | |||
if (msg != null) | |||
if (await context.Channel.GetMessageAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) is T msg) | |||
return TypeReaderResult.FromSuccess(msg); | |||
} | |||
@@ -15,17 +15,25 @@ namespace Discord.Commands | |||
internal class PrimitiveTypeReader<T> : TypeReader | |||
{ | |||
private readonly TryParseDelegate<T> _tryParse; | |||
private readonly float _score; | |||
public PrimitiveTypeReader() | |||
: this(PrimitiveParsers.Get<T>(), 1) | |||
{ } | |||
public PrimitiveTypeReader(TryParseDelegate<T> tryParse, float score) | |||
{ | |||
_tryParse = PrimitiveParsers.Get<T>(); | |||
if (score < 0 || score > 1) | |||
throw new ArgumentOutOfRangeException(nameof(score), score, "Scores must be within the range [0, 1]"); | |||
_tryParse = tryParse; | |||
_score = score; | |||
} | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
T value; | |||
if (_tryParse(input, out value)) | |||
return Task.FromResult(TypeReaderResult.FromSuccess(value)); | |||
if (_tryParse(input, out T value)) | |||
return Task.FromResult(TypeReaderResult.FromSuccess(new TypeReaderValue(value, _score))); | |||
return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, $"Failed to parse {typeof(T).Name}")); | |||
} | |||
} | |||
@@ -9,7 +9,7 @@ namespace Discord.Commands | |||
internal class RoleTypeReader<T> : TypeReader | |||
where T : class, IRole | |||
{ | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
ulong id; | |||
@@ -1,9 +1,10 @@ | |||
using System.Threading.Tasks; | |||
using System; | |||
using System.Threading.Tasks; | |||
namespace Discord.Commands | |||
{ | |||
public abstract class TypeReader | |||
{ | |||
public abstract Task<TypeReaderResult> Read(ICommandContext context, string input); | |||
public abstract Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services); | |||
} | |||
} |
@@ -10,7 +10,7 @@ namespace Discord.Commands | |||
internal class UserTypeReader<T> : TypeReader | |||
where T : class, IUser | |||
{ | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input) | |||
public override async Task<TypeReaderResult> Read(ICommandContext context, string input, IServiceProvider services) | |||
{ | |||
var results = new Dictionary<ulong, TypeReaderValue>(); | |||
IReadOnlyCollection<IUser> channelUsers = await context.Channel.GetUsersAsync(CacheMode.CacheOnly).ToArray().ConfigureAwait(false); //TODO: must be a better way? | |||
@@ -43,8 +43,7 @@ namespace Discord.Commands | |||
if (index >= 0) | |||
{ | |||
string username = input.Substring(0, index); | |||
ushort discriminator; | |||
if (ushort.TryParse(input.Substring(index + 1), out discriminator)) | |||
if (ushort.TryParse(input.Substring(index + 1), out ushort discriminator)) | |||
{ | |||
var channelUser = channelUsers.FirstOrDefault(x => x.DiscriminatorValue == discriminator && | |||
string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)); | |||
@@ -0,0 +1,27 @@ | |||
using System.Collections.Generic; | |||
using System.Diagnostics; | |||
namespace Discord.Commands | |||
{ | |||
[DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||
public class PreconditionGroupResult : PreconditionResult | |||
{ | |||
public IReadOnlyCollection<PreconditionResult> PreconditionResults { get; } | |||
protected PreconditionGroupResult(CommandError? error, string errorReason, ICollection<PreconditionResult> preconditions) | |||
: base(error, errorReason) | |||
{ | |||
PreconditionResults = (preconditions ?? new List<PreconditionResult>(0)).ToReadOnlyCollection(); | |||
} | |||
public static new PreconditionGroupResult FromSuccess() | |||
=> new PreconditionGroupResult(null, null, null); | |||
public static PreconditionGroupResult FromError(string reason, ICollection<PreconditionResult> preconditions) | |||
=> new PreconditionGroupResult(CommandError.UnmetPrecondition, reason, preconditions); | |||
public static new PreconditionGroupResult FromError(IResult result) //needed? | |||
=> new PreconditionGroupResult(result.Error, result.ErrorReason, null); | |||
public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; | |||
private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; | |||
} | |||
} |
@@ -3,14 +3,14 @@ | |||
namespace Discord.Commands | |||
{ | |||
[DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||
public struct PreconditionResult : IResult | |||
public class PreconditionResult : IResult | |||
{ | |||
public CommandError? Error { get; } | |||
public string ErrorReason { get; } | |||
public bool IsSuccess => !Error.HasValue; | |||
private PreconditionResult(CommandError? error, string errorReason) | |||
protected PreconditionResult(CommandError? error, string errorReason) | |||
{ | |||
Error = error; | |||
ErrorReason = errorReason; | |||
@@ -0,0 +1,27 @@ | |||
using System; | |||
using System.Collections.Generic; | |||
using System.Diagnostics; | |||
using System.Text; | |||
namespace Discord.Commands | |||
{ | |||
[DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||
public abstract class RuntimeResult : IResult | |||
{ | |||
protected RuntimeResult(CommandError? error, string reason) | |||
{ | |||
Error = error; | |||
Reason = reason; | |||
} | |||
public CommandError? Error { get; } | |||
public string Reason { get; } | |||
public bool IsSuccess => !Error.HasValue; | |||
string IResult.ErrorReason => Reason; | |||
public override string ToString() => Reason ?? (IsSuccess ? "Successful" : "Unsuccessful"); | |||
private string DebuggerDisplay => IsSuccess ? $"Success: {Reason ?? "No Reason"}" : $"{Error}: {Reason}"; | |||
} | |||
} |
@@ -58,7 +58,7 @@ namespace Discord.Commands | |||
{ | |||
foreach (var prop in ownerType.DeclaredProperties) | |||
{ | |||
if (prop.SetMethod?.IsPublic == true && prop.GetCustomAttribute<DontInjectAttribute>() == null) | |||
if (prop.SetMethod?.IsStatic == false && prop.SetMethod?.IsPublic == true && prop.GetCustomAttribute<DontInjectAttribute>() == null) | |||
result.Add(prop); | |||
} | |||
ownerType = ownerType.BaseType.GetTypeInfo(); | |||
@@ -11,7 +11,10 @@ namespace Discord.Audio | |||
public override bool CanSeek => false; | |||
public override bool CanWrite => false; | |||
public virtual void WriteHeader(ushort seq, uint timestamp, bool missed) { } | |||
public virtual void WriteHeader(ushort seq, uint timestamp, bool missed) | |||
{ | |||
throw new InvalidOperationException("This stream does not accept headers"); | |||
} | |||
public override void Write(byte[] buffer, int offset, int count) | |||
{ | |||
WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); | |||
@@ -28,8 +28,8 @@ namespace Discord.Audio | |||
/// <summary>Creates a new outgoing stream accepting Opus-encoded data. This is a direct stream with no internal timer.</summary> | |||
AudioOutStream CreateDirectOpusStream(); | |||
/// <summary>Creates a new outgoing stream accepting PCM (raw) data.</summary> | |||
AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate = null, int bufferMillis = 1000); | |||
AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate = null, int bufferMillis = 1000, int packetLoss = 30); | |||
/// <summary>Creates a new direct outgoing stream accepting PCM (raw) data. This is a direct stream with no internal timer.</summary> | |||
AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate = null); | |||
AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate = null, int packetLoss = 30); | |||
} | |||
} |
@@ -30,7 +30,9 @@ namespace Discord | |||
/// <summary> Gets a collection of pinned messages in this channel. </summary> | |||
Task<IReadOnlyCollection<IMessage>> GetPinnedMessagesAsync(RequestOptions options = null); | |||
/// <summary> Bulk deletes multiple messages. </summary> | |||
Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null); | |||
Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null); | |||
/// <summary> Bulk deletes multiple messages. </summary> | |||
Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null); | |||
/// <summary> Broadcasts the "user is typing" message to all users in this channel, lasting 10 seconds. </summary> | |||
Task TriggerTypingAsync(RequestOptions options = null); | |||
@@ -6,8 +6,16 @@ | |||
public class Emoji : IEmote | |||
{ | |||
// TODO: need to constrain this to unicode-only emojis somehow | |||
/// <summary> | |||
/// The unicode representation of this emote. | |||
/// </summary> | |||
public string Name { get; } | |||
public override string ToString() => Name; | |||
/// <summary> | |||
/// Creates a unciode emoji. | |||
/// Creates a unicode emoji. | |||
/// </summary> | |||
/// <param name="unicode">The pure UTF-8 encoding of an emoji</param> | |||
public Emoji(string unicode) | |||
@@ -15,9 +23,17 @@ | |||
Name = unicode; | |||
} | |||
/// <summary> | |||
/// The unicode representation of this emote. | |||
/// </summary> | |||
public string Name { get; } | |||
public override bool Equals(object other) | |||
{ | |||
if (other == null) return false; | |||
if (other == this) return true; | |||
var otherEmoji = other as Emoji; | |||
if (otherEmoji == null) return false; | |||
return string.Equals(Name, otherEmoji.Name); | |||
} | |||
public override int GetHashCode() => Name.GetHashCode(); | |||
} | |||
} |
@@ -25,6 +25,25 @@ namespace Discord | |||
Name = name; | |||
} | |||
public override bool Equals(object other) | |||
{ | |||
if (other == null) return false; | |||
if (other == this) return true; | |||
var otherEmote = other as Emote; | |||
if (otherEmote == null) return false; | |||
return string.Equals(Name, otherEmote.Name) && Id == otherEmote.Id; | |||
} | |||
public override int GetHashCode() | |||
{ | |||
unchecked | |||
{ | |||
return (Name.GetHashCode() * 397) ^ Id.GetHashCode(); | |||
} | |||
} | |||
/// <summary> | |||
/// Parse an Emote from its raw format | |||
/// </summary> | |||
@@ -58,6 +77,6 @@ namespace Discord | |||
} | |||
private string DebuggerDisplay => $"{Name} ({Id})"; | |||
public override string ToString() => Name; | |||
public override string ToString() => $"<:{Name}:{Id}>"; | |||
} | |||
} |
@@ -20,7 +20,7 @@ namespace Discord | |||
RoleIds = roleIds; | |||
} | |||
public override string ToString() => Name; | |||
private string DebuggerDisplay => $"{Name} ({Id})"; | |||
public override string ToString() => $"<:{Name}:{Id}>"; | |||
} | |||
} |
@@ -66,10 +66,10 @@ namespace Discord | |||
Task<IReadOnlyCollection<IBan>> GetBansAsync(RequestOptions options = null); | |||
/// <summary> Bans the provided user from this guild and optionally prunes their recent messages. </summary> | |||
/// <param name="pruneDays">The number of days to remove messages from this user for - must be between [0, 7]</param> | |||
Task AddBanAsync(IUser user, int pruneDays = 0, RequestOptions options = null); | |||
Task AddBanAsync(IUser user, int pruneDays = 0, string reason = null, RequestOptions options = null); | |||
/// <summary> Bans the provided user id from this guild and optionally prunes their recent messages. </summary> | |||
/// <param name="pruneDays">The number of days to remove messages from this user for - must be between [0, 7]</param> | |||
Task AddBanAsync(ulong userId, int pruneDays = 0, RequestOptions options = null); | |||
Task AddBanAsync(ulong userId, int pruneDays = 0, string reason = null, RequestOptions options = null); | |||
/// <summary> Unbans the provided user if it is currently banned. </summary> | |||
Task RemoveBanAsync(IUser user, RequestOptions options = null); | |||
/// <summary> Unbans the provided user id if it is currently banned. </summary> | |||
@@ -9,6 +9,8 @@ | |||
/// <summary> Users must fulfill the requirements of Low, and be registered on Discord for at least 5 minutes. </summary> | |||
Medium = 2, | |||
/// <summary> Users must fulfill the requirements of Medium, and be a member of this guild for at least 10 minutes. </summary> | |||
High = 3 | |||
High = 3, | |||
/// <summary> Users must fulfill the requirements of High, and must have a verified phone on their Discord account. </summary> | |||
Extreme = 4 | |||
} | |||
} |
@@ -1,13 +1,14 @@ | |||
using System; | |||
using System.Collections.Immutable; | |||
using System.Diagnostics; | |||
using System.Linq; | |||
namespace Discord | |||
{ | |||
[DebuggerDisplay(@"{DebuggerDisplay,nq}")] | |||
public class Embed : IEmbed | |||
{ | |||
public string Type { get; } | |||
public EmbedType Type { get; } | |||
public string Description { get; internal set; } | |||
public string Url { get; internal set; } | |||
@@ -22,12 +23,12 @@ namespace Discord | |||
public EmbedThumbnail? Thumbnail { get; internal set; } | |||
public ImmutableArray<EmbedField> Fields { get; internal set; } | |||
internal Embed(string type) | |||
internal Embed(EmbedType type) | |||
{ | |||
Type = type; | |||
Fields = ImmutableArray.Create<EmbedField>(); | |||
} | |||
internal Embed(string type, | |||
internal Embed(EmbedType type, | |||
string title, | |||
string description, | |||
string url, | |||
@@ -56,6 +57,8 @@ namespace Discord | |||
Fields = fields; | |||
} | |||
public int Length => Title?.Length + Author?.Name?.Length + Description?.Length + Footer?.Text?.Length + Fields.Sum(f => f.Name.Length + f.Value.ToString().Length) ?? 0; | |||
public override string ToString() => Title; | |||
private string DebuggerDisplay => $"{Title} ({Type})"; | |||
} | |||
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -19,6 +20,6 @@ namespace Discord | |||
} | |||
private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; | |||
public override string ToString() => Url; | |||
public override string ToString() => Url.ToString(); | |||
} | |||
} |
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -19,6 +20,6 @@ namespace Discord | |||
} | |||
private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; | |||
public override string ToString() => Url; | |||
public override string ToString() => Url.ToString(); | |||
} | |||
} |
@@ -0,0 +1,13 @@ | |||
namespace Discord | |||
{ | |||
public enum EmbedType | |||
{ | |||
Rich, | |||
Link, | |||
Video, | |||
Image, | |||
Gifv, | |||
Article, | |||
Tweet | |||
} | |||
} |
@@ -1,4 +1,5 @@ | |||
using System.Diagnostics; | |||
using System; | |||
using System.Diagnostics; | |||
namespace Discord | |||
{ | |||
@@ -17,6 +18,6 @@ namespace Discord | |||
} | |||
private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; | |||
public override string ToString() => Url; | |||
public override string ToString() => Url.ToString(); | |||
} | |||
} |
@@ -6,9 +6,9 @@ namespace Discord | |||
public interface IEmbed | |||
{ | |||
string Url { get; } | |||
string Type { get; } | |||
string Title { get; } | |||
string Description { get; } | |||
EmbedType Type { get; } | |||
DateTimeOffset? Timestamp { get; } | |||
Color? Color { get; } | |||
EmbedImage? Image { get; } | |||
@@ -8,6 +8,46 @@ namespace Discord | |||
{ | |||
/// <summary> Gets the default user color value. </summary> | |||
public static readonly Color Default = new Color(0); | |||
/// <summary> Gets the teal color value </summary> | |||
public static readonly Color Teal = new Color(0x1ABC9C); | |||
/// <summary> Gets the dark teal color value </summary> | |||
public static readonly Color DarkTeal = new Color(0x11806A); | |||
/// <summary> Gets the green color value </summary> | |||
public static readonly Color Green = new Color(0x2ECC71); | |||
/// <summary> Gets the dark green color value </summary> | |||
public static readonly Color DarkGreen = new Color(0x1F8B4C); | |||
/// <summary> Gets the blue color value </summary> | |||
public static readonly Color Blue = new Color(0x3498DB); | |||
/// <summary> Gets the dark blue color value </summary> | |||
public static readonly Color DarkBlue = new Color(0x206694); | |||
/// <summary> Gets the purple color value </summary> | |||
public static readonly Color Purple = new Color(0x9B59B6); | |||
/// <summary> Gets the dark purple color value </summary> | |||
public static readonly Color DarkPurple = new Color(0x71368A); | |||
/// <summary> Gets the magenta color value </summary> | |||
public static readonly Color Magenta = new Color(0xE91E63); | |||
/// <summary> Gets the dark magenta color value </summary> | |||
public static readonly Color DarkMagenta = new Color(0xAD1457); | |||
/// <summary> Gets the gold color value </summary> | |||
public static readonly Color Gold = new Color(0xF1C40F); | |||
/// <summary> Gets the light orange color value </summary> | |||
public static readonly Color LightOrange = new Color(0xC27C0E); | |||
/// <summary> Gets the orange color value </summary> | |||
public static readonly Color Orange = new Color(0xE67E22); | |||
/// <summary> Gets the dark orange color value </summary> | |||
public static readonly Color DarkOrange = new Color(0xA84300); | |||
/// <summary> Gets the red color value </summary> | |||
public static readonly Color Red = new Color(0xE74C3C); | |||
/// <summary> Gets the dark red color value </summary> | |||
public static readonly Color DarkRed = new Color(0x992D22); | |||
/// <summary> Gets the light grey color value </summary> | |||
public static readonly Color LightGrey = new Color(0x979C9F); | |||
/// <summary> Gets the lighter grey color value </summary> | |||
public static readonly Color LighterGrey = new Color(0x95A5A6); | |||
/// <summary> Gets the dark grey color value </summary> | |||
public static readonly Color DarkGrey = new Color(0x607D8B); | |||
/// <summary> Gets the darker grey color value </summary> | |||
public static readonly Color DarkerGrey = new Color(0x546E7A); | |||
/// <summary> Gets the encoded value for this color. </summary> | |||
public uint RawValue { get; } | |||
@@ -25,7 +25,7 @@ namespace Discord | |||
ChannelPermissions GetPermissions(IGuildChannel channel); | |||
/// <summary> Kicks this user from this guild. </summary> | |||
Task KickAsync(RequestOptions options = null); | |||
Task KickAsync(string reason = null, RequestOptions options = null); | |||
/// <summary> Modifies this user's properties in this guild. </summary> | |||
Task ModifyAsync(Action<GuildUserProperties> func, RequestOptions options = null); | |||
@@ -20,8 +20,6 @@ namespace Discord | |||
string Username { get; } | |||
/// <summary> Returns a private message channel to this user, creating one if it does not already exist. </summary> | |||
Task<IDMChannel> GetDMChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); | |||
/// <summary> Returns a private message channel to this user, creating one if it does not already exist. </summary> | |||
Task<IDMChannel> CreateDMChannelAsync(RequestOptions options = null); | |||
Task<IDMChannel> GetOrCreateDMChannelAsync(RequestOptions options = null); | |||
} | |||
} |
@@ -0,0 +1,10 @@ | |||
using System; | |||
namespace Discord | |||
{ | |||
internal static class StringExtensions | |||
{ | |||
public static bool IsNullOrUri(this string url) => | |||
string.IsNullOrEmpty(url) || Uri.IsWellFormedUriString(url, UriKind.Absolute); | |||
} | |||
} |
@@ -0,0 +1,16 @@ | |||
using System.Threading.Tasks; | |||
namespace Discord | |||
{ | |||
public static class UserExtensions | |||
{ | |||
public static async Task<IUserMessage> SendMessageAsync(this IUser user, | |||
string text, | |||
bool isTTS = false, | |||
Embed embed = null, | |||
RequestOptions options = null) | |||
{ | |||
return await (await user.GetOrCreateDMChannelAsync().ConfigureAwait(false)).SendMessageAsync(text, isTTS, embed, options).ConfigureAwait(false); | |||
} | |||
} | |||
} |
@@ -9,8 +9,8 @@ namespace Discord.Net.Rest | |||
void SetHeader(string key, string value); | |||
void SetCancelToken(CancellationToken cancelToken); | |||
Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false); | |||
Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false); | |||
Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly = false); | |||
Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null); | |||
Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null); | |||
Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null); | |||
} | |||
} |
@@ -14,6 +14,10 @@ namespace Discord | |||
public CancellationToken CancelToken { get; set; } = CancellationToken.None; | |||
public RetryMode? RetryMode { get; set; } | |||
public bool HeaderOnly { get; internal set; } | |||
/// <summary> | |||
/// The reason for this action in the guild's audit log | |||
/// </summary> | |||
public string AuditLogReason { get; set; } | |||
internal bool IgnoreState { get; set; } | |||
internal string BucketId { get; set; } | |||
@@ -1,6 +1,7 @@ | |||
#pragma warning disable CS1591 | |||
using System; | |||
using Newtonsoft.Json; | |||
using Newtonsoft.Json.Converters; | |||
namespace Discord.API | |||
{ | |||
@@ -8,14 +9,14 @@ namespace Discord.API | |||
{ | |||
[JsonProperty("title")] | |||
public string Title { get; set; } | |||
[JsonProperty("type")] | |||
public string Type { get; set; } | |||
[JsonProperty("description")] | |||
public string Description { get; set; } | |||
[JsonProperty("url")] | |||
public string Url { get; set; } | |||
[JsonProperty("color")] | |||
public uint? Color { get; set; } | |||
[JsonProperty("type"), JsonConverter(typeof(StringEnumConverter))] | |||
public EmbedType Type { get; set; } | |||
[JsonProperty("timestamp")] | |||
public DateTimeOffset? Timestamp { get; set; } | |||
[JsonProperty("author")] | |||
@@ -1,4 +1,5 @@ | |||
using Newtonsoft.Json; | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
{ | |||
@@ -1,4 +1,5 @@ | |||
using Newtonsoft.Json; | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
{ | |||
@@ -1,4 +1,5 @@ | |||
#pragma warning disable CS1591 | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
@@ -1,4 +1,5 @@ | |||
#pragma warning disable CS1591 | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
@@ -1,4 +1,5 @@ | |||
#pragma warning disable CS1591 | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
@@ -1,4 +1,5 @@ | |||
#pragma warning disable CS1591 | |||
using System; | |||
using Newtonsoft.Json; | |||
namespace Discord.API | |||
@@ -4,5 +4,6 @@ namespace Discord.API.Rest | |||
internal class CreateGuildBanParams | |||
{ | |||
public Optional<int> DeleteMessageDays { get; set; } | |||
public string Reason { get; set; } | |||
} | |||
} |
@@ -30,7 +30,7 @@ namespace Discord.API | |||
protected readonly JsonSerializer _serializer; | |||
protected readonly SemaphoreSlim _stateLock; | |||
private readonly RestClientProvider RestClientProvider; | |||
private readonly RestClientProvider _restClientProvider; | |||
protected bool _isDisposed; | |||
private CancellationTokenSource _loginCancelToken; | |||
@@ -48,7 +48,7 @@ namespace Discord.API | |||
public DiscordRestApiClient(RestClientProvider restClientProvider, string userAgent, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, | |||
JsonSerializer serializer = null) | |||
{ | |||
RestClientProvider = restClientProvider; | |||
_restClientProvider = restClientProvider; | |||
UserAgent = userAgent; | |||
DefaultRetryMode = defaultRetryMode; | |||
_serializer = serializer ?? new JsonSerializer { DateFormatString = "yyyy-MM-ddTHH:mm:ssZ", ContractResolver = new DiscordContractResolver() }; | |||
@@ -60,7 +60,7 @@ namespace Discord.API | |||
} | |||
internal void SetBaseUrl(string baseUrl) | |||
{ | |||
RestClient = RestClientProvider(baseUrl); | |||
RestClient = _restClientProvider(baseUrl); | |||
RestClient.SetHeader("accept", "*/*"); | |||
RestClient.SetHeader("user-agent", UserAgent); | |||
RestClient.SetHeader("authorization", GetPrefixedToken(AuthTokenType, AuthToken)); | |||
@@ -189,7 +189,7 @@ namespace Discord.API | |||
options.BucketId = AuthTokenType == TokenType.User ? ClientBucket.Get(clientBucket).Id : bucketId; | |||
options.IsClientBucket = AuthTokenType == TokenType.User; | |||
var json = payload != null ? SerializeJson(payload) : null; | |||
string json = payload != null ? SerializeJson(payload) : null; | |||
var request = new JsonRestRequest(RestClient, method, endpoint, json, options); | |||
await SendInternalAsync(method, endpoint, request).ConfigureAwait(false); | |||
} | |||
@@ -233,7 +233,7 @@ namespace Discord.API | |||
options.BucketId = AuthTokenType == TokenType.User ? ClientBucket.Get(clientBucket).Id : bucketId; | |||
options.IsClientBucket = AuthTokenType == TokenType.User; | |||
var json = payload != null ? SerializeJson(payload) : null; | |||
string json = payload != null ? SerializeJson(payload) : null; | |||
var request = new JsonRestRequest(RestClient, method, endpoint, json, options); | |||
return DeserializeJson<TResponse>(await SendInternalAsync(method, endpoint, request).ConfigureAwait(false)); | |||
} | |||
@@ -803,7 +803,8 @@ namespace Discord.API | |||
options = RequestOptions.CreateOrClone(options); | |||
var ids = new BucketIds(guildId: guildId); | |||
await SendAsync("PUT", () => $"guilds/{guildId}/bans/{userId}?delete-message-days={args.DeleteMessageDays}", ids, options: options).ConfigureAwait(false); | |||
string reason = string.IsNullOrWhiteSpace(args.Reason) ? "" : $"&reason={args.Reason}"; | |||
await SendAsync("PUT", () => $"guilds/{guildId}/bans/{userId}?delete-message-days={args.DeleteMessageDays}{reason}", ids, options: options).ConfigureAwait(false); | |||
} | |||
public async Task RemoveGuildBanAsync(ulong guildId, ulong userId, RequestOptions options = null) | |||
{ | |||
@@ -980,14 +981,15 @@ namespace Discord.API | |||
Expression<Func<string>> endpoint = () => $"guilds/{guildId}/members?limit={limit}&after={afterUserId}"; | |||
return await SendAsync<IReadOnlyCollection<GuildMember>>("GET", endpoint, ids, options: options).ConfigureAwait(false); | |||
} | |||
public async Task RemoveGuildMemberAsync(ulong guildId, ulong userId, RequestOptions options = null) | |||
public async Task RemoveGuildMemberAsync(ulong guildId, ulong userId, string reason, RequestOptions options = null) | |||
{ | |||
Preconditions.NotEqual(guildId, 0, nameof(guildId)); | |||
Preconditions.NotEqual(userId, 0, nameof(userId)); | |||
options = RequestOptions.CreateOrClone(options); | |||
var ids = new BucketIds(guildId: guildId); | |||
await SendAsync("DELETE", () => $"guilds/{guildId}/members/{userId}", ids, options: options).ConfigureAwait(false); | |||
reason = string.IsNullOrWhiteSpace(reason) ? "" : $"?reason={reason}"; | |||
await SendAsync("DELETE", () => $"guilds/{guildId}/members/{userId}{reason}", ids, options: options).ConfigureAwait(false); | |||
} | |||
public async Task ModifyGuildMemberAsync(ulong guildId, ulong userId, Rest.ModifyGuildMemberParams args, RequestOptions options = null) | |||
{ | |||
@@ -178,12 +178,12 @@ namespace Discord.Rest | |||
var args = new UploadFileParams(stream) { Filename = filename, Content = text, IsTTS = isTTS }; | |||
var model = await client.ApiClient.UploadFileAsync(channel.Id, args, options).ConfigureAwait(false); | |||
return RestUserMessage.Create(client, channel, client.CurrentUser, model); | |||
} | |||
} | |||
public static async Task DeleteMessagesAsync(IMessageChannel channel, BaseDiscordClient client, | |||
IEnumerable<IMessage> messages, RequestOptions options) | |||
public static async Task DeleteMessagesAsync(IMessageChannel channel, BaseDiscordClient client, | |||
IEnumerable<ulong> messageIds, RequestOptions options) | |||
{ | |||
var msgs = messages.Select(x => x.Id).ToArray(); | |||
var msgs = messageIds.ToArray(); | |||
if (msgs.Length < 100) | |||
{ | |||
var args = new DeleteMessagesParams(msgs); | |||
@@ -73,7 +73,9 @@ namespace Discord.Rest | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -86,7 +86,9 @@ namespace Discord.Rest | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -64,7 +64,9 @@ namespace Discord.Rest | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -43,7 +43,9 @@ namespace Discord.Rest | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -107,9 +107,9 @@ namespace Discord.Rest | |||
} | |||
public static async Task AddBanAsync(IGuild guild, BaseDiscordClient client, | |||
ulong userId, int pruneDays, RequestOptions options) | |||
ulong userId, int pruneDays, string reason, RequestOptions options) | |||
{ | |||
var args = new CreateGuildBanParams { DeleteMessageDays = pruneDays }; | |||
var args = new CreateGuildBanParams { DeleteMessageDays = pruneDays, Reason = reason }; | |||
await client.ApiClient.CreateGuildBanAsync(guild.Id, userId, args, options).ConfigureAwait(false); | |||
} | |||
public static async Task RemoveBanAsync(IGuild guild, BaseDiscordClient client, | |||
@@ -137,10 +137,10 @@ namespace Discord.Rest | |||
public Task<IReadOnlyCollection<RestBan>> GetBansAsync(RequestOptions options = null) | |||
=> GuildHelper.GetBansAsync(this, Discord, options); | |||
public Task AddBanAsync(IUser user, int pruneDays = 0, RequestOptions options = null) | |||
=> GuildHelper.AddBanAsync(this, Discord, user.Id, pruneDays, options); | |||
public Task AddBanAsync(ulong userId, int pruneDays = 0, RequestOptions options = null) | |||
=> GuildHelper.AddBanAsync(this, Discord, userId, pruneDays, options); | |||
public Task AddBanAsync(IUser user, int pruneDays = 0, string reason = null, RequestOptions options = null) | |||
=> GuildHelper.AddBanAsync(this, Discord, user.Id, pruneDays, reason, options); | |||
public Task AddBanAsync(ulong userId, int pruneDays = 0, string reason = null, RequestOptions options = null) | |||
=> GuildHelper.AddBanAsync(this, Discord, userId, pruneDays, reason, options); | |||
public Task RemoveBanAsync(IUser user, RequestOptions options = null) | |||
=> GuildHelper.RemoveBanAsync(this, Discord, user.Id, options); | |||
@@ -8,19 +8,66 @@ namespace Discord | |||
{ | |||
private readonly Embed _embed; | |||
public const int MaxFieldCount = 25; | |||
public const int MaxTitleLength = 256; | |||
public const int MaxDescriptionLength = 2048; | |||
public const int MaxEmbedLength = 6000; // user bot limit is 2000, but we don't validate that here. | |||
public EmbedBuilder() | |||
{ | |||
_embed = new Embed("rich"); | |||
_embed = new Embed(EmbedType.Rich); | |||
Fields = new List<EmbedFieldBuilder>(); | |||
} | |||
public string Title { get { return _embed.Title; } set { _embed.Title = value; } } | |||
public string Description { get { return _embed.Description; } set { _embed.Description = value; } } | |||
public string Url { get { return _embed.Url; } set { _embed.Url = value; } } | |||
public string ThumbnailUrl { get { return _embed.Thumbnail?.Url; } set { _embed.Thumbnail = new EmbedThumbnail(value, null, null, null); } } | |||
public string ImageUrl { get { return _embed.Image?.Url; } set { _embed.Image = new EmbedImage(value, null, null, null); } } | |||
public DateTimeOffset? Timestamp { get { return _embed.Timestamp; } set { _embed.Timestamp = value; } } | |||
public Color? Color { get { return _embed.Color; } set { _embed.Color = value; } } | |||
public string Title | |||
{ | |||
get => _embed.Title; | |||
set | |||
{ | |||
if (value?.Length > MaxTitleLength) throw new ArgumentException($"Title length must be less than or equal to {MaxTitleLength}.", nameof(Title)); | |||
_embed.Title = value; | |||
} | |||
} | |||
public string Description | |||
{ | |||
get => _embed.Description; | |||
set | |||
{ | |||
if (value?.Length > MaxDescriptionLength) throw new ArgumentException($"Description length must be less than or equal to {MaxDescriptionLength}.", nameof(Description)); | |||
_embed.Description = value; | |||
} | |||
} | |||
public string Url | |||
{ | |||
get => _embed.Url; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(Url)); | |||
_embed.Url = value; | |||
} | |||
} | |||
public string ThumbnailUrl | |||
{ | |||
get => _embed.Thumbnail?.Url; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(ThumbnailUrl)); | |||
_embed.Thumbnail = new EmbedThumbnail(value, null, null, null); | |||
} | |||
} | |||
public string ImageUrl | |||
{ | |||
get => _embed.Image?.Url; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(ImageUrl)); | |||
_embed.Image = new EmbedImage(value, null, null, null); | |||
} | |||
} | |||
public DateTimeOffset? Timestamp { get => _embed.Timestamp; set { _embed.Timestamp = value; } } | |||
public Color? Color { get => _embed.Color; set { _embed.Color = value; } } | |||
public EmbedAuthorBuilder Author { get; set; } | |||
public EmbedFooterBuilder Footer { get; set; } | |||
@@ -30,8 +77,10 @@ namespace Discord | |||
get => _fields; | |||
set | |||
{ | |||
if (value != null) _fields = value; | |||
else throw new ArgumentNullException("Cannot set an embed builder's fields collection to null", nameof(value)); | |||
if (value == null) throw new ArgumentNullException("Cannot set an embed builder's fields collection to null", nameof(Fields)); | |||
if (value.Count > MaxFieldCount) throw new ArgumentException($"Field count must be less than or equal to {MaxFieldCount}.", nameof(Fields)); | |||
_fields = value; | |||
} | |||
} | |||
@@ -88,6 +137,17 @@ namespace Discord | |||
Author = author; | |||
return this; | |||
} | |||
public EmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null) | |||
{ | |||
var author = new EmbedAuthorBuilder | |||
{ | |||
Name = name, | |||
IconUrl = iconUrl, | |||
Url = url | |||
}; | |||
Author = author; | |||
return this; | |||
} | |||
public EmbedBuilder WithFooter(EmbedFooterBuilder footer) | |||
{ | |||
Footer = footer; | |||
@@ -100,6 +160,16 @@ namespace Discord | |||
Footer = footer; | |||
return this; | |||
} | |||
public EmbedBuilder WithFooter(string text, string iconUrl = null) | |||
{ | |||
var footer = new EmbedFooterBuilder | |||
{ | |||
Text = text, | |||
IconUrl = iconUrl | |||
}; | |||
Footer = footer; | |||
return this; | |||
} | |||
public EmbedBuilder AddField(string name, object value) | |||
{ | |||
@@ -107,7 +177,7 @@ namespace Discord | |||
.WithIsInline(false) | |||
.WithName(name) | |||
.WithValue(value); | |||
Fields.Add(field); | |||
AddField(field); | |||
return this; | |||
} | |||
public EmbedBuilder AddInlineField(string name, object value) | |||
@@ -116,11 +186,16 @@ namespace Discord | |||
.WithIsInline(true) | |||
.WithName(name) | |||
.WithValue(value); | |||
Fields.Add(field); | |||
AddField(field); | |||
return this; | |||
} | |||
public EmbedBuilder AddField(EmbedFieldBuilder field) | |||
{ | |||
if (Fields.Count >= MaxFieldCount) | |||
{ | |||
throw new ArgumentException($"Field count must be less than or equal to {MaxFieldCount}.", nameof(field)); | |||
} | |||
Fields.Add(field); | |||
return this; | |||
} | |||
@@ -128,7 +203,18 @@ namespace Discord | |||
{ | |||
var field = new EmbedFieldBuilder(); | |||
action(field); | |||
Fields.Add(field); | |||
this.AddField(field); | |||
return this; | |||
} | |||
public EmbedBuilder AddField(string title, string text, bool inline = false) | |||
{ | |||
var field = new EmbedFieldBuilder | |||
{ | |||
Name = title, | |||
Value = text, | |||
IsInline = inline | |||
}; | |||
_fields.Add(field); | |||
return this; | |||
} | |||
@@ -140,6 +226,12 @@ namespace Discord | |||
for (int i = 0; i < Fields.Count; i++) | |||
fields.Add(Fields[i].Build()); | |||
_embed.Fields = fields.ToImmutable(); | |||
if (_embed.Length > MaxEmbedLength) | |||
{ | |||
throw new InvalidOperationException($"Total embed length must be less than or equal to {MaxEmbedLength}"); | |||
} | |||
return _embed; | |||
} | |||
public static implicit operator Embed(EmbedBuilder builder) => builder?.Build(); | |||
@@ -149,9 +241,32 @@ namespace Discord | |||
{ | |||
private EmbedField _field; | |||
public string Name { get { return _field.Name; } set { _field.Name = value; } } | |||
public object Value { get { return _field.Value; } set { _field.Value = value.ToString(); } } | |||
public bool IsInline { get { return _field.Inline; } set { _field.Inline = value; } } | |||
public const int MaxFieldNameLength = 256; | |||
public const int MaxFieldValueLength = 1024; | |||
public string Name | |||
{ | |||
get => _field.Name; | |||
set | |||
{ | |||
if (string.IsNullOrEmpty(value)) throw new ArgumentException($"Field name must not be null or empty.", nameof(Name)); | |||
if (value.Length > MaxFieldNameLength) throw new ArgumentException($"Field name length must be less than or equal to {MaxFieldNameLength}.", nameof(Name)); | |||
_field.Name = value; | |||
} | |||
} | |||
public object Value | |||
{ | |||
get => _field.Value; | |||
set | |||
{ | |||
var stringValue = value?.ToString(); | |||
if (string.IsNullOrEmpty(stringValue)) throw new ArgumentException($"Field value must not be null or empty.", nameof(Value)); | |||
if (stringValue.Length > MaxFieldValueLength) throw new ArgumentException($"Field value length must be less than or equal to {MaxFieldValueLength}.", nameof(Value)); | |||
_field.Value = stringValue; | |||
} | |||
} | |||
public bool IsInline { get => _field.Inline; set { _field.Inline = value; } } | |||
public EmbedFieldBuilder() | |||
{ | |||
@@ -182,9 +297,35 @@ namespace Discord | |||
{ | |||
private EmbedAuthor _author; | |||
public string Name { get { return _author.Name; } set { _author.Name = value; } } | |||
public string Url { get { return _author.Url; } set { _author.Url = value; } } | |||
public string IconUrl { get { return _author.IconUrl; } set { _author.IconUrl = value; } } | |||
public const int MaxAuthorNameLength = 256; | |||
public string Name | |||
{ | |||
get => _author.Name; | |||
set | |||
{ | |||
if (value?.Length > MaxAuthorNameLength) throw new ArgumentException($"Author name length must be less than or equal to {MaxAuthorNameLength}.", nameof(Name)); | |||
_author.Name = value; | |||
} | |||
} | |||
public string Url | |||
{ | |||
get => _author.Url; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(Url)); | |||
_author.Url = value; | |||
} | |||
} | |||
public string IconUrl | |||
{ | |||
get => _author.IconUrl; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(IconUrl)); | |||
_author.IconUrl = value; | |||
} | |||
} | |||
public EmbedAuthorBuilder() | |||
{ | |||
@@ -215,8 +356,26 @@ namespace Discord | |||
{ | |||
private EmbedFooter _footer; | |||
public string Text { get { return _footer.Text; } set { _footer.Text = value; } } | |||
public string IconUrl { get { return _footer.IconUrl; } set { _footer.IconUrl = value; } } | |||
public const int MaxFooterTextLength = 2048; | |||
public string Text | |||
{ | |||
get => _footer.Text; | |||
set | |||
{ | |||
if (value?.Length > MaxFooterTextLength) throw new ArgumentException($"Footer text length must be less than or equal to {MaxFooterTextLength}.", nameof(Text)); | |||
_footer.Text = value; | |||
} | |||
} | |||
public string IconUrl | |||
{ | |||
get => _footer.IconUrl; | |||
set | |||
{ | |||
if (!value.IsNullOrUri()) throw new ArgumentException("Url must be a well-formed URI", nameof(IconUrl)); | |||
_footer.IconUrl = value; | |||
} | |||
} | |||
public EmbedFooterBuilder() | |||
{ | |||
@@ -85,8 +85,8 @@ namespace Discord.Rest | |||
else if (args.RoleIds.IsSpecified) | |||
UpdateRoles(args.RoleIds.Value.ToArray()); | |||
} | |||
public Task KickAsync(RequestOptions options = null) | |||
=> UserHelper.KickAsync(this, Discord, options); | |||
public Task KickAsync(string reason = null, RequestOptions options = null) | |||
=> UserHelper.KickAsync(this, Discord, reason, options); | |||
/// <inheritdoc /> | |||
public Task AddRoleAsync(IRole role, RequestOptions options = null) | |||
=> AddRolesAsync(new[] { role }, options); | |||
@@ -54,7 +54,7 @@ namespace Discord.Rest | |||
Update(model); | |||
} | |||
public Task<RestDMChannel> CreateDMChannelAsync(RequestOptions options = null) | |||
public Task<RestDMChannel> GetOrCreateDMChannelAsync(RequestOptions options = null) | |||
=> UserHelper.CreateDMChannelAsync(this, Discord, options); | |||
public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) | |||
@@ -64,9 +64,7 @@ namespace Discord.Rest | |||
private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")})"; | |||
//IUser | |||
Task<IDMChannel> IUser.GetDMChannelAsync(CacheMode mode, RequestOptions options) | |||
=> Task.FromResult<IDMChannel>(null); | |||
async Task<IDMChannel> IUser.CreateDMChannelAsync(RequestOptions options) | |||
=> await CreateDMChannelAsync(options).ConfigureAwait(false); | |||
async Task<IDMChannel> IUser.GetOrCreateDMChannelAsync(RequestOptions options) | |||
=> await GetOrCreateDMChannelAsync(options); | |||
} | |||
} |
@@ -45,7 +45,7 @@ namespace Discord.Rest | |||
GuildPermissions IGuildUser.GuildPermissions => GuildPermissions.Webhook; | |||
ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) => Permissions.ToChannelPerms(channel, GuildPermissions.Webhook.RawValue); | |||
Task IGuildUser.KickAsync(RequestOptions options) | |||
Task IGuildUser.KickAsync(string reason, RequestOptions options) | |||
{ | |||
throw new NotSupportedException("Webhook users cannot be kicked."); | |||
} | |||
@@ -53,9 +53,9 @@ namespace Discord.Rest | |||
} | |||
public static async Task KickAsync(IGuildUser user, BaseDiscordClient client, | |||
RequestOptions options) | |||
string reason, RequestOptions options) | |||
{ | |||
await client.ApiClient.RemoveGuildMemberAsync(user.GuildId, user.Id, options).ConfigureAwait(false); | |||
await client.ApiClient.RemoveGuildMemberAsync(user.GuildId, user.Id, reason, options).ConfigureAwait(false); | |||
} | |||
public static async Task<RestDMChannel> CreateDMChannelAsync(IUser user, BaseDiscordClient client, | |||
@@ -0,0 +1,23 @@ | |||
namespace Discord | |||
{ | |||
public static class EmbedBuilderExtensions | |||
{ | |||
public static EmbedBuilder WithColor(this EmbedBuilder builder, uint rawValue) => | |||
builder.WithColor(new Color(rawValue)); | |||
public static EmbedBuilder WithColor(this EmbedBuilder builder, byte r, byte g, byte b) => | |||
builder.WithColor(new Color(r, g, b)); | |||
public static EmbedBuilder WithColor(this EmbedBuilder builder, int r, int g, int b) => | |||
builder.WithColor(new Color(r, g, b)); | |||
public static EmbedBuilder WithColor(this EmbedBuilder builder, float r, float g, float b) => | |||
builder.WithColor(new Color(r, g, b)); | |||
public static EmbedBuilder WithAuthor(this EmbedBuilder builder, IUser user) => | |||
builder.WithAuthor($"{user.Username}#{user.Discriminator}", user.GetAvatarUrl()); | |||
public static EmbedBuilder WithAuthor(this EmbedBuilder builder, IGuildUser user) => | |||
builder.WithAuthor($"{user.Nickname ?? user.Username}#{user.Discriminator}", user.GetAvatarUrl()); | |||
} | |||
} |
@@ -62,26 +62,31 @@ namespace Discord.Net.Rest | |||
_cancelToken = cancelToken; | |||
} | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly) | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null) | |||
{ | |||
string uri = Path.Combine(_baseUrl, endpoint); | |||
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) | |||
{ | |||
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); | |||
return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); | |||
} | |||
} | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly) | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null) | |||
{ | |||
string uri = Path.Combine(_baseUrl, endpoint); | |||
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) | |||
{ | |||
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); | |||
restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); | |||
return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); | |||
} | |||
} | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly) | |||
public async Task<RestResponse> SendAsync(string method, string endpoint, IReadOnlyDictionary<string, object> multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null) | |||
{ | |||
string uri = Path.Combine(_baseUrl, endpoint); | |||
using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) | |||
{ | |||
if (reason != null) restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); | |||
var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); | |||
if (multipartParams != null) | |||
{ | |||
@@ -15,7 +15,7 @@ namespace Discord.Net.Queue | |||
public override async Task<RestResponse> SendAsync() | |||
{ | |||
return await Client.SendAsync(Method, Endpoint, Json, Options.CancelToken, Options.HeaderOnly).ConfigureAwait(false); | |||
return await Client.SendAsync(Method, Endpoint, Json, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason).ConfigureAwait(false); | |||
} | |||
} | |||
} |
@@ -16,7 +16,7 @@ namespace Discord.Net.Queue | |||
public override async Task<RestResponse> SendAsync() | |||
{ | |||
return await Client.SendAsync(Method, Endpoint, MultipartParams, Options.CancelToken, Options.HeaderOnly).ConfigureAwait(false); | |||
return await Client.SendAsync(Method, Endpoint, MultipartParams, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason).ConfigureAwait(false); | |||
} | |||
} | |||
} |
@@ -28,7 +28,7 @@ namespace Discord.Net.Queue | |||
public virtual async Task<RestResponse> SendAsync() | |||
{ | |||
return await Client.SendAsync(Method, Endpoint, Options.CancelToken, Options.HeaderOnly).ConfigureAwait(false); | |||
return await Client.SendAsync(Method, Endpoint, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason).ConfigureAwait(false); | |||
} | |||
} | |||
} |
@@ -54,7 +54,9 @@ namespace Discord.Rpc | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -57,7 +57,9 @@ namespace Discord.Rpc | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -58,7 +58,9 @@ namespace Discord.Rpc | |||
=> ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, options); | |||
public Task DeleteMessagesAsync(IEnumerable<IMessage> messages, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages, options); | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); | |||
public Task DeleteMessagesAsync(IEnumerable<ulong> messageIds, RequestOptions options = null) | |||
=> ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); | |||
public Task TriggerTypingAsync(RequestOptions options = null) | |||
=> ChannelHelper.TriggerTypingAsync(this, Discord, options); | |||
@@ -49,7 +49,7 @@ namespace Discord.Rpc | |||
Username = model.Username.Value; | |||
} | |||
public Task<RestDMChannel> CreateDMChannelAsync(RequestOptions options = null) | |||
public Task<RestDMChannel> GetOrCreateDMChannelAsync(RequestOptions options = null) | |||
=> UserHelper.CreateDMChannelAsync(this, Discord, options); | |||
public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) | |||
@@ -59,9 +59,7 @@ namespace Discord.Rpc | |||
private string DebuggerDisplay => $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")})"; | |||
//IUser | |||
Task<IDMChannel> IUser.GetDMChannelAsync(CacheMode mode, RequestOptions options) | |||
=> Task.FromResult<IDMChannel>(null); | |||
async Task<IDMChannel> IUser.CreateDMChannelAsync(RequestOptions options) | |||
=> await CreateDMChannelAsync(options).ConfigureAwait(false); | |||
async Task<IDMChannel> IUser.GetOrCreateDMChannelAsync(RequestOptions options) | |||
=> await GetOrCreateDMChannelAsync(options); | |||
} | |||
} |
@@ -142,31 +142,31 @@ namespace Discord.Audio | |||
public AudioOutStream CreateOpusStream(int bufferMillis) | |||
{ | |||
var outputStream = new OutputStream(ApiClient); | |||
var sodiumEncrypter = new SodiumEncryptStream( outputStream, this); | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); | |||
return new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); | |||
var outputStream = new OutputStream(ApiClient); //Ignores header | |||
var sodiumEncrypter = new SodiumEncryptStream( outputStream, this); //Passes header | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes | |||
return new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); //Generates header | |||
} | |||
public AudioOutStream CreateDirectOpusStream() | |||
{ | |||
var outputStream = new OutputStream(ApiClient); | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); | |||
return new RTPWriteStream(sodiumEncrypter, _ssrc); | |||
var outputStream = new OutputStream(ApiClient); //Ignores header | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header | |||
return new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header (external input), passes | |||
} | |||
public AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate, int bufferMillis) | |||
public AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate, int bufferMillis, int packetLoss) | |||
{ | |||
var outputStream = new OutputStream(ApiClient); | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); | |||
var bufferedStream = new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); | |||
return new OpusEncodeStream(bufferedStream, bitrate ?? (96 * 1024), application); | |||
var outputStream = new OutputStream(ApiClient); //Ignores header | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes | |||
var bufferedStream = new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); //Ignores header, generates header | |||
return new OpusEncodeStream(bufferedStream, bitrate ?? (96 * 1024), application, packetLoss); //Generates header | |||
} | |||
public AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate) | |||
public AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate, int packetLoss) | |||
{ | |||
var outputStream = new OutputStream(ApiClient); | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); | |||
return new OpusEncodeStream(rtpWriter, bitrate ?? (96 * 1024), application); | |||
var outputStream = new OutputStream(ApiClient); //Ignores header | |||
var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header | |||
var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes | |||
return new OpusEncodeStream(rtpWriter, bitrate ?? (96 * 1024), application, packetLoss); //Generates header | |||
} | |||
internal async Task CreateInputStreamAsync(ulong userId) | |||
@@ -174,11 +174,11 @@ namespace Discord.Audio | |||
//Assume Thread-safe | |||
if (!_streams.ContainsKey(userId)) | |||
{ | |||
var readerStream = new InputStream(); | |||
var opusDecoder = new OpusDecodeStream(readerStream); | |||
var readerStream = new InputStream(); //Consumes header | |||
var opusDecoder = new OpusDecodeStream(readerStream); //Passes header | |||
//var jitterBuffer = new JitterBuffer(opusDecoder, _audioLogger); | |||
var rtpReader = new RTPReadStream(opusDecoder); | |||
var decryptStream = new SodiumDecryptStream(rtpReader, this); | |||
var rtpReader = new RTPReadStream(opusDecoder); //Generates header | |||
var decryptStream = new SodiumDecryptStream(rtpReader, this); //No header | |||
_streams.TryAdd(userId, new StreamPair(readerStream, decryptStream)); | |||
await _streamCreatedEvent.InvokeAsync(userId, readerStream); | |||
} | |||
@@ -17,7 +17,7 @@ namespace Discord.Audio | |||
public AudioApplication Application { get; } | |||
public int BitRate { get;} | |||
public OpusEncoder(int bitrate, AudioApplication application) | |||
public OpusEncoder(int bitrate, AudioApplication application, int packetLoss) | |||
{ | |||
if (bitrate < 1 || bitrate > DiscordVoiceAPIClient.MaxBitrate) | |||
throw new ArgumentOutOfRangeException(nameof(bitrate)); | |||
@@ -48,7 +48,7 @@ namespace Discord.Audio | |||
_ptr = CreateEncoder(SamplingRate, Channels, (int)opusApplication, out var error); | |||
CheckError(error); | |||
CheckError(EncoderCtl(_ptr, OpusCtl.SetSignal, (int)opusSignal)); | |||
CheckError(EncoderCtl(_ptr, OpusCtl.SetPacketLossPercent, 30)); //% | |||
CheckError(EncoderCtl(_ptr, OpusCtl.SetPacketLossPercent, packetLoss)); //% | |||
CheckError(EncoderCtl(_ptr, OpusCtl.SetInbandFEC, 1)); //True | |||
CheckError(EncoderCtl(_ptr, OpusCtl.SetBitrate, bitrate)); | |||
} | |||
@@ -88,11 +88,12 @@ namespace Discord.Audio.Streams | |||
if (_queuedFrames.TryDequeue(out Frame frame)) | |||
{ | |||
await _client.SetSpeakingAsync(true).ConfigureAwait(false); | |||
_next.WriteHeader(seq++, timestamp, false); | |||
_next.WriteHeader(seq, timestamp, false); | |||
await _next.WriteAsync(frame.Buffer, 0, frame.Bytes).ConfigureAwait(false); | |||
_bufferPool.Enqueue(frame.Buffer); | |||
_queueLock.Release(); | |||
nextTick += _ticksPerFrame; | |||
seq++; | |||
timestamp += OpusEncoder.FrameSamplesPerChannel; | |||
_silenceFrames = 0; | |||
#if DEBUG | |||
@@ -105,12 +106,13 @@ namespace Discord.Audio.Streams | |||
{ | |||
if (_silenceFrames++ < MaxSilenceFrames) | |||
{ | |||
_next.WriteHeader(seq++, timestamp, false); | |||
_next.WriteHeader(seq, timestamp, false); | |||
await _next.WriteAsync(_silenceFrame, 0, _silenceFrame.Length).ConfigureAwait(false); | |||
} | |||
else | |||
await _client.SetSpeakingAsync(false).ConfigureAwait(false); | |||
nextTick += _ticksPerFrame; | |||
seq++; | |||
timestamp += OpusEncoder.FrameSamplesPerChannel; | |||
} | |||
#if DEBUG | |||
@@ -126,6 +128,7 @@ namespace Discord.Audio.Streams | |||
}); | |||
} | |||
public override void WriteHeader(ushort seq, uint timestamp, bool missed) { } //Ignore, we use our own timing | |||
public override async Task WriteAsync(byte[] data, int offset, int count, CancellationToken cancelToken) | |||
{ | |||
if (cancelToken.CanBeCanceled) | |||
@@ -1,4 +1,4 @@ | |||
using Discord.Logging; | |||
/*using Discord.Logging; | |||
using System; | |||
using System.Collections.Concurrent; | |||
using System.Threading; | |||
@@ -243,4 +243,4 @@ namespace Discord.Audio.Streams | |||
return Task.Delay(0); | |||
} | |||
} | |||
} | |||
}*/ |
@@ -25,12 +25,13 @@ namespace Discord.Audio.Streams | |||
public override void WriteHeader(ushort seq, uint timestamp, bool missed) | |||
{ | |||
if (_hasHeader) | |||
throw new InvalidOperationException("Header received with no payload"); | |||
_nextMissed = missed; | |||
throw new InvalidOperationException("Header received with no payload"); | |||
_hasHeader = true; | |||
_nextMissed = missed; | |||
_next.WriteHeader(seq, timestamp, missed); | |||
} | |||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) | |||
{ | |||
if (!_hasHeader) | |||
throw new InvalidOperationException("Received payload without an RTP header"); | |||
@@ -39,17 +40,17 @@ namespace Discord.Audio.Streams | |||
if (!_nextMissed) | |||
{ | |||
count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0, false); | |||
await _next.WriteAsync(_buffer, 0, count, cancellationToken).ConfigureAwait(false); | |||
await _next.WriteAsync(_buffer, 0, count, cancelToken).ConfigureAwait(false); | |||
} | |||
else if (count > 0) | |||
{ | |||
count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0, true); | |||
await _next.WriteAsync(_buffer, 0, count, cancellationToken).ConfigureAwait(false); | |||
count = _decoder.DecodeFrame(buffer, offset, count, _buffer, 0, true); | |||
await _next.WriteAsync(_buffer, 0, count, cancelToken).ConfigureAwait(false); | |||
} | |||
else | |||
{ | |||
count = _decoder.DecodeFrame(null, 0, 0, _buffer, 0, true); | |||
await _next.WriteAsync(_buffer, 0, count, cancellationToken).ConfigureAwait(false); | |||
count = _decoder.DecodeFrame(null, 0, 0, _buffer, 0, true); | |||
await _next.WriteAsync(_buffer, 0, count, cancelToken).ConfigureAwait(false); | |||
} | |||
} | |||
@@ -8,20 +8,22 @@ namespace Discord.Audio.Streams | |||
public class OpusEncodeStream : AudioOutStream | |||
{ | |||
public const int SampleRate = 48000; | |||
private readonly AudioStream _next; | |||
private readonly OpusEncoder _encoder; | |||
private readonly byte[] _buffer; | |||
private int _partialFramePos; | |||
public OpusEncodeStream(AudioStream next, int bitrate, AudioApplication application) | |||
private ushort _seq; | |||
private uint _timestamp; | |||
public OpusEncodeStream(AudioStream next, int bitrate, AudioApplication application, int packetLoss) | |||
{ | |||
_next = next; | |||
_encoder = new OpusEncoder(bitrate, application); | |||
_encoder = new OpusEncoder(bitrate, application, packetLoss); | |||
_buffer = new byte[OpusConverter.FrameBytes]; | |||
} | |||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) | |||
{ | |||
//Assume threadsafe | |||
while (count > 0) | |||
@@ -30,10 +32,13 @@ namespace Discord.Audio.Streams | |||
{ | |||
//We have enough data and no partial frames. Pass the buffer directly to the encoder | |||
int encFrameSize = _encoder.EncodeFrame(buffer, offset, _buffer, 0); | |||
await _next.WriteAsync(_buffer, 0, encFrameSize, cancellationToken).ConfigureAwait(false); | |||
_next.WriteHeader(_seq, _timestamp, false); | |||
await _next.WriteAsync(_buffer, 0, encFrameSize, cancelToken).ConfigureAwait(false); | |||
offset += OpusConverter.FrameBytes; | |||
count -= OpusConverter.FrameBytes; | |||
_seq++; | |||
_timestamp += OpusConverter.FrameSamplesPerChannel; | |||
} | |||
else if (_partialFramePos + count >= OpusConverter.FrameBytes) | |||
{ | |||
@@ -41,11 +46,14 @@ namespace Discord.Audio.Streams | |||
int partialSize = OpusConverter.FrameBytes - _partialFramePos; | |||
Buffer.BlockCopy(buffer, offset, _buffer, _partialFramePos, partialSize); | |||
int encFrameSize = _encoder.EncodeFrame(_buffer, 0, _buffer, 0); | |||
await _next.WriteAsync(_buffer, 0, encFrameSize, cancellationToken).ConfigureAwait(false); | |||
_next.WriteHeader(_seq, _timestamp, false); | |||
await _next.WriteAsync(_buffer, 0, encFrameSize, cancelToken).ConfigureAwait(false); | |||
offset += partialSize; | |||
count -= partialSize; | |||
_partialFramePos = 0; | |||
_seq++; | |||
_timestamp += OpusConverter.FrameSamplesPerChannel; | |||
} | |||
else | |||
{ | |||
@@ -57,8 +65,8 @@ namespace Discord.Audio.Streams | |||
} | |||
} | |||
/* | |||
public override async Task FlushAsync(CancellationToken cancellationToken) | |||
/* //Opus throws memory errors on bad frames | |||
public override async Task FlushAsync(CancellationToken cancelToken) | |||
{ | |||
try | |||
{ | |||
@@ -67,7 +75,7 @@ namespace Discord.Audio.Streams | |||
} | |||
catch (Exception) { } //Incomplete frame | |||
_partialFramePos = 0; | |||
await base.FlushAsync(cancellationToken).ConfigureAwait(false); | |||
await base.FlushAsync(cancelToken).ConfigureAwait(false); | |||
}*/ | |||
public override async Task FlushAsync(CancellationToken cancelToken) | |||