From e18b34ae135ebc00a8d531c22a01ff8e5713219d Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Tue, 11 Jan 2022 10:00:02 +0100 Subject: [PATCH 01/11] Remove unused imports --- lib/src/checks.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/src/checks.dart b/lib/src/checks.dart index 72bfaa7..18aac5f 100644 --- a/lib/src/checks.dart +++ b/lib/src/checks.dart @@ -17,11 +17,8 @@ import 'dart:async'; import 'package:logging/logging.dart'; import 'package:nyxx/nyxx.dart'; import 'package:nyxx_commands/nyxx_commands.dart'; -import 'package:nyxx_commands/src/commands.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; -import 'context.dart'; - final Logger _logger = Logger('Commands'); /// Represents a check executed on a [Command]. From c48d9bfe055ca0e8a71df42a4984298cca7e5fa6 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Tue, 11 Jan 2022 10:26:14 +0100 Subject: [PATCH 02/11] Refactor: rename registerChild method --- example/example.dart | 22 +++++++++++----------- example/example_clean.dart | 18 +++++++++--------- lib/src/command.dart | 8 ++++---- lib/src/commands.dart | 8 ++++---- lib/src/group.dart | 36 +++++++++++++++++++++++------------- 5 files changed, 51 insertions(+), 41 deletions(-) diff --git a/example/example.dart b/example/example.dart index 790dae1..6f90d39 100644 --- a/example/example.dart +++ b/example/example.dart @@ -138,8 +138,8 @@ void main() { ); // Once we've created our command, we need to add it to our bot: - commands.registerChild(ping); - // The name `registerChild` might seem a bit weird as a name for adding a command to our bot, but + commands.addCommand(ping); + // The name `addCommand` might seem a bit weird as a name for adding a command to our bot, but // it makes sense if you imagine each bot as "owning" a command: // // client @@ -194,9 +194,9 @@ void main() { ], ); - // The other way to add a command to a group is using the `Group`'s `registerChild` method, + // The other way to add a command to a group is using the `Group`'s `addCommand` method, // similarly to how we added the `ping` command to the bot earlie. - throwGroup.registerChild(Command( + throwGroup.addCommand(Command( 'die', 'Throw a die', (Context context) { @@ -207,7 +207,7 @@ void main() { )); // Finally, just like the `ping` command, we need to add our command group to the bot: - commands.registerChild(throwGroup); + commands.addCommand(throwGroup); // At this point, if you run this file, a new command should have appeared in the slash command // menu on Discord: `throw`. Selecting it will let you choose from two sub-commands: `coin` or @@ -243,7 +243,7 @@ void main() { ); // As usual, we need to register the command to our bot. - commands.registerChild(say); + commands.addCommand(say); // At this point, if you run this file your command structure will look like this: // @@ -307,7 +307,7 @@ void main() { }, ); - commands.registerChild(nick); + commands.addCommand(nick); // At this point, if you run the file your command structure will look like this: // @@ -467,7 +467,7 @@ void main() { }, ); - commands.registerChild(favouriteShape); + commands.addCommand(favouriteShape); // At this point, if you run the file you will see that the `favourite-shape` command has been // added to the slash command menu. @@ -511,7 +511,7 @@ void main() { }, ); - commands.registerChild(favouriteFruit); + commands.addCommand(favouriteFruit); // At this point, if you run the file you will be able to use the `favourite-fruit` command. Once // you've selected the command in the slash command menu, you'll be given an option to provide a @@ -549,7 +549,7 @@ void main() { ], ); - commands.registerChild(alphabet); + commands.addCommand(alphabet); // At this point, if you run the file you will get an `alphabet` command appear in the slash // command menu. Executing it once will run fine, however trying to execute it again less that 30 @@ -587,7 +587,7 @@ void main() { }, ); - commands.registerChild(betterSay); + commands.addCommand(betterSay); // At this point, if you run the file, a new command `better-say` will have been added to the bot. // Attempting to invoke it with an empty string (`!better-say ""`) will cause the argument to diff --git a/example/example_clean.dart b/example/example_clean.dart index 0bd8fbf..fc6fc3b 100644 --- a/example/example_clean.dart +++ b/example/example_clean.dart @@ -43,7 +43,7 @@ void main() { }, ); - commands.registerChild(ping); + commands.addCommand(ping); Group throwGroup = Group( 'throw', @@ -62,7 +62,7 @@ void main() { ], ); - throwGroup.registerChild(Command( + throwGroup.addCommand(Command( 'die', 'Throw a die', (Context context) { @@ -72,7 +72,7 @@ void main() { }, )); - commands.registerChild(throwGroup); + commands.addCommand(throwGroup); Command say = Command( 'say', @@ -82,7 +82,7 @@ void main() { }, ); - commands.registerChild(say); + commands.addCommand(say); Command nick = Command( 'nick', @@ -99,7 +99,7 @@ void main() { }, ); - commands.registerChild(nick); + commands.addCommand(nick); Converter shapeConverter = Converter( (view, context) { @@ -172,7 +172,7 @@ void main() { }, ); - commands.registerChild(favouriteShape); + commands.addCommand(favouriteShape); Command favouriteFruit = Command( 'favourite-fruit', @@ -182,7 +182,7 @@ void main() { }, ); - commands.registerChild(favouriteFruit); + commands.addCommand(favouriteFruit); Command alphabet = Command( 'alphabet', @@ -198,7 +198,7 @@ void main() { ], ); - commands.registerChild(alphabet); + commands.addCommand(alphabet); const Converter nonEmptyStringConverter = CombineConverter(stringConverter, filterInput); @@ -213,7 +213,7 @@ void main() { }, ); - commands.registerChild(betterSay); + commands.addCommand(betterSay); } enum Shape { diff --git a/lib/src/command.dart b/lib/src/command.dart index 12df59d..6f90b76 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -198,7 +198,7 @@ class Command with GroupMixin { _loadArguments(execute, contextType); for (final child in children) { - registerChild(child); + addCommand(child); } for (final check in checks) { @@ -462,14 +462,14 @@ class Command with GroupMixin { } @override - void registerChild(GroupMixin child) { + void addCommand(GroupMixin command) { if (type != CommandType.textOnly) { - if (child.hasSlashCommand || (child is Command && child.type != CommandType.textOnly)) { + if (command.hasSlashCommand || (command is Command && command.type != CommandType.textOnly)) { throw CommandRegistrationError('Cannot nest Slash commands!'); } } - super.registerChild(child); + super.addCommand(command); } /// Add a check to this commands [singleChecks]. diff --git a/lib/src/commands.dart b/lib/src/commands.dart index bd067d0..847fd37 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -415,8 +415,8 @@ class CommandsPlugin extends BasePlugin with GroupMixin { } @override - void registerChild(GroupMixin child) { - super.registerChild(child); + void addCommand(GroupMixin command) { + super.addCommand(command); if (client?.ready ?? false) { _commandsLogger @@ -425,8 +425,8 @@ class CommandsPlugin extends BasePlugin with GroupMixin { interactions.sync(); } - for (final command in child.walkCommands()) { - _commandsLogger.info('Registered command "${command.fullName}"'); + for (final child in command.walkCommands()) { + _commandsLogger.info('Registered command "${child.fullName}"'); } } diff --git a/lib/src/group.dart b/lib/src/group.dart index afbdc0d..2d0027b 100644 --- a/lib/src/group.dart +++ b/lib/src/group.dart @@ -139,19 +139,20 @@ mixin GroupMixin { /// [CommandRegistrationError] is thrown. /// /// If [child] already has a parent, an [CommandRegistrationError] is thrown. - void registerChild(GroupMixin child) { - if (childrenMap.containsKey(child.name)) { - throw CommandRegistrationError('Command with name "$fullName ${child.name}" already exists'); + void addCommand(GroupMixin command) { + if (childrenMap.containsKey(command.name)) { + throw CommandRegistrationError( + 'Command with name "$fullName ${command.name}" already exists'); } - for (final alias in child.aliases) { + for (final alias in command.aliases) { if (childrenMap.containsKey(alias)) { throw CommandRegistrationError('Command with alias "$fullName $alias" already exists'); } } - if (child._parent != null) { - throw CommandRegistrationError('Cannot register command "${child.fullName}" again'); + if (command._parent != null) { + throw CommandRegistrationError('Cannot register command "${command.fullName}" again'); } if (_parent != null) { @@ -159,17 +160,26 @@ mixin GroupMixin { 'commands to have incomplete definitions'); } - child._parent = this; + command._parent = this; - childrenMap[child.name] = child; - for (final alias in child.aliases) { - childrenMap[alias] = child; + childrenMap[command.name] = command; + for (final alias in command.aliases) { + childrenMap[alias] = command; } - child.onPreCall.listen(preCallController.add); - child.onPostCall.listen(postCallController.add); + command.onPreCall.listen(preCallController.add); + command.onPostCall.listen(postCallController.add); } + /// Add a child to this group. + /// + /// If any of its name or aliases confict with already registered commands, a + /// [CommandRegistrationError] is thrown. + /// + /// If [child] already has a parent, an [CommandRegistrationError] is thrown. + @Deprecated('Use addCommand() instead') + void registerChild(GroupMixin child) => addCommand(child); + /// Iterate over all the commands in this group and any subgroups. Iterable walkCommands() sync* { if (this is Command) { @@ -245,7 +255,7 @@ class Group with GroupMixin { } for (final child in children) { - registerChild(child); + addCommand(child); } for (final check in checks) { From 3b592804276a5d2e92c646e1986ea4204835ca9a Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Wed, 12 Jan 2022 22:35:30 +0100 Subject: [PATCH 03/11] Add remaining method to CooldownCheck (#21) * Add remaining method to CooldownCheck() * Extract DateTime.now() calls to variable * Remove redundant code --- lib/src/checks.dart | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/src/checks.dart b/lib/src/checks.dart index 18aac5f..e239c35 100644 --- a/lib/src/checks.dart +++ b/lib/src/checks.dart @@ -579,6 +579,32 @@ class CooldownCheck extends AbstractCheck { return Object.hashAll(keys); } + /// Get the remaining cooldown time for a context. + /// + /// If the context is not on cooldown, [Duration.zero] is returned. + Duration remaining(Context context) { + if (check(context) as bool) { + return Duration.zero; + } + + DateTime now = DateTime.now(); + + int key = getKey(context); + if (_currentBucket.containsKey(key)) { + DateTime end = _currentBucket[key]!.start.add(duration); + + return end.difference(now); + } + + if (_previousBucket.containsKey(key)) { + DateTime end = _previousBucket[key]!.start.add(duration); + + return end.difference(now); + } + + return Duration.zero; + } + @override late Iterable preCallHooks = [ (context) { From 421da8042f3608588907b90ffd70309fac405e9d Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Wed, 12 Jan 2022 22:36:42 +0100 Subject: [PATCH 04/11] Release 3.3.0 --- CHANGELOG.md | 9 ++++++++- pubspec.yaml | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8fdeb1..5f6dbd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 3.3.0 +__New features__: +- Added a `remaining()` method to `CooldownCheck` to get the remaining cooldown for a context + +__Deprecations__: +- `registerChild` has been deprecated, users should prefer the better named `addCommand` method + ## 3.2.0 __Bug fixes__: - Exceptions are now correctly caught for commands with async `execute` functions. @@ -41,7 +48,7 @@ __Breaking changes__: - `BotOptions` has been renamed to `CommandsOptions` and no longer supports the options found in `ClientOptions`. Create two seperate instances and pass them to `NyxxFactory.createNyxx...` and `CommandsPlugin` respectively, in the `options` named parameter - The `bot` field on `Context` has been replaced with a `client` field pointing to the `INyxx` instance and a `commands` field pointing to the `CommandsPlugin` instance. -## 2.0.0: +## 2.0.0 __Breaking changes__: - Messages sent by bot users will no longer be executed by default, see `BotOptions.acceptBotCommands` and `BotOptions.acceptSelfCommands` diff --git a/pubspec.yaml b/pubspec.yaml index 48e7b62..944aff1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_commands -version: 3.2.0 +version: 3.3.0 description: A framework for easily creating slash commands and text commands for Discord using the nyxx library. homepage: https://github.com/nyxx-discord/nyxx_commands/blob/main/README.md From afab33ec20b6688e83b3ea1b77fc7975342455e1 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Wed, 12 Jan 2022 22:37:48 +0100 Subject: [PATCH 05/11] Remove confusing comment --- example/example.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example/example.dart b/example/example.dart index 6f90d39..93b6ab0 100644 --- a/example/example.dart +++ b/example/example.dart @@ -139,8 +139,7 @@ void main() { // Once we've created our command, we need to add it to our bot: commands.addCommand(ping); - // The name `addCommand` might seem a bit weird as a name for adding a command to our bot, but - // it makes sense if you imagine each bot as "owning" a command: + // The commands on a bot can be represented with a parent-child tree that looks like this: // // client // ┗━ ping From 1f597a7fe154142accb6889a54b4e4b12cbb176b Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Mon, 4 Apr 2022 23:36:37 +0200 Subject: [PATCH 06/11] Correctly forward arguyments in getConfirmation to getButtonPress (#43) --- lib/src/context/component_wrappers.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/context/component_wrappers.dart b/lib/src/context/component_wrappers.dart index 44138f1..b8abd94 100644 --- a/lib/src/context/component_wrappers.dart +++ b/lib/src/context/component_wrappers.dart @@ -92,7 +92,11 @@ mixin ComponentWrappersMixin implements IContext { await respond(componentMessageBuilder); - IButtonInteractionEvent event = await getButtonPress([confirmButton, denyButton]); + IButtonInteractionEvent event = await getButtonPress( + [confirmButton, denyButton], + authorOnly: authorOnly, + timeout: timeout, + ); return event.interaction.customId == confirmButton.customId; } } From fda6d12f944262c9686dcd250bc3b13a64b432fc Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Tue, 5 Apr 2022 07:30:05 +0200 Subject: [PATCH 07/11] Implement channel types for channel converters (#44) * Add processOptionCallback * Add GuildChannelConverter * Correctly handle processOptionCallback in CombineConverter --- lib/nyxx_commands.dart | 1 + lib/src/commands/chat_command.dart | 8 +- lib/src/converters/converter.dart | 160 +++++++++++++++++++---------- 3 files changed, 112 insertions(+), 57 deletions(-) diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index 5a4daaa..148921a 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -36,6 +36,7 @@ export 'src/converters/converter.dart' CombineConverter, Converter, FallbackConverter, + GuildChannelConverter, attachmentConverter, boolConverter, categoryGuildChannelConverter, diff --git a/lib/src/commands/chat_command.dart b/lib/src/commands/chat_command.dart index 3623f9c..83576c9 100644 --- a/lib/src/commands/chat_command.dart +++ b/lib/src/commands/chat_command.dart @@ -657,13 +657,17 @@ class ChatCommand choices ??= argumentConverter?.choices; - options.add(CommandOptionBuilder( + CommandOptionBuilder builder = CommandOptionBuilder( argumentConverter?.type ?? CommandOptionType.string, name, _mappedDescriptions[name]!.value, required: !mirror.isOptional, choices: choices?.toList(), - )); + ); + + argumentConverter?.processOptionCallback?.call(builder); + + options.add(builder); } return options; diff --git a/lib/src/converters/converter.dart b/lib/src/converters/converter.dart index a8e4649..2295c65 100644 --- a/lib/src/converters/converter.dart +++ b/lib/src/converters/converter.dart @@ -83,6 +83,11 @@ class Converter { /// Used by [CommandsPlugin.getConverter] to construct assembled converters. final Type output; + /// A callback called with the [CommandOptionBuilder] created for an option using this converter. + /// + /// Can be used to make custom changes to the builder that are not implemented by default. + final void Function(CommandOptionBuilder)? processOptionCallback; + /// Create a new converter. /// /// Strongly typing converter variables is recommended (i.e use `Converter(...)` instead @@ -90,6 +95,7 @@ class Converter { const Converter( this.convert, { this.choices, + this.processOptionCallback, this.type = CommandOptionType.string, }) : output = T; @@ -118,6 +124,12 @@ class CombineConverter implements Converter { @override final Type output; + final void Function(CommandOptionBuilder)? _customProcessOptionCallback; + + @override + void Function(CommandOptionBuilder)? get processOptionCallback => + _customProcessOptionCallback ?? converter.processOptionCallback; + final Iterable? _choices; final CommandOptionType? _type; @@ -127,9 +139,11 @@ class CombineConverter implements Converter { this.process, { Iterable? choices, CommandOptionType? type, + void Function(CommandOptionBuilder)? processOptionCallback, }) : _choices = choices, _type = type, - output = T; + output = T, + _customProcessOptionCallback = processOptionCallback; @override Iterable? get choices => _choices ?? converter.choices; @@ -165,6 +179,9 @@ class FallbackConverter implements Converter { /// The converters this [FallbackConverter] will attempt to use. final Iterable> converters; + @override + final void Function(CommandOptionBuilder)? processOptionCallback; + final Iterable? _choices; final CommandOptionType? _type; @@ -176,6 +193,7 @@ class FallbackConverter implements Converter { this.converters, { Iterable? choices, CommandOptionType? type, + this.processOptionCallback, }) : _choices = choices, _type = type, output = T; @@ -507,12 +525,10 @@ const Converter userConverter = FallbackConverter( type: CommandOptionType.user, ); -T? snowflakeToGuildChannel(Snowflake snowflake, IChatContext context) { +IGuildChannel? snowflakeToGuildChannel(Snowflake snowflake, IChatContext context) { if (context.guild != null) { try { - return context.guild!.channels - .whereType() - .firstWhere((channel) => channel.id == snowflake); + return context.guild!.channels.firstWhere((channel) => channel.id == snowflake); } on StateError { return null; } @@ -521,15 +537,14 @@ T? snowflakeToGuildChannel(Snowflake snowflake, IChatCo return null; } -T? convertGuildChannel(StringView view, IChatContext context) { +IGuildChannel? convertGuildChannel(StringView view, IChatContext context) { if (context.guild != null) { String word = view.getQuotedWord(); - Iterable channels = context.guild!.channels.whereType(); - List caseInsensitive = []; - List partial = []; + List caseInsensitive = []; + List partial = []; - for (final channel in channels) { + for (final channel in context.guild!.channels) { if (channel.name.toLowerCase() == word.toLowerCase()) { caseInsensitive.add(channel); } @@ -548,50 +563,90 @@ T? convertGuildChannel(StringView view, IChatContext co return null; } +/// A converter that converts input to one or more types of [IGuildChannel]s. +/// +/// This converter will only allow users to select channels of one of the types in [channelTypes], +/// and then will further only accept channels of type `T`. +/// +/// You might also be interested in: +/// - [guildChannelConverter], a converter for all [IGuildChannel]s; +/// - [textGuildChannelConverter], a converter for [ITextGuildChannel]s; +/// - [voiceGuildChannelConverter], a converter for [IVoiceGuildChannel]s; +/// - [categoryGuildChannelConverter], a converter for [ICategoryGuildChannel]s; +/// - [stageVoiceChannelConverter], a converter for [IStageVoiceGuildChannel]s. +class GuildChannelConverter implements Converter { + /// The types of channels this converter allows users to select. + /// + /// If this is `null`, all channel types can be selected. Note that only channels which match both + /// these types *and* `T` will be parsed by this converter. + final List? channelTypes; + + final FallbackConverter _internal = const FallbackConverter( + [ + CombineConverter(snowflakeConverter, snowflakeToGuildChannel), + Converter(convertGuildChannel), + ], + type: CommandOptionType.channel, + ); + + /// Create a new [GuildChannelConverter]. + const GuildChannelConverter(this.channelTypes); + + @override + Iterable get choices => []; + + @override + FutureOr Function(StringView, IChatContext) get convert => (view, context) async { + IGuildChannel? channel = await _internal.convert(view, context); + + if (channel is T) { + return channel; + } + + return null; + }; + + @override + void Function(CommandOptionBuilder) get processOptionCallback => + (builder) => builder.channelTypes = channelTypes; + + @override + Type get output => T; + + @override + CommandOptionType get type => CommandOptionType.channel; +} + /// A converter that converts input to an [IGuildChannel]. /// /// This will first attempt to parse the input as a [Snowflake] that will then be converted to an /// [IGuildChannel]. If this fails, the channel will be looked up by name in the current guild. /// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel]. -const Converter guildChannelConverter = FallbackConverter( - [ - CombineConverter( - snowflakeConverter, snowflakeToGuildChannel), - Converter(convertGuildChannel), - ], - type: CommandOptionType.channel, -); +/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel] and is +/// set to accept all channel types. +const GuildChannelConverter guildChannelConverter = GuildChannelConverter(null); /// A converter that converts input to an [ITextGuildChannel]. /// /// This will first attempt to parse the input as a [Snowflake] that will then be converted to an /// [ITextGuildChannel]. If this fails, the channel will be looked up by name in the current guild. /// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel]. -const Converter textGuildChannelConverter = FallbackConverter( - [ - CombineConverter( - snowflakeConverter, snowflakeToGuildChannel), - Converter(convertGuildChannel), - ], - type: CommandOptionType.channel, -); +/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel] and is +/// set to accept channels of type [ChannelType.text]. +const GuildChannelConverter textGuildChannelConverter = GuildChannelConverter([ + ChannelType.text, +]); /// A converter that converts input to an [IVoiceGuildChannel]. /// /// This will first attempt to parse the input as a [Snowflake] that will then be converted to an /// [IVoiceGuildChannel]. If this fails, the channel will be looked up by name in the current guild. /// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel]. -const Converter voiceGuildChannelConverter = FallbackConverter( - [ - CombineConverter( - snowflakeConverter, snowflakeToGuildChannel), - Converter(convertGuildChannel), - ], - type: CommandOptionType.channel, -); +/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel] and is +/// set to accept channels of type [ChannelType.voice]. +const GuildChannelConverter voiceGuildChannelConverter = GuildChannelConverter([ + ChannelType.voice, +]); /// A converter that converts input to an [ICategoryGuildChannel]. /// @@ -599,15 +654,12 @@ const Converter voiceGuildChannelConverter = FallbackConvert /// [ICategoryGuildChannel]. If this fails, the channel will be looked up by name in the current /// guild. /// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel]. -const Converter categoryGuildChannelConverter = FallbackConverter( - [ - CombineConverter( - snowflakeConverter, snowflakeToGuildChannel), - Converter(convertGuildChannel), - ], - type: CommandOptionType.channel, -); +/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel] and it +/// set to accept channels of type [ChannelType.category]. +const GuildChannelConverter categoryGuildChannelConverter = + GuildChannelConverter([ + ChannelType.category, +]); /// A converter that converts input to an [IStageVoiceGuildChannel]. /// @@ -615,15 +667,12 @@ const Converter categoryGuildChannelConverter = FallbackC /// [IStageVoiceGuildChannel]. If this fails, the channel will be looked up by name in the current /// guild. /// -/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel]. -const Converter stageVoiceChannelConverter = FallbackConverter( - [ - CombineConverter( - snowflakeConverter, snowflakeToGuildChannel), - Converter(convertGuildChannel), - ], - type: CommandOptionType.channel, -); +/// This converter has a Discord Slash Command argument type of [CommandOptionType.channel] and is +/// set to accept channels of type [ChannelType.guildStage]. +const GuildChannelConverter stageVoiceChannelConverter = + GuildChannelConverter([ + ChannelType.guildStage, +]); FutureOr snowflakeToRole(Snowflake snowflake, IChatContext context) { if (context.guild != null) { @@ -821,6 +870,7 @@ void registerDefaultConverters(CommandsPlugin commands) { ..addConverter(guildChannelConverter) ..addConverter(textGuildChannelConverter) ..addConverter(voiceGuildChannelConverter) + ..addConverter(categoryGuildChannelConverter) ..addConverter(stageVoiceChannelConverter) ..addConverter(roleConverter) ..addConverter(mentionableConverter) From 8c2b66dca3784ac74a6f6ae77963330f1ba130b9 Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Thu, 7 Apr 2022 13:34:50 +0200 Subject: [PATCH 08/11] Implement support for autocompletion (#45) * Update command name regex * Implement autocomplete support * Add @Autocomplete annotation * Correct capitalisation of autocompleteCallback * Correct @Autocomplete documentation --- lib/nyxx_commands.dart | 6 +- lib/src/commands.dart | 107 ++++++++++++++++++++-- lib/src/commands/chat_command.dart | 9 ++ lib/src/context/autocomplete_context.dart | 78 ++++++++++++++++ lib/src/context/context.dart | 27 ++++-- lib/src/context/interaction_context.dart | 21 ++++- lib/src/converters/converter.dart | 30 +++++- lib/src/errors.dart | 23 +++++ lib/src/util/util.dart | 41 ++++++++- 9 files changed, 317 insertions(+), 25 deletions(-) create mode 100644 lib/src/context/autocomplete_context.dart diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index 148921a..ce7e562 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -25,10 +25,11 @@ export 'src/commands/interfaces.dart' export 'src/commands/message_command.dart' show MessageCommand; export 'src/commands/options.dart' show CommandOptions; export 'src/commands/user_command.dart' show UserCommand; +export 'src/context/autocomplete_context.dart' show AutocompleteContext; export 'src/context/chat_context.dart' show IChatContext, InteractionChatContext, MessageChatContext; -export 'src/context/context.dart' show IContext; -export 'src/context/interaction_context.dart' show IInteractionContext; +export 'src/context/context.dart' show IContext, IContextBase; +export 'src/context/interaction_context.dart' show IInteractionContext, IInteractionContextBase; export 'src/context/message_context.dart' show MessageContext; export 'src/context/user_context.dart' show UserContext; export 'src/converters/converter.dart' @@ -56,6 +57,7 @@ export 'src/converters/converter.dart' parse; export 'src/errors.dart' show + AutocompleteFailedException, BadInputException, CheckFailedException, CommandInvocationException, diff --git a/lib/src/commands.dart b/lib/src/commands.dart index ef35143..991d34b 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -25,12 +25,14 @@ import 'commands/interfaces.dart'; import 'commands/message_command.dart'; import 'commands/user_command.dart'; import 'context/chat_context.dart'; +import 'context/autocomplete_context.dart'; import 'context/context.dart'; import 'context/message_context.dart'; import 'context/user_context.dart'; import 'converters/converter.dart'; import 'errors.dart'; import 'options.dart'; +import 'util/util.dart'; import 'util/view.dart'; final Logger logger = Logger('Commands'); @@ -327,6 +329,28 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { } } + Future _processAutocompleteInteraction( + IAutocompleteInteractionEvent interactionEvent, + FutureOr?> Function(AutocompleteContext) callback, + ChatCommand command, + ) async { + try { + AutocompleteContext context = await _autocompleteContext(interactionEvent, command); + + try { + Iterable? choices = await callback(context); + + if (choices != null) { + interactionEvent.respond(choices.toList()); + } + } on Exception catch (e) { + throw AutocompleteFailedException(e, context); + } + } on CommandsException catch (e) { + _onCommandErrorController.add(e); + } + } + Future _messageChatContext( IMessage message, StringView contentView, String prefix) async { ChatCommand command = getCommand(contentView) ?? (throw CommandNotFoundException(contentView)); @@ -452,6 +476,35 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { ); } + Future _autocompleteContext( + IAutocompleteInteractionEvent interactionEvent, + ChatCommand command, + ) async { + ISlashCommandInteraction interaction = interactionEvent.interaction; + + IMember? member = interaction.memberAuthor; + IUser user; + if (member != null) { + user = await member.user.getOrDownload(); + } else { + user = interaction.userAuthor!; + } + + return AutocompleteContext( + commands: this, + guild: await interaction.guild?.getOrDownload(), + channel: await interaction.channel.getOrDownload(), + member: member, + user: user, + command: command, + client: client!, + interaction: interaction, + interactionEvent: interactionEvent, + option: interactionEvent.focusedOption, + currentValue: interactionEvent.focusedOption.value.toString(), + ); + } + Future> _getSlashBuilders() async { List builders = []; @@ -510,6 +563,8 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { if (command is ChatCommand) { builder.registerHandler((interaction) => _processChatInteraction(interaction, command)); + + _processAutocompleteHandlerRegistration(builder.options, command); } builders.add(builder); @@ -593,12 +648,12 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { ) { for (final builder in options) { if (builder.type == CommandOptionType.subCommand) { - builder.registerHandler( - (interaction) => _processChatInteraction( - interaction, - current.children.where((child) => child.name == builder.name).first as ChatCommand, - ), - ); + ChatCommand command = + current.children.where((child) => child.name == builder.name).first as ChatCommand; + + builder.registerHandler((interaction) => _processChatInteraction(interaction, command)); + + _processAutocompleteHandlerRegistration(builder.options!, command); } else if (builder.type == CommandOptionType.subCommandGroup) { _processHandlerRegistration( builder.options!, @@ -610,6 +665,46 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { return options; } + void _processAutocompleteHandlerRegistration( + Iterable options, + ChatCommand command, + ) { + Iterator builderIterator = options.iterator; + Iterator argumentTypeIterator = command.argumentTypes.iterator; + + MethodMirror mirror = (reflect(command.execute) as ClosureMirror).function; + + // Skip context argument + Iterable autocompleters = mirror.parameters.skip(1).map((parameter) { + Iterable annotations = parameter.metadata + .where((metadataMirror) => metadataMirror.hasReflectee) + .map((metadataMirror) => metadataMirror.reflectee) + .whereType(); + + if (annotations.isNotEmpty) { + return annotations.first; + } + + return null; + }); + + Iterator autocompletersIterator = autocompleters.iterator; + + while (builderIterator.moveNext() && + argumentTypeIterator.moveNext() && + autocompletersIterator.moveNext()) { + FutureOr?> Function(AutocompleteContext)? autocompleteCallback = + autocompletersIterator.current?.callback; + + autocompleteCallback ??= getConverter(argumentTypeIterator.current)?.autocompleteCallback; + + if (autocompleteCallback != null) { + builderIterator.current.registerAutocompleteHandler( + (event) => _processAutocompleteInteraction(event, autocompleteCallback!, command)); + } + } + } + /// Adds a converter to this [CommandsPlugin]. /// /// Converters can be used to convert user input ([String]s) to the type required by the command's diff --git a/lib/src/commands/chat_command.dart b/lib/src/commands/chat_command.dart index 83576c9..a5ab988 100644 --- a/lib/src/commands/chat_command.dart +++ b/lib/src/commands/chat_command.dart @@ -314,6 +314,9 @@ class ChatCommand /// - [checks] and [check], the equivalent for inherited checks. final List singleChecks = []; + /// The types of the required and positional arguments of [execute], in the order they appear. + final List argumentTypes = []; + @override final CommandOptions options; @@ -481,9 +484,15 @@ class ChatCommand throw CommandRegistrationError( 'Command callback parameters must not have more than one UseConverter annotation'); } + if (parametrer.metadata.where((element) => element.reflectee is Autocomplete).length > 1) { + throw CommandRegistrationError( + 'Command callback parameters must not have more than one UseConverter annotation'); + } } for (final argument in _arguments) { + argumentTypes.add(argument.type.reflectedType); + Iterable names = argument.metadata .where((element) => element.reflectee is Name) .map((nameMirror) => nameMirror.reflectee) diff --git a/lib/src/context/autocomplete_context.dart b/lib/src/context/autocomplete_context.dart new file mode 100644 index 0000000..3e96007 --- /dev/null +++ b/lib/src/context/autocomplete_context.dart @@ -0,0 +1,78 @@ +// Copyright 2021 Abitofevrything and others. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_interactions/nyxx_interactions.dart'; + +import '../commands.dart'; +import '../commands/chat_command.dart'; +import 'context.dart'; +import 'interaction_context.dart'; + +/// Represents a context in which an autocomplete event was triggered. +class AutocompleteContext implements IContextBase, IInteractionContextBase { + @override + final CommandsPlugin commands; + + @override + final IGuild? guild; + + @override + final ITextChannel channel; + + @override + final IMember? member; + + @override + final IUser user; + + @override + final ChatCommand command; + + @override + final INyxx client; + + @override + final ISlashCommandInteraction interaction; + + @override + final IAutocompleteInteractionEvent interactionEvent; + + /// The option that the user is currently filling in. + /// + /// Other options might have already been filled in and are accessible through [interactionEvent]. + final IInteractionOption option; + + /// The value the user has put in [option] so far. + /// + /// This can be empty. It will generally not contain malformed data, but care should still be + /// taken. Read [the official documentation](https://discord.com/developers/docs/interactions/application-commands#autocomplete) + /// for more. + final String currentValue; + + /// Create a new [AutocompleteContext]. + AutocompleteContext({ + required this.commands, + required this.guild, + required this.channel, + required this.member, + required this.user, + required this.command, + required this.client, + required this.interaction, + required this.interactionEvent, + required this.option, + required this.currentValue, + }); +} diff --git a/lib/src/context/context.dart b/lib/src/context/context.dart index 0210e06..a4d6136 100644 --- a/lib/src/context/context.dart +++ b/lib/src/context/context.dart @@ -18,32 +18,39 @@ import 'package:nyxx_interactions/nyxx_interactions.dart'; import '../commands.dart'; import '../commands/interfaces.dart'; -/// A context in which a command was executed. +/// The base class for all contexts in nyxx_commands. /// -/// Contains data about how and where the command was executed, and provides a simple interfaces for -/// responding to commands. -abstract class IContext { +/// Contains data that all contexts provide. +// TODO: Rename this class to IContext +abstract class IContextBase { /// The instance of [CommandsPlugin] which created this context. CommandsPlugin get commands; - /// The guild in which the command was executed, or `null` if invoked outside of a guild. + /// The guild in which the context was created, or `null` if created outside of a guild. IGuild? get guild; - /// The channel in which the command was executed. + /// The channel in which the context was created. ITextChannel get channel; - /// The member that executed the command, or `null` if invoked outside of a guild. + /// The member that triggered this context's created, or `null` if created outside of a guild. IMember? get member; - /// The user that executed the command. + /// The user that triggered this context's creation. IUser get user; - /// The command that was executed. + /// The command that was executed or is being processed. ICommand get command; - /// The client that emitted the event triggering this command. + /// The client that emitted the event triggering this context's creation. INyxx get client; +} +/// A context in which a command was executed. +/// +/// Contains data about how and where the command was executed, and provides a simple interfaces for +/// responding to commands. +// TODO: rename this class to ICommandContext (to differentiate from AutocompleteContext) +abstract class IContext implements IContextBase { /// Send a response to the command. /// /// If [private] is set to `true`, then the response will only be made visible to the user that diff --git a/lib/src/context/interaction_context.dart b/lib/src/context/interaction_context.dart index 124f555..1ff4a4a 100644 --- a/lib/src/context/interaction_context.dart +++ b/lib/src/context/interaction_context.dart @@ -17,14 +17,22 @@ import 'package:nyxx_interactions/nyxx_interactions.dart'; import '../context/context.dart'; -/// Represents a context that originated from an interaction. -abstract class IInteractionContext implements IContext { - /// The interaction that triggered the commands execution. +/// The base class for all interaction-triggered contexts in nyxx_ccommands. +/// +/// Contains data allowing access to the underlying interaction that triggered the context's +/// creation. +// TODO: Rename this class to IInteractionContext +abstract class IInteractionContextBase { + /// The interaction that triggered this context's creation. ISlashCommandInteraction get interaction; - /// The interaction event that triggered this commands execution. - ISlashCommandInteractionEvent get interactionEvent; + /// The interaction event that triggered this context's creation. + InteractionEventAbstract get interactionEvent; +} +/// Represents a context that originated from an interaction. +// TODO: Rename this class to IInterationCommandContext +abstract class IInteractionContext implements IContext, IInteractionContextBase { /// Send a response to the command. /// /// If [private] is set to `true`, then the response will only be made visible to the user that @@ -49,6 +57,9 @@ abstract class IInteractionContext implements IContext { /// You might also be interested in: /// - [respond], for sending a full response. Future acknowledge({bool? hidden}); + + @override + ISlashCommandInteractionEvent get interactionEvent; } mixin InteractionContextMixin implements IInteractionContext { diff --git a/lib/src/converters/converter.dart b/lib/src/converters/converter.dart index 2295c65..a262a3c 100644 --- a/lib/src/converters/converter.dart +++ b/lib/src/converters/converter.dart @@ -18,6 +18,7 @@ import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; import '../commands.dart'; +import '../context/autocomplete_context.dart'; import '../context/chat_context.dart'; import '../errors.dart'; import '../util/view.dart'; @@ -88,6 +89,16 @@ class Converter { /// Can be used to make custom changes to the builder that are not implemented by default. final void Function(CommandOptionBuilder)? processOptionCallback; + /// A function called to provide [autocompletion](https://discord.com/developers/docs/interactions/application-commands#autocomplete) + /// for arguments of this type. + /// + /// This function should return an iterable of options the user can select from, or `null` to + /// indicate failure. In the event of a failure, the user will see a "options failed to load" + /// message in their client. + /// + /// This function should return at most 25 results and should not throw. + final FutureOr?> Function(AutocompleteContext)? autocompleteCallback; + /// Create a new converter. /// /// Strongly typing converter variables is recommended (i.e use `Converter(...)` instead @@ -96,6 +107,7 @@ class Converter { this.convert, { this.choices, this.processOptionCallback, + this.autocompleteCallback, this.type = CommandOptionType.string, }) : output = T; @@ -130,6 +142,12 @@ class CombineConverter implements Converter { void Function(CommandOptionBuilder)? get processOptionCallback => _customProcessOptionCallback ?? converter.processOptionCallback; + final FutureOr?> Function(AutocompleteContext)? _autocompleteCallback; + + @override + FutureOr?> Function(AutocompleteContext)? get autocompleteCallback => + _autocompleteCallback ?? converter.autocompleteCallback; + final Iterable? _choices; final CommandOptionType? _type; @@ -140,10 +158,12 @@ class CombineConverter implements Converter { Iterable? choices, CommandOptionType? type, void Function(CommandOptionBuilder)? processOptionCallback, + FutureOr?> Function(AutocompleteContext)? autocompleteCallback, }) : _choices = choices, _type = type, output = T, - _customProcessOptionCallback = processOptionCallback; + _customProcessOptionCallback = processOptionCallback, + _autocompleteCallback = autocompleteCallback; @override Iterable? get choices => _choices ?? converter.choices; @@ -182,6 +202,9 @@ class FallbackConverter implements Converter { @override final void Function(CommandOptionBuilder)? processOptionCallback; + @override + final FutureOr?> Function(AutocompleteContext)? autocompleteCallback; + final Iterable? _choices; final CommandOptionType? _type; @@ -194,6 +217,7 @@ class FallbackConverter implements Converter { Iterable? choices, CommandOptionType? type, this.processOptionCallback, + this.autocompleteCallback, }) : _choices = choices, _type = type, output = T; @@ -610,6 +634,10 @@ class GuildChannelConverter implements Converter { void Function(CommandOptionBuilder) get processOptionCallback => (builder) => builder.channelTypes = channelTypes; + @override + FutureOr?> Function(AutocompleteContext)? get autocompleteCallback => + null; + @override Type get output => T; diff --git a/lib/src/errors.dart b/lib/src/errors.dart index 3f5d915..7e17e8c 100644 --- a/lib/src/errors.dart +++ b/lib/src/errors.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'checks/checks.dart'; +import 'context/autocomplete_context.dart'; import 'context/chat_context.dart'; import 'context/context.dart'; import 'util/view.dart'; @@ -49,6 +50,28 @@ class CommandInvocationException extends CommandsException { CommandInvocationException(String message, this.context) : super(message); } +/// A wrapper class for an exception that caused an autocomplete event to fail. +/// +/// This generally indicates incorrect or slow code inside an autocomplete callback, and the +/// developer should try to fix the issue. +/// +/// If you are throwing exceptions to indicate autocomplete failure, consider returning `null` +/// instead. +class AutocompleteFailedException extends CommandsException { + /// The context in which the exception occurred. + /// + /// If the exception was not triggered by a slow response, default options can still be returned + /// by accessing the [AutocompleteContext.interactionEvent] and calling + /// [IAutocompleteInteractionEvent.respond] with the default options. + final AutocompleteContext context; + + /// The exception that occurred. + final Exception exception; + + /// Create a new [AutocompleteFailedException]. + AutocompleteFailedException(this.exception, this.context) : super(exception.toString()); +} + /// A wrapper class for an exception that was thrown inside the [ICommand.execute] callback. /// /// This generally indicates incorrect or incomplete code inside a command callback, and the diff --git a/lib/src/util/util.dart b/lib/src/util/util.dart index 68a9e60..48cd885 100644 --- a/lib/src/util/util.dart +++ b/lib/src/util/util.dart @@ -12,9 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:async'; + import 'package:nyxx/nyxx.dart'; import 'package:nyxx_interactions/nyxx_interactions.dart'; +import '../context/autocomplete_context.dart'; import '../converters/converter.dart'; import 'view.dart'; @@ -184,6 +187,39 @@ class UseConverter { String toString() => 'UseConverter[converter=$converter]'; } +/// An annotation used to override the callback used to handle autocomplete events for a specific +/// argument. +/// +/// For example, using the top-level function `foo` as an autocomplete handler: +/// ```dart +/// ChatCommand test = ChatCommand( +/// 'test', +/// 'A test command', +/// ( +/// IChatContext context, +/// @Autocomplete(foo) String bar, +/// ) async { +/// context.respond(MessageBuilder.content(bar)); +/// }, +/// ); +/// +/// commands.addCommand(test); +/// ``` +/// +/// You might also be interested in: +/// - [Converter.autoCompleteCallback], the way to register autocomplete handlers for all arguments +/// of a given type. +class Autocomplete { + /// The autocomplete handler to use. + final FutureOr?> Function(AutocompleteContext) callback; + + /// Create a new [Autocomplete]. + /// + /// This is intended to be used as an `@Autocomplete(...)` annotation, and has no functionality as + /// a standalone class. + const Autocomplete(this.callback); +} + final RegExp _mentionPattern = RegExp(r'^<@!?([0-9]{15,20})>'); /// A wrapper function for prefixes that allows commands to be invoked with a mention prefix. @@ -241,4 +277,7 @@ String Function(IMessage) dmOr(String Function(IMessage) defaultPrefix) { /// /// For more inforrmation on naming restrictions, check the /// [Discord documentation](https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming). -final RegExp commandNameRegexp = RegExp(r'^[\w-]{1,32}$', unicode: true); +final RegExp commandNameRegexp = RegExp( + r'^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$', + unicode: true, +); From 92689bf0c85ccb1162828cbcc92435780329565c Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Sat, 9 Apr 2022 16:10:49 +0200 Subject: [PATCH 09/11] Implement default command types (#46) --- lib/src/commands.dart | 4 +-- lib/src/commands/chat_command.dart | 41 ++++++++++++++++++++++++------ lib/src/commands/options.dart | 6 +++++ lib/src/options.dart | 5 ++++ lib/src/util/mixins.dart | 7 +++++ 5 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/src/commands.dart b/lib/src/commands.dart index 991d34b..4c7b1c8 100644 --- a/lib/src/commands.dart +++ b/lib/src/commands.dart @@ -512,7 +512,7 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { for (final command in children) { if (command is IChatCommandComponent) { - if (command is ChatCommand && command.type == CommandType.textOnly) { + if (command is ChatCommand && command.resolvedType == CommandType.textOnly) { continue; } @@ -836,7 +836,7 @@ class CommandsPlugin extends BasePlugin implements ICommandGroup { if (_chatCommands.containsKey(name)) { IChatCommandComponent child = _chatCommands[name]!; - if (child is ChatCommand && child.type != CommandType.slashOnly) { + if (child is ChatCommand && child.resolvedType != CommandType.slashOnly) { ChatCommand? found = child.getCommand(view); if (found == null) { diff --git a/lib/src/commands/chat_command.dart b/lib/src/commands/chat_command.dart index a5ab988..8926314 100644 --- a/lib/src/commands/chat_command.dart +++ b/lib/src/commands/chat_command.dart @@ -57,6 +57,14 @@ enum CommandType { /// Indicates that a [ChatCommand] can be executed by both Slash Commands and text messages. all, + + /// Indicates that a [ChatCommand] should use the default type provided by [IOptions.options]. + /// + /// If the default type provided by the options is itself [def], the behaviour is identical to + /// [all]. + // TODO: Instead of having [def], make [ChatCommand.type] be a classical option + // ([ChatCommand.options.type]) and have it be inherited. + def, } mixin ChatGroupMixin implements IChatCommandComponent { @@ -126,7 +134,7 @@ mixin ChatGroupMixin implements IChatCommandComponent { if (_childrenMap.containsKey(name)) { IChatCommandComponent child = _childrenMap[name]!; - if (child is ChatCommand && child.type != CommandType.slashOnly) { + if (child is ChatCommand && child.resolvedType != CommandType.slashOnly) { ChatCommand? found = child.getCommand(view); if (found == null) { @@ -152,7 +160,8 @@ mixin ChatGroupMixin implements IChatCommandComponent { @override bool get hasSlashCommand => children.any((child) => - (child is ChatCommand && child.type != CommandType.textOnly) || child.hasSlashCommand); + (child is ChatCommand && child.resolvedType != CommandType.textOnly) || + child.hasSlashCommand); @override Iterable getOptions(CommandsPlugin commands) { @@ -166,7 +175,7 @@ mixin ChatGroupMixin implements IChatCommandComponent { child.description, options: List.of(child.getOptions(commands)), )); - } else if (child is ChatCommand && child.type != CommandType.textOnly) { + } else if (child is ChatCommand && child.resolvedType != CommandType.textOnly) { options.add(CommandOptionBuilder( CommandOptionType.subCommand, child.name, @@ -278,10 +287,26 @@ class ChatCommand /// commands executable only through Slash Commands, or only through text messages. /// /// You might also be interested in: + /// - [resolvedType], for getting the resolved type of this command. /// - [ChatCommand.slashOnly], for creating [ChatCommand]s with type [CommandType.slashOnly]; /// - [ChatCommand.textOnly], for creating [ChatCommand]s with type [CommandType.textOnly]. final CommandType type; + /// The resolved type of this [ChatCommand]. + /// + /// If [type] is [CommandType.def], this will query the parent of this command for the default + /// type. Otherwise, [type] is returned. + /// + /// If [type] is [CommandType.def] and no parent provides a default type, [CommandType.def] is + /// returned. + CommandType get resolvedType { + if (type != CommandType.def) { + return type; + } + + return resolvedOptions.defaultCommandType ?? CommandType.def; + } + /// The function called to execute this command. /// /// The argument types for the function are dynamically loaded, so you should specify the types of @@ -340,7 +365,7 @@ class ChatCommand String description, Function execute, { List aliases = const [], - CommandType type = CommandType.all, + CommandType type = CommandType.def, Iterable children = const [], Iterable checks = const [], Iterable singleChecks = const [], @@ -410,7 +435,7 @@ class ChatCommand this.execute, Type contextType, { this.aliases = const [], - this.type = CommandType.all, + this.type = CommandType.def, Iterable children = const [], Iterable checks = const [], Iterable singleChecks = const [], @@ -641,7 +666,7 @@ class ChatCommand @override Iterable getOptions(CommandsPlugin commands) { - if (type != CommandType.textOnly) { + if (resolvedType != CommandType.textOnly) { List options = []; for (final mirror in _arguments) { @@ -693,9 +718,9 @@ class ChatCommand 'All child commands of chat groups or commands must implement IChatCommandComponent'); } - if (type != CommandType.textOnly) { + if (resolvedType != CommandType.textOnly) { if (command.hasSlashCommand || - (command is ChatCommand && command.type != CommandType.textOnly)) { + (command is ChatCommand && command.resolvedType != CommandType.textOnly)) { throw CommandRegistrationError('Cannot nest Slash commands!'); } } diff --git a/lib/src/commands/options.dart b/lib/src/commands/options.dart index 757e002..a8edeb0 100644 --- a/lib/src/commands/options.dart +++ b/lib/src/commands/options.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:nyxx_commands/src/commands/chat_command.dart'; + /// Options that modify how a command behaves. /// /// You might also be interested in: @@ -60,6 +62,9 @@ class CommandOptions { /// - [IInteractionContext.respond], which can override this setting by setting the `hidden` flag. final bool? hideOriginalResponse; + /// The default [CommandType] for [ChatCommand]s that are children of this entity. + final CommandType? defaultCommandType; + /// Create a set of command options. /// /// Options set to `null` will be inherited from the parent. @@ -68,5 +73,6 @@ class CommandOptions { this.acceptBotCommands, this.acceptSelfCommands, this.hideOriginalResponse, + this.defaultCommandType, }); } diff --git a/lib/src/options.dart b/lib/src/options.dart index 750729a..6b9e201 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -14,6 +14,7 @@ import 'package:nyxx_interactions/nyxx_interactions.dart'; +import 'commands/chat_command.dart'; import 'commands/options.dart'; /// Options that modify how the [CommandsPlugin] instance works. @@ -49,6 +50,9 @@ class CommandsOptions implements CommandOptions { @override final bool hideOriginalResponse; + @override + final CommandType defaultCommandType; + /// Create a new set of [CommandsOptions]. const CommandsOptions({ this.logErrors = true, @@ -57,5 +61,6 @@ class CommandsOptions implements CommandOptions { this.acceptSelfCommands = false, this.backend, this.hideOriginalResponse = true, + this.defaultCommandType = CommandType.all, }); } diff --git a/lib/src/util/mixins.dart b/lib/src/util/mixins.dart index fe0dd88..487d58e 100644 --- a/lib/src/util/mixins.dart +++ b/lib/src/util/mixins.dart @@ -13,6 +13,7 @@ // limitations under the License. import '../checks/checks.dart'; +import '../commands/chat_command.dart'; import '../commands/interfaces.dart'; import '../commands/options.dart'; import '../context/context.dart'; @@ -64,12 +65,18 @@ mixin OptionsMixin on ICommandRegisterable implements IOp ? (parent as ICommandRegisterable).resolvedOptions : parent!.options; + CommandType? defaultCommandType; + if (options.defaultCommandType != CommandType.def) { + defaultCommandType = options.defaultCommandType; + } + return CommandOptions( autoAcknowledgeInteractions: options.autoAcknowledgeInteractions ?? parentOptions.autoAcknowledgeInteractions, acceptBotCommands: options.acceptBotCommands ?? parentOptions.acceptBotCommands, acceptSelfCommands: options.acceptSelfCommands ?? parentOptions.acceptSelfCommands, hideOriginalResponse: options.hideOriginalResponse ?? parentOptions.hideOriginalResponse, + defaultCommandType: defaultCommandType ?? parentOptions.defaultCommandType, ); } } From 56600f43fbbbcfbc9cf314514c69cee7fdee40fc Mon Sep 17 00:00:00 2001 From: Abitofevrything <54505189+abitofevrything@users.noreply.github.com> Date: Sun, 10 Apr 2022 15:16:50 +0200 Subject: [PATCH 10/11] Implement minimum and maximum values for number converters (#47) * Implement minimum and maximum values for number converters * Add note on limitations of number and channel converters --- lib/nyxx_commands.dart | 3 ++ lib/src/converters/converter.dart | 89 ++++++++++++++++++++++++++++--- pubspec.yaml | 2 +- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/lib/nyxx_commands.dart b/lib/nyxx_commands.dart index ce7e562..08f3e13 100644 --- a/lib/nyxx_commands.dart +++ b/lib/nyxx_commands.dart @@ -36,8 +36,11 @@ export 'src/converters/converter.dart' show CombineConverter, Converter, + DoubleConverter, FallbackConverter, GuildChannelConverter, + IntConverter, + NumConverter, attachmentConverter, boolConverter, categoryGuildChannelConverter, diff --git a/lib/src/converters/converter.dart b/lib/src/converters/converter.dart index a262a3c..e9db498 100644 --- a/lib/src/converters/converter.dart +++ b/lib/src/converters/converter.dart @@ -314,29 +314,97 @@ const Converter stringConverter = Converter( int? convertInt(StringView view, IChatContext context) => int.tryParse(view.getQuotedWord()); +/// A converter that converts input to various types of numbers, possibly with a minimum or maximum +/// value. +/// +/// Note: this converter does not ensure that all values will be in the range `min..max`. [min] and +/// [max] offer purely client-side validation and input from text commands is not validated beyond +/// being a valid number. +/// +/// You might also be interested in: +/// - [IntConverter], for converting [int]s; +/// - [DoubleConverter], for converting [double]s. +class NumConverter extends Converter { + /// The smallest value the user will be allowed to input in the Discord Client. + final T? min; + + /// The biggest value the user will be allows to input in the Discord Client. + final T? max; + + /// Create a new [NumConverter]. + const NumConverter( + T? Function(StringView, IChatContext) convert, { + required CommandOptionType type, + this.min, + this.max, + }) : super(convert, type: type); + + @override + void Function(CommandOptionBuilder)? get processOptionCallback => (builder) { + builder.min = min; + builder.max = max; + }; +} + +/// A converter that converts input to [int]s, possibly with a minimum or maximum value. +/// +/// Note: this converter does not ensure that all values will be in the range `min..max`. [min] and +/// [max] offer purely client-side validation and input from text commands is not validated beyond +/// being a valid integer. +/// +/// You might also be interested in: +/// - [intConverter], the default [IntConverter]. +class IntConverter extends NumConverter { + /// Create a new [IntConverter]. + const IntConverter({ + int? min, + int? max, + }) : super( + convertInt, + type: CommandOptionType.integer, + min: min, + max: max, + ); +} + /// A [Converter] that converts input to an [int]. /// /// This converter attempts to parse the next word or quoted section of the input with [int.parse]. /// /// This converter has a Discord Slash Command Argument Type of [CommandOptionType.integer]. -const Converter intConverter = Converter( - convertInt, - type: CommandOptionType.integer, -); +const Converter intConverter = IntConverter(); double? convertDouble(StringView view, IChatContext context) => double.tryParse(view.getQuotedWord()); +/// A converter that converts input to [double]s, possibly with a minimum or maximum value. +/// +/// Note: this converter does not ensure that all values will be in the range `min..max`. [min] and +/// [max] offer purely client-side validation and input from text commands is not validated beyond +/// being a valid double. +/// +/// You might also be interested in: +/// - [doubleConverter], the default [DoubleConverter]. +class DoubleConverter extends NumConverter { + /// Create a new [DoubleConverter]. + const DoubleConverter({ + double? min, + double? max, + }) : super( + convertDouble, + type: CommandOptionType.integer, + min: min, + max: max, + ); +} + /// A [Converter] that converts input to a [double]. /// /// This converter attempts to parse the next word or quoted section of the input with /// [double.parse]. /// /// This converter has a Discord Slash Command Argument Type of [CommandOptionType.number]. -const Converter doubleConverter = Converter( - convertDouble, - type: CommandOptionType.number, -); +const Converter doubleConverter = DoubleConverter(); bool? convertBool(StringView view, IChatContext context) { String word = view.getQuotedWord(); @@ -592,6 +660,11 @@ IGuildChannel? convertGuildChannel(StringView view, IChatContext context) { /// This converter will only allow users to select channels of one of the types in [channelTypes], /// and then will further only accept channels of type `T`. /// +/// +/// Note: this converter does not ensure that all values will conform to [channelTypes]. +/// [channelTypes] offers purely client-side validation and input from text commands will not be +/// validated beyond being assignable to `T`. +/// /// You might also be interested in: /// - [guildChannelConverter], a converter for all [IGuildChannel]s; /// - [textGuildChannelConverter], a converter for [ITextGuildChannel]s; diff --git a/pubspec.yaml b/pubspec.yaml index d6bd0ec..a3e1fde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: logging: ^1.0.2 meta: ^1.7.0 nyxx: ^3.0.0 - nyxx_interactions: ^4.0.0 + nyxx_interactions: ^4.1.0 random_string: ^2.3.1 dev_dependencies: From defaf781daf4d3ab2e45234196365d78b655d49f Mon Sep 17 00:00:00 2001 From: Abitofevrything Date: Sun, 10 Apr 2022 19:58:59 +0200 Subject: [PATCH 11/11] Release 4.1.0 --- CHANGELOG.md | 17 +++++++++++++++++ pubspec.yaml | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 106cb8e..56ae018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## 4.1.0 +__New features__: +- Support for autocompletion has been added. See `Converter.autocompleteCallback` and the `@Autocomplete(...)` annotation for more. +- Added the ability to allow only slash commands or disable them entirely. See `CommandType.def` and `CommandOptions.defaultCommandType` for more. +- Added `ChatCommand.argumentTypes`, which allows developers to access the argument types for a chat command callback. +- Added `Converter.processOptionCallback`, which allows developers to modify the builder generated for a command argument. +- Added `IntConverter`, `DoubleConverter` and `NumConverter` for converting numbers with custom bounds. These new classes allow you to specify a minimum and maximum value for an argument when used with `@UseConverter(...)`. +- Added `GUildChannelConverter` for converting more specific types of guild channels. + +__Bug fixes__: +- Fixed an issue with `IContext.getButtonPress` not behaving correectly when `authorOnly` or `timeout` was specified. +- Fixed the default converters for guild channels accepting all channels in the Discord UI even if they were not the correct type. + +__Miscellaneous__: +- Updated the command name validation regex. +- Bump `nyxx_interactions` to 4.1.0. + ## 4.0.0 __Breaking changes__: - `nyxx_interactions` has been upgraded to 4.0.0. diff --git a/pubspec.yaml b/pubspec.yaml index a3e1fde..c39b5ea 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: nyxx_commands -version: 4.0.0 +version: 4.1.0 description: A framework for easily creating slash commands and text commands for Discord using the nyxx library. homepage: https://github.com/nyxx-discord/nyxx_commands/blob/main/README.md